From 317c0a7e0c63acf32c6f6717bd9afd05e752cb03 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 14:45:54 +0100 Subject: [PATCH 01/77] feat(ui): add panelResize geometry for edge and corner resizing Pure box arithmetic for resizing a floating panel from any of the eight compass handles: the named edges follow the pointer, the opposite edge stays pinned, and minimum size plus the viewport margin win. Owned by the same boundary groups as PanelPositionControls, which wires it next. Co-Authored-By: Claude Fable 5.1 --- scripts/format-scope.json | 1 + scripts/package-boundaries.json | 3 ++ src/ui/panelResize.js | 65 +++++++++++++++++++++++++++++++++ src/ui/panelResize.test.mjs | 64 ++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 src/ui/panelResize.js create mode 100644 src/ui/panelResize.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index caffd41d2..cc248f38d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -147,6 +147,7 @@ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", "src/ui/rightPanelRail.js", + "src/ui/panelResize.test.mjs", "src/ui/panelMeasurement.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 433f5f36a..8e6cda2cd 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -553,6 +553,7 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", + "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/radio.js", @@ -1670,6 +1671,7 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", + "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/radio.js", @@ -2327,6 +2329,7 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", + "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/rightPanelRail.js", diff --git a/src/ui/panelResize.js b/src/ui/panelResize.js new file mode 100644 index 000000000..4472e7a01 --- /dev/null +++ b/src/ui/panelResize.js @@ -0,0 +1,65 @@ +/** + * Pure geometry for resizing a floating panel from any edge or corner. The + * DOM wiring lives in PanelPositionControls; this module only decides boxes. + */ + +/** Viewport margin a resized panel keeps clear on every side. */ +const EDGE_MARGIN_PX = 6; +/** Resize directions: compass edges first, then corners. */ +export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se']; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +/** + * Work out the box a resize gesture produces. Edges named in `dir` move with + * the pointer; the opposite edge stays put, so growing from the left keeps + * the right side pinned. Minimum size and the viewport margin win over the + * pointer. + * @param {{left: number, top: number, width: number, height: number}} box Box at gesture start. + * @param {string} dir One of RESIZE_DIRECTIONS. + * @param {number} dx Pointer travel since gesture start. + * @param {number} dy Pointer travel since gesture start. + * @param {object} [limits] + * @param {number} limits.minWidth + * @param {number} limits.minHeight + * @param {number} [limits.viewportWidth=Infinity] + * @param {number} [limits.viewportHeight=Infinity] + * @param {number} [limits.margin=6] + * @returns {{left: number, top: number, width: number, height: number}} + */ +export function resizeBox( + box, + dir, + dx, + dy, + { + minWidth, + minHeight, + viewportWidth = Infinity, + viewportHeight = Infinity, + margin = EDGE_MARGIN_PX, + } = {}, +) { + let { left, top, width, height } = box; + const right = box.left + box.width; + const bottom = box.top + box.height; + if (dir.includes('e')) + width = clamp(box.width + dx, minWidth, viewportWidth - margin - box.left); + if (dir.includes('s')) + height = clamp( + box.height + dy, + minHeight, + viewportHeight - margin - box.top, + ); + if (dir.includes('w')) { + width = clamp(box.width - dx, minWidth, right - margin); + left = right - width; + } + if (dir.includes('n')) { + height = clamp(box.height - dy, minHeight, bottom - margin); + top = bottom - height; + } + return { left, top, width, height }; +} diff --git a/src/ui/panelResize.test.mjs b/src/ui/panelResize.test.mjs new file mode 100644 index 000000000..3e1577665 --- /dev/null +++ b/src/ui/panelResize.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resizeBox } from './panelResize.js'; + +const box = { left: 100, top: 100, width: 400, height: 300 }; +const limits = { + minWidth: 200, + minHeight: 150, + viewportWidth: 1000, + viewportHeight: 800, +}; + +test('east and south edges grow away from the anchored corner', () => { + assert.deepEqual(resizeBox(box, 'se', 50, 20, limits), { + left: 100, + top: 100, + width: 450, + height: 320, + }); + assert.deepEqual(resizeBox(box, 'e', -30, 999, limits), { + left: 100, + top: 100, + width: 370, + height: 300, + }); +}); + +test('west and north edges keep the opposite edge pinned', () => { + assert.deepEqual(resizeBox(box, 'w', -40, 0, limits), { + left: 60, + top: 100, + width: 440, + height: 300, + }); + assert.deepEqual(resizeBox(box, 'nw', 40, 30, limits), { + left: 140, + top: 130, + width: 360, + height: 270, + }); + assert.deepEqual( + resizeBox(box, 'n', -50, 0, limits).top, + 100, + 'horizontal motion does not move a north edge', + ); +}); + +test('minimum size stops the moving edge, not the anchored one', () => { + const small = resizeBox(box, 'w', 350, 0, limits); + assert.deepEqual(small, { left: 300, top: 100, width: 200, height: 300 }); + assert.equal(resizeBox(box, 'ne', 0, 400, limits).height, 150); + assert.equal(resizeBox(box, 'ne', 0, 400, limits).top, 250); +}); + +test('the viewport margin caps growth in every direction', () => { + assert.equal(resizeBox(box, 'e', 2000, 0, limits).width, 1000 - 6 - 100); + assert.equal(resizeBox(box, 's', 0, 2000, limits).height, 800 - 6 - 100); + const west = resizeBox(box, 'w', -2000, 0, limits); + assert.equal(west.left, 6); + assert.equal(west.left + west.width, 500); + const north = resizeBox(box, 'n', 0, -2000, limits); + assert.equal(north.top, 6); + assert.equal(north.top + north.height, 400); +}); From 96a1e710eab7a7bfa6ba26589740cca8c6b6c592 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:06:29 +0100 Subject: [PATCH 02/77] feat(ui): portable, resizable rail panels for CCTV A drag spec can now be `portable`: dragging the panel by its header lifts it out of the right rail into a fixed window, resizable from all four edges and corners, that remembers its place and size under the existing godsEyeView.v8.panelPos. key ({ left, top, width?, height?, floating }) and snaps back on a header double-click. Only CCTV opts in here; the rail skips .panel-floating panels when measuring and allocating, and its placement rules in layers.css are guarded so no !important is needed. PanelPositionControls gains an optional onPanelResized hook, threaded through PanelChrome to a no-op on the application shell. Co-Authored-By: Claude Fable 5.1 --- src/rightRailPolicy.test.mjs | 6 +- src/ui/applicationShell.js | 10 + src/ui/panelChrome.js | 2 + src/ui/panelPositionControls.js | 390 ++++++++++++++++++++++++++++++-- src/ui/panelRails.test.mjs | 5 +- src/ui/rightPanelRail.js | 4 +- src/ui/styles/controls.css | 110 +++++++++ src/ui/styles/layers.css | 20 +- src/ui/styles/responsive.css | 4 +- 9 files changed, 525 insertions(+), 26 deletions(-) diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index 2254ea24e..b727e6d12 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -45,7 +45,11 @@ test('desktop Display participates in Tactical exclusivity without changing mobi /panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/, ); assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); - assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); + // A panel floating out of the rail (portable mode) is exempt from exclusivity. + assert.match( + css, + /#right-context-rail\.layout-exclusive\s*> \[data-panel-id\]\.collapsed:not\(\.panel-floating\) \{/, + ); }); test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => { diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index b22c21c3f..3a01f97fe 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -96,6 +96,7 @@ export class StyleManager extends ShellFacade { _setRadioDisclosure: (...args) => this._setRadioDisclosure(...args), _syncCctvPanelViewport: (...args) => this._syncCctvPanelViewport(...args), + _onPanelResized: (...args) => this._onPanelResized(...args), _syncContextRadioLauncherState: (...args) => this._syncContextRadioLauncherState(...args), _showToast: (...args) => this._showToast(...args), @@ -1409,6 +1410,15 @@ export class StyleManager extends ShellFacade { this._initCockpitDisplayPortal(); } + /** + * A portable panel finished a resize or snapped back to its rail. Nothing + * in the shell depends on it yet; a panel with a viewport of its own can + * refit here. + * @param {string} _panelId + * @returns {void} + */ + _onPanelResized(_panelId) {} + /** * Recalculates the CCTV panel max-height based on its current top position * and the window height, enabling internal scroll without viewport overflow. diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 2562f137b..66e469a45 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -56,6 +56,7 @@ export class PanelChrome { layoutRightPanels: () => this._layoutRightPanels(), syncCctvPanelViewport: () => this._syncCctvPanelViewport(), showToast: (message) => this._showToast(message), + onPanelResized: (panelId) => this._onPanelResized(panelId), }); this._panelLayout = new PanelLayoutController({ readHud: () => ({ @@ -139,6 +140,7 @@ export class PanelChrome { this._initCommandDockPins(); this._initCommandDockTrayMetrics(); this._maybeNotifyLayoutReset(); + this._panelPosition._initPanelDrag(); } _collapsePanelOnEscape(event, panelId) { diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js index 40d1c319f..ca85593a5 100644 --- a/src/ui/panelPositionControls.js +++ b/src/ui/panelPositionControls.js @@ -1,4 +1,6 @@ /** Own panel position preferences, viewport clamping and drag listeners. */ +import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js'; + /** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; /** @@ -10,40 +12,93 @@ const PANEL_POSITION_STORAGE_VERSION = 'v8'; /** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */ const PANEL_Z_BASE = 100; const PANEL_Z_MAX = 139; +/** Pointer travel before a header press becomes a drag that lifts a portable panel out. */ +const DRAG_THRESHOLD_PX = 4; +/** Viewport margin every positioned panel keeps clear. */ +const VIEWPORT_MARGIN_PX = 6; +/** One-time hint shown the first time a portable panel leaves its rail. */ +const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`; +const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back'; +/** Header children whose own interaction wins over a drag or a snap-back. */ +const INTERACTIVE_SELECTOR = + 'input, select, option, textarea, button, a, [role="button"]'; +/** Inline geometry a snap-back clears so the rail owns placement again. */ +const FLOATING_STYLE_PROPERTIES = [ + 'left', + 'top', + 'right', + 'bottom', + 'width', + 'height', +]; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + export class PanelPositionControls { + /** + * @param {object} options + * @param {Function} options.syncPanelCollapseButton + * @param {Function} options.layoutRightPanels + * @param {Function} options.syncCctvPanelViewport + * @param {Function} options.showToast + * @param {(panelId: string) => void} [options.onPanelResized] Fires after a + * portable panel's resize gesture ends or the panel snaps back to its rail. + */ constructor({ syncPanelCollapseButton, layoutRightPanels, syncCctvPanelViewport, showToast, + onPanelResized = null, }) { this._syncPanelCollapseButton = syncPanelCollapseButton; this._layoutRightPanels = layoutRightPanels; this._syncCctvPanelViewport = syncCctvPanelViewport; this._showToast = showToast; + this._onPanelResized = onPanelResized; this._ppToggles = document.getElementById('pp-toggles'); this._panelZCounter = PANEL_Z_BASE + 10; this._draggableResizeObserver = null; this._cancelDrag = null; + /** Portable panels by id: `{ panel, min: { width, height } }`. */ + this._portablePanels = new Map(); + this._resizeHandles = []; + this._dragInitialized = false; this.removers = []; this.destroyed = false; } - listen(target, type, callback) { + listen(target, type, callback, options) { if (this.destroyed || !target) return; const listener = (event) => { if (!this.destroyed) callback(event); }; - target.addEventListener(type, listener); - this.removers.push(() => target.removeEventListener(type, listener)); + target.addEventListener(type, listener, options); + this.removers.push(() => + target.removeEventListener(type, listener, options), + ); } _reclampDraggablePanels() { if (this.destroyed) return; const el = this._ppToggles; - if (!el || !el.style.top || el.style.top === 'auto') return; - const top = parseInt(el.style.top, 10); - if (!Number.isFinite(top)) return; - el.style.top = `${this._clampToViewport(0, top, el).top}px`; - this._pinPanelToRight(el); + if (el && el.style.top && el.style.top !== 'auto') { + const top = parseInt(el.style.top, 10); + if (Number.isFinite(top)) { + el.style.top = `${this._clampToViewport(0, top, el).top}px`; + this._pinPanelToRight(el); + } + } + // Floating panels only move back on-screen; their size is the user's. + for (const { panel } of this._portablePanels.values()) { + if (!panel.classList.contains('panel-floating')) continue; + const left = parseInt(panel.style.left, 10); + const top = parseInt(panel.style.top, 10); + if (!Number.isFinite(left) || !Number.isFinite(top)) continue; + const next = this._clampToViewport(left, top, panel); + panel.style.left = `${next.left}px`; + panel.style.top = `${next.top}px`; + } } _maybeNotifyLayoutReset() { @@ -65,19 +120,41 @@ export class PanelPositionControls { } _initPanelDrag() { - if (this.destroyed) return; + if (this.destroyed || this._dragInitialized) return; + this._dragInitialized = true; + const cctvPanel = document.getElementById('cctv-panel'); + // A `portable` spec lifts out of its rail on a header drag, resizes from + // every edge and remembers its window; the others only reposition in + // place. Another panel opts in by adding a spec here. const dragSpecs = [ { id: 'pp-toggles', panel: this._ppToggles, handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'), }, + { + id: 'cctv-panel', + panel: cctvPanel, + handle: cctvPanel?.querySelector('.panel-header'), + portable: true, + min: { width: 300, height: 160 }, + }, ].filter(Boolean); for (const spec of dragSpecs) { if (!spec.panel || !spec.handle) continue; + if (spec.portable) { + this._portablePanels.set(spec.id, { + panel: spec.panel, + min: { width: 300, height: 160, ...spec.min }, + }); + spec.panel.classList.add('panel-portable'); + } this._restorePanelPosition(spec.id, spec.panel); this._makePanelDraggable(spec.id, spec.panel, spec.handle); + if (spec.portable) { + this._makePanelResizable(spec.id, spec.panel, spec.min); + } } // Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its // full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and @@ -88,6 +165,9 @@ export class PanelPositionControls { ); this._draggableResizeObserver.observe(this._ppToggles); } + if (this._portablePanels.size) { + this.listen(window, 'resize', () => this._reclampDraggablePanels()); + } } _panelStorageKey(panelId) { @@ -151,6 +231,27 @@ export class PanelPositionControls { const pos = JSON.parse(raw); if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') return; + // A floating record only means something to a panel that can float; a + // portable panel in turn only ever stores floating records. + const portable = this._portablePanels.get(panelId); + if (Boolean(pos.floating) !== Boolean(portable)) return; + if (portable) { + panelEl.classList.add('panel-floating', 'panel-draggable'); + if (Number.isFinite(pos.width)) { + panelEl.style.width = `${clamp( + Math.round(pos.width), + portable.min.width, + Math.max(portable.min.width, window.innerWidth - 12), + )}px`; + } + if (Number.isFinite(pos.height)) { + panelEl.style.height = `${clamp( + Math.round(pos.height), + portable.min.height, + Math.max(portable.min.height, window.innerHeight - 12), + )}px`; + } + } // Clamp to the viewport: a position saved at one window size would otherwise land off-screen at // another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too. const { left, top } = this._clampToViewport( @@ -182,13 +283,21 @@ export class PanelPositionControls { _savePanelPosition(panelId, panelEl) { const rect = panelEl.getBoundingClientRect(); + const record = { + left: Math.round(rect.left), + top: Math.round(rect.top), + }; + if (this._portablePanels.has(panelId)) { + // Only an explicitly set size is remembered: freezing a collapsed + // panel's measured height would stop it from ever expanding again. + if (panelEl.style.width) record.width = Math.round(rect.width); + if (panelEl.style.height) record.height = Math.round(rect.height); + record.floating = true; + } try { localStorage.setItem( this._panelStorageKey(panelId), - JSON.stringify({ - left: Math.round(rect.left), - top: Math.round(rect.top), - }), + JSON.stringify(record), ); } catch { // storage unavailable @@ -211,11 +320,78 @@ export class PanelPositionControls { panelEl.style.zIndex = String(this._panelZCounter); } + _maybeShowFloatHint() { + try { + if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return; + localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1'); + } catch { + // storage unavailable: still worth saying once this session + if (this._floatHintShown) return; + this._floatHintShown = true; + } + this._showToast(PANEL_FLOAT_HINT); + } + + /** + * Switch a portable panel from rail flow to a fixed window at the place it + * currently occupies. Width is frozen so a `width: 100%` rail panel keeps + * its size once fixed-positioned; height stays natural until a resize. + */ + _liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) { + if (panelEl.classList.contains('panel-floating')) return false; + panelEl.style.left = `${Math.round(rect.left)}px`; + panelEl.style.top = `${Math.round(rect.top)}px`; + panelEl.style.right = 'auto'; + panelEl.style.bottom = 'auto'; + if (!panelEl.classList.contains('collapsed')) { + panelEl.style.width = `${Math.round(rect.width)}px`; + } + // Rail bookkeeping no longer applies to a window the rail does not allocate. + panelEl.style.removeProperty('--right-panel-allocated-height'); + panelEl.removeAttribute('aria-hidden'); + panelEl.classList.add('panel-floating', 'panel-draggable'); + this._promotePanelZ(panelEl); + this._layoutRightPanels(); + this._maybeShowFloatHint(); + return true; + } + + /** Snap a floating panel back into its rail and forget its window. */ + _resetPanelPosition(panelId) { + const portable = this._portablePanels.get(panelId); + if (!portable) return; + const panelEl = portable.panel; + this._cancelDrag?.(); + panelEl.classList.remove( + 'panel-floating', + 'panel-draggable', + 'panel-dragging', + 'panel-resizing', + ); + for (const property of FLOATING_STYLE_PROPERTIES) { + panelEl.style.removeProperty(property); + } + try { + localStorage.removeItem(this._panelStorageKey(panelId)); + } catch { + // storage unavailable + } + this._layoutRightPanels(); + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + this._onPanelResized?.(panelId); + } + _makePanelDraggable(panelId, panelEl, handleEl) { // Z-order promotion: bring clicked panel to front of the stacking context this.listen(panelEl, 'pointerdown', () => { this._promotePanelZ(panelEl); }); + if (this._portablePanels.has(panelId)) { + this._makePortablePanelDraggable(panelId, panelEl, handleEl); + return; + } this.listen(handleEl, 'pointerdown', (event) => { if (event.button !== 0) return; @@ -283,11 +459,199 @@ export class PanelPositionControls { window.addEventListener('pointercancel', onUp); }); } + + /** + * Portable drag: a press on the header only becomes a drag after a few + * pixels of travel, at which point the panel lifts out of its rail. The + * collapse button still toggles on a plain click, and the click that ends + * a real drag is swallowed so it cannot toggle by accident. Double-clicking + * the header snaps the panel back. + */ + _makePortablePanelDraggable(panelId, panelEl, handleEl) { + let swallowNextClick = false; + this.listen( + handleEl, + 'click', + (event) => { + if (!swallowNextClick) return; + swallowNextClick = false; + event.stopImmediatePropagation(); + event.preventDefault(); + }, + { capture: true }, + ); + this.listen(handleEl, 'dblclick', (event) => { + if (event.target.closest?.(INTERACTIVE_SELECTOR)) return; + if (!panelEl.classList.contains('panel-floating')) return; + event.preventDefault(); + this._resetPanelPosition(panelId); + }); + this.listen(handleEl, 'pointerdown', (event) => { + if (event.button !== 0) return; + const interactive = event.target.closest?.(INTERACTIVE_SELECTOR); + if (interactive && !interactive.matches('.panel-collapse-btn')) return; + + this._cancelDrag?.(); + // A header press must not start a text selection across the page. + event.preventDefault(); + const startX = event.clientX; + const startY = event.clientY; + let dragging = false; + let offsetX = 0; + let offsetY = 0; + let width = 0; + let height = 0; + + const onMove = (moveEvent) => { + if (!dragging) { + if ( + Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) < + DRAG_THRESHOLD_PX + ) + return; + dragging = true; + const rect = panelEl.getBoundingClientRect(); + offsetX = startX - rect.left; + offsetY = startY - rect.top; + width = rect.width; + height = rect.height; + this._liftPanelOut(panelId, panelEl, rect); + panelEl.classList.add('panel-dragging'); + this._promotePanelZ(panelEl); + } + moveEvent.preventDefault(); + const maxLeft = Math.max( + VIEWPORT_MARGIN_PX, + window.innerWidth - width - VIEWPORT_MARGIN_PX, + ); + const maxTop = Math.max( + VIEWPORT_MARGIN_PX, + window.innerHeight - height - VIEWPORT_MARGIN_PX, + ); + panelEl.style.left = `${clamp(moveEvent.clientX - offsetX, VIEWPORT_MARGIN_PX, maxLeft)}px`; + panelEl.style.top = `${clamp(moveEvent.clientY - offsetY, VIEWPORT_MARGIN_PX, maxTop)}px`; + }; + + const cancel = () => { + panelEl.classList.remove('panel-dragging'); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + this._cancelDrag = null; + }; + const onUp = () => { + cancel(); + if (!dragging) return; + swallowNextClick = true; + setTimeout(() => { + swallowNextClick = false; + }, 0); + this._savePanelPosition(panelId, panelEl); + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + }; + + this._cancelDrag = cancel; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + }); + } + + /** + * Append a resize handle for every edge and corner. Seven are invisible + * strips; the bottom-right corner shows a grip. Resizing a docked panel + * lifts it out first, so the handles work in both states. + */ + _makePanelResizable(panelId, panelEl, min = {}) { + const limits = { width: 300, height: 160, ...min }; + for (const dir of RESIZE_DIRECTIONS) { + const handle = document.createElement('div'); + handle.className = + dir === 'se' ? 'panel-resize-grip' : 'panel-resize-edge'; + handle.dataset.dir = dir; + handle.setAttribute('aria-hidden', 'true'); + if (dir === 'se') { + handle.title = 'Drag to resize · double-click the header to snap back'; + } + panelEl.appendChild(handle); + this._resizeHandles.push(handle); + this.listen(handle, 'pointerdown', (event) => + this._startPanelResize(event, panelId, panelEl, dir, limits), + ); + } + } + + _startPanelResize(event, panelId, panelEl, dir, limits) { + if (event.button !== 0) return; + event.preventDefault(); + this._cancelDrag?.(); + const rect = panelEl.getBoundingClientRect(); + this._liftPanelOut(panelId, panelEl, rect); + const startBox = { + left: Math.round(rect.left), + top: Math.round(rect.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + const startX = event.clientX; + const startY = event.clientY; + const apply = ({ left, top, width, height }) => { + panelEl.style.left = `${left}px`; + panelEl.style.top = `${top}px`; + panelEl.style.width = `${width}px`; + panelEl.style.height = `${height}px`; + }; + panelEl.classList.add('panel-resizing'); + this._promotePanelZ(panelEl); + apply(startBox); + + const onMove = (moveEvent) => { + moveEvent.preventDefault(); + apply( + resizeBox( + startBox, + dir, + moveEvent.clientX - startX, + moveEvent.clientY - startY, + { + minWidth: limits.width, + minHeight: limits.height, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }, + ), + ); + }; + const cancel = () => { + panelEl.classList.remove('panel-resizing'); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + this._cancelDrag = null; + }; + const onUp = () => { + cancel(); + this._savePanelPosition(panelId, panelEl); + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + this._onPanelResized?.(panelId); + }; + + this._cancelDrag = cancel; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + } + destroy() { if (this.destroyed) return; this.destroyed = true; this._cancelDrag?.(); for (const remove of this.removers.splice(0)) remove(); + for (const handle of this._resizeHandles.splice(0)) handle.remove(); this._draggableResizeObserver?.disconnect(); this._draggableResizeObserver = null; } diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 46fbebc5f..2b03c1406 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -59,7 +59,10 @@ function element( getBoundingClientRect() { return this.rect; }, - matches: (selector) => selector === '[data-panel-id]', + matches: (selector) => + selector === '[data-panel-id]' || + (selector === '[data-panel-id]:not(.panel-floating)' && + !classes.has('panel-floating')), contains(target) { return ( target === this || this.children.some((child) => child.contains(target)) diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 3dd513d9f..1c324a523 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -41,8 +41,10 @@ export function layoutRightPanelRail({ }) { if (!stack) return; + // A panel lifted out of the rail (PanelPositionControls' portable mode) + // is a fixed window the rail neither measures nor allocates. const panels = [...stack.children].filter((panel) => - panel.matches('[data-panel-id]'), + panel.matches('[data-panel-id]:not(.panel-floating)'), ); if (!hud.visible || hud.variant !== 'tactical') { for (const panel of panels.filter((item) => diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index c86be98f1..d2df76850 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -103,6 +103,116 @@ opacity: 0.96; } +/* ── Portable rail panels ──────────────────── + A header drag lifts a portable panel (PanelPositionControls) out of its + rail into a fixed window that remembers its place and size. The rail's + own placement rules in layers.css are guarded with :not(.panel-floating), + so these only have to beat a panel's base placement (one id). */ +.panel-portable .panel-header { + cursor: grab; +} +.panel-portable.panel-dragging .panel-header { + cursor: grabbing; +} +#right-context-rail > .panel-floating { + position: fixed; + inset: auto; + max-height: calc(100vh - 12px); + margin: 0; + flex: none; + pointer-events: auto; +} +/* A collapsed window keeps its stored height for later but shows only its + header strip, so the resize handles have nothing to attach to. */ +#right-context-rail > .panel-floating.collapsed { + max-height: max-content; +} +.panel-floating[style*='height'] > .cctv-panel-inner { + box-sizing: border-box; + height: 100%; + max-height: none; +} +.panel-resize-edge, +.panel-resize-grip { + position: absolute; + z-index: 3; + touch-action: none; +} +.panel-resize-edge[data-dir='n'], +.panel-resize-edge[data-dir='s'] { + left: 10px; + right: 10px; + height: 7px; + cursor: ns-resize; +} +.panel-resize-edge[data-dir='n'] { + top: -3px; +} +.panel-resize-edge[data-dir='s'] { + bottom: -3px; +} +.panel-resize-edge[data-dir='e'], +.panel-resize-edge[data-dir='w'] { + top: 10px; + bottom: 10px; + width: 7px; + cursor: ew-resize; +} +.panel-resize-edge[data-dir='e'] { + right: -3px; +} +.panel-resize-edge[data-dir='w'] { + left: -3px; +} +.panel-resize-edge[data-dir='ne'], +.panel-resize-edge[data-dir='nw'], +.panel-resize-edge[data-dir='sw'] { + width: 14px; + height: 14px; +} +.panel-resize-edge[data-dir='ne'] { + top: -3px; + right: -3px; + cursor: nesw-resize; +} +.panel-resize-edge[data-dir='nw'] { + top: -3px; + left: -3px; + cursor: nwse-resize; +} +.panel-resize-edge[data-dir='sw'] { + bottom: -3px; + left: -3px; + cursor: nesw-resize; +} +.panel-resize-grip { + right: 3px; + bottom: 3px; + width: 14px; + height: 14px; + cursor: nwse-resize; + opacity: 0.55; + clip-path: polygon(100% 0, 100% 100%, 0 100%); + background: repeating-linear-gradient( + 135deg, + transparent 0 3px, + rgba(0, 212, 255, 0.6) 3px 4px + ); + transition: opacity var(--transition-fast); +} +.panel-portable:hover .panel-resize-grip, +.panel-resizing .panel-resize-grip { + opacity: 1; +} +.panel-collapsible.collapsed > .panel-resize-edge, +.panel-collapsible.collapsed > .panel-resize-grip { + display: none; +} +.panel-resizing, +.panel-resizing * { + user-select: none; +} + .panel-title { font-family: var(--font-mono); font-size: 9px; diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 72207fa52..79ad8c268 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -161,7 +161,7 @@ max-height var(--transition-fast); } #right-context-rail > #pp-toggles, -#right-context-rail > #cctv-panel, +#right-context-rail > #cctv-panel:not(.panel-floating), #right-context-rail > #global-context-panel { position: relative; inset: auto; @@ -173,12 +173,12 @@ max-height: 100%; pointer-events: auto; } -#right-context-rail #cctv-panel { +#right-context-rail #cctv-panel:not(.panel-floating) { width: 100%; max-height: 100%; pointer-events: auto; } -#right-context-rail #cctv-panel.collapsed { +#right-context-rail #cctv-panel.collapsed:not(.panel-floating) { width: var(--right-display-collapsed-width); margin-left: auto; } @@ -234,18 +234,22 @@ #right-context-rail.layout-focus { height: var(--right-stack-max-height); } -#right-context-rail.layout-exclusive > [data-panel-id].collapsed { +#right-context-rail.layout-exclusive + > [data-panel-id].collapsed:not(.panel-floating) { display: none; } -#right-context-rail.layout-focus > [data-panel-id].collapsed { +#right-context-rail.layout-focus + > [data-panel-id].collapsed:not(.panel-floating) { display: block; flex: 0 0 auto; } -#right-context-rail.layout-focus.layout-exclusive > [data-panel-id].collapsed { +#right-context-rail.layout-focus.layout-exclusive + > [data-panel-id].collapsed:not(.panel-floating) { display: none; } #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):not(.panel-floating), #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) { flex: 0 1 var(--right-panel-allocated-height); height: var(--right-panel-allocated-height); @@ -259,7 +263,7 @@ scrollbar-color: rgba(0, 212, 255, 0.4) transparent; } #right-context-rail.layout-focus - > #cctv-panel:not(.collapsed) + > #cctv-panel:not(.collapsed):not(.panel-floating) .cctv-panel-inner, #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index 7ebdaadae..38fe8efb3 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -202,8 +202,8 @@ } #right-context-rail #pp-toggles.collapsed .pp-header-row, - #right-context-rail #cctv-panel, - #right-context-rail #cctv-panel.collapsed, + #right-context-rail #cctv-panel:not(.panel-floating), + #right-context-rail #cctv-panel.collapsed:not(.panel-floating), #right-context-rail #radio-panel, #right-context-rail #radio-panel.collapsed, #right-context-rail #global-context-panel, From 335b4b08a2b9816d616ed079a065babe45a3ab39 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:18:21 +0100 Subject: [PATCH 03/77] test(ui): cover rail exclusion and add the panel-resize QA harness The right-rail test now proves a .panel-floating panel is neither measured, allocated nor auto-collapsed while its docked sibling still is. scripts/qa-panel-resize.mjs drives the CCTV panel in a real browser: lift-out by header drag, all eight handles with the opposite edge pinned, minimum size, reload restore and double-click snap-back. Its pure helpers sit behind a main guard so src/tooling/panelResizeQa.test.mjs can check them without a browser. Run it with `npm run qa:panel-resize`. Co-Authored-By: Claude Fable 5.1 --- package.json | 3 +- scripts/format-scope.json | 2 + scripts/qa-panel-resize.mjs | 285 +++++++++++++++++++++++++++++ src/tooling/panelResizeQa.test.mjs | 57 ++++++ src/ui/panelRails.test.mjs | 22 +++ 5 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 scripts/qa-panel-resize.mjs create mode 100644 src/tooling/panelResizeQa.test.mjs diff --git a/package.json b/package.json index 809841ba2..baeb1ba4f 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "test:track": "node scripts/track-regression.mjs", "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs", - "qa:transit": "node scripts/qa-transit.mjs" + "qa:transit": "node scripts/qa-transit.mjs", + "qa:panel-resize": "node scripts/qa-panel-resize.mjs" }, "dependencies": { "@mapbox/vector-tile": "^3.0.0", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cc248f38d..b0dfd39a0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -26,6 +26,8 @@ "src/main.js", "docs/APPLICATION.md", "scripts/qa-application.mjs", + "scripts/qa-panel-resize.mjs", + "src/tooling/panelResizeQa.test.mjs", "src/app/application.js", "src/app/application.test.mjs", "src/app/mapLifecycle.test.mjs", diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs new file mode 100644 index 000000000..fc97614e9 --- /dev/null +++ b/scripts/qa-panel-resize.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env node +/** + * Prove the portable CCTV panel in a real browser: a header drag lifts it out + * of the right rail, every resize handle moves only its own edges, minimum + * sizes hold, the window survives a reload and a header double-click snaps + * it back and forgets the stored position. + */ +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; +import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js'; + +export const PANEL_ID = 'cctv-panel'; +export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`; +export const MIN_SIZE = { width: 300, height: 160 }; +const VIEWPORT = { width: 1400, height: 900 }; +const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' }; + +/** Box edges a resize in `dir` must leave exactly where they were. */ +export function pinnedEdges(dir) { + return Object.entries(EDGE_BY_SIDE) + .filter(([side]) => !dir.includes(side)) + .map(([, edge]) => edge); +} + +/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */ +export function growthDelta(dir, amount) { + return { + dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0, + dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0, + }; +} + +/** + * Viewport point inside the handle for `dir`, given the panel's client rect. + * Edge strips straddle the border by 3px; corners and the grip sit inside. + */ +export function handlePoint(rect, dir) { + const inset = dir.length === 2 ? 4 : 1; + const x = dir.includes('w') + ? rect.left + inset + : dir.includes('e') + ? rect.right - inset + : rect.left + rect.width / 2; + const y = dir.includes('n') + ? rect.top + inset + : dir.includes('s') + ? rect.bottom - inset + : rect.top + rect.height / 2; + return { x, y }; +} + +/** Edges of `after` that moved although a resize in `dir` should have pinned them. */ +export function driftedEdges(before, after, dir, tolerance = 1.5) { + const edges = (box) => ({ + left: box.left, + top: box.top, + right: box.left + box.width, + bottom: box.top + box.height, + }); + const a = edges(before); + const b = edges(after); + return pinnedEdges(dir).filter( + (edge) => Math.abs(a[edge] - b[edge]) > tolerance, + ); +} + +function near(actual, expected, tolerance, label) { + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${label}: expected ${expected} ± ${tolerance}, got ${actual}`, + ); +} + +async function main() { + const { default: puppeteer } = await import('puppeteer'); + const args = process.argv.slice(2); + const urlIndex = args.indexOf('--url'); + const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; + const browser = await puppeteer.launch({ + headless: true, + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || + (await puppeteer.executablePath()), + args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], + }); + try { + const page = await browser.newPage(); + await page.setViewport(VIEWPORT); + const errors = []; + page.on('pageerror', (error) => errors.push(error.stack || error.message)); + + const boot = async () => { + await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.viewer, { + timeout: 90_000, + }); + await page.waitForSelector(`#${PANEL_ID} .panel-header`); + // The first-run dialog sits over the globe until a choice is made. + await page.evaluate(() => { + document.querySelector('.first-run-explore')?.click(); + }); + await page.keyboard.press('Escape'); + await page.evaluate(() => { + document.getElementById('first-run-launcher')?.remove(); + for (const node of document.querySelectorAll('[class*=first-run]')) + node.remove(); + }); + await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2); + await new Promise((resolve) => setTimeout(resolve, 400)); + }; + const readPanel = () => + page.evaluate((id) => { + const panel = document.getElementById(id); + const rect = panel.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + floating: panel.classList.contains('panel-floating'), + collapsed: panel.classList.contains('collapsed'), + inRail: panel.parentElement?.id === 'right-context-rail', + allocated: panel.style.getPropertyValue( + '--right-panel-allocated-height', + ), + inlineWidth: panel.style.width, + inlineHeight: panel.style.height, + stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`), + }; + }, PANEL_ID); + const headerPoint = async () => { + const rect = await page.evaluate((id) => { + const title = document.querySelector( + `#${id} .panel-header .panel-title`, + ); + const r = title.getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }, PANEL_ID); + return rect; + }; + const drag = async (from, dx, dy) => { + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps: 6 }); + await page.mouse.move(from.x + dx, from.y + dy, { steps: 6 }); + await page.mouse.up(); + await new Promise((resolve) => setTimeout(resolve, 120)); + }; + + await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate((key) => { + localStorage.removeItem(key); + localStorage.removeItem('godsEyeView.v8.panelFloatHintShown'); + }, STORAGE_KEY); + await boot(); + + // Expand CCTV so the panel has a body worth resizing. + await page.evaluate((id) => { + const panel = document.getElementById(id); + if (panel.classList.contains('collapsed')) + document + .querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`) + .click(); + }, PANEL_ID); + await page.waitForFunction( + (id) => !document.getElementById(id).classList.contains('collapsed'), + {}, + PANEL_ID, + ); + await new Promise((resolve) => setTimeout(resolve, 600)); + const docked = await readPanel(); + assert.equal(docked.floating, false); + assert.equal(docked.inRail, true); + + // Lift out: drag the header 200px left and 100px up. + await drag(await headerPoint(), -200, -100); + const lifted = await readPanel(); + assert.equal(lifted.floating, true, 'a header drag lifts the panel out'); + assert.equal( + lifted.collapsed, + false, + 'the drag-ending click must not collapse', + ); + near(lifted.left, Math.max(6, docked.left - 200), 2, 'lifted left'); + near(lifted.top, Math.max(6, docked.top - 100), 2, 'lifted top'); + near(lifted.width, docked.width, 2, 'lifted width'); + await new Promise((resolve) => setTimeout(resolve, 700)); + const afterLayout = await readPanel(); + assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it'); + const railExcludes = await page.evaluate((id) => { + const rail = document.getElementById('right-context-rail'); + const counted = [...rail.children].filter((panel) => + panel.matches('[data-panel-id]:not(.panel-floating):not(.collapsed)'), + ); + return { + expandedCount: rail.dataset.expandedCount, + counted: counted.length, + includesPanel: counted.some((panel) => panel.id === id), + }; + }, PANEL_ID); + assert.equal(railExcludes.includesPanel, false); + if (railExcludes.expandedCount !== undefined) + assert.equal(Number(railExcludes.expandedCount), railExcludes.counted); + const storedAfterLift = JSON.parse(afterLayout.stored); + assert.equal(storedAfterLift.floating, true); + assert.equal( + 'height' in storedAfterLift, + false, + 'a drag alone must not freeze the measured height', + ); + console.log('PASS: header drag lifts CCTV out of the rail'); + + // Every handle moves only its own edges. + const limits = { + minWidth: MIN_SIZE.width, + minHeight: MIN_SIZE.height, + viewportWidth: VIEWPORT.width, + viewportHeight: VIEWPORT.height, + }; + for (const dir of RESIZE_DIRECTIONS) { + const before = await readPanel(); + const { dx, dy } = growthDelta(dir, 40); + await drag(handlePoint(before, dir), dx, dy); + const after = await readPanel(); + const expected = resizeBox(before, dir, dx, dy, limits); + for (const key of ['left', 'top', 'width', 'height']) + near(after[key], expected[key], 2, `${dir} ${key}`); + assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`); + } + console.log('PASS: eight resize handles keep the opposite edge pinned'); + + // Minimum size stops the moving edge. + let before = await readPanel(); + await drag(handlePoint(before, 'se'), -2000, -2000); + let after = await readPanel(); + near(after.width, MIN_SIZE.width, 2, 'min width (se)'); + near(after.height, MIN_SIZE.height, 2, 'min height (se)'); + assert.deepEqual(driftedEdges(before, after, 'se', 2), []); + before = after; + await drag(handlePoint(before, 'nw'), 2000, 2000); + after = await readPanel(); + near(after.width, MIN_SIZE.width, 2, 'min width (nw)'); + near(after.height, MIN_SIZE.height, 2, 'min height (nw)'); + assert.deepEqual(driftedEdges(before, after, 'nw', 2), []); + console.log('PASS: minimum size holds from both corners'); + + // Reload restores the window. + const saved = await readPanel(); + const record = JSON.parse(saved.stored); + assert.equal(record.floating, true); + await boot(); + const restored = await readPanel(); + assert.equal(restored.floating, true, 'floating survives a reload'); + for (const key of ['left', 'top', 'width', 'height']) + near(restored[key], record[key], 2, `restored ${key}`); + console.log('PASS: reload restores position and size'); + + // Double-click the header to snap back. + const point = await headerPoint(); + await page.mouse.click(point.x, point.y, { clickCount: 1 }); + await page.mouse.click(point.x, point.y, { clickCount: 2 }); + await new Promise((resolve) => setTimeout(resolve, 300)); + const snapped = await readPanel(); + assert.equal(snapped.floating, false, 'double-click snaps back'); + assert.equal(snapped.inRail, true); + assert.equal(snapped.inlineWidth, ''); + assert.equal(snapped.inlineHeight, ''); + assert.equal(snapped.stored, null, 'the storage key is gone'); + console.log('PASS: header double-click returns the panel to the rail'); + + assert.deepEqual(errors, []); + } finally { + await browser.close(); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); + }); +} diff --git a/src/tooling/panelResizeQa.test.mjs b/src/tooling/panelResizeQa.test.mjs new file mode 100644 index 000000000..586f22c08 --- /dev/null +++ b/src/tooling/panelResizeQa.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + driftedEdges, + growthDelta, + handlePoint, + MIN_SIZE, + pinnedEdges, + STORAGE_KEY, +} from '../../scripts/qa-panel-resize.mjs'; +import { RESIZE_DIRECTIONS } from '../ui/panelResize.js'; + +test('the harness targets the key and minimums the controls use', () => { + assert.equal(STORAGE_KEY, 'godsEyeView.v8.panelPos.cctv-panel'); + assert.deepEqual(MIN_SIZE, { width: 300, height: 160 }); +}); + +test('pinned edges are exactly the sides a direction does not name', () => { + assert.deepEqual(pinnedEdges('n'), ['bottom', 'right', 'left']); + assert.deepEqual(pinnedEdges('se'), ['top', 'left']); + assert.deepEqual(pinnedEdges('nw'), ['bottom', 'right']); + for (const dir of RESIZE_DIRECTIONS) + assert.equal(pinnedEdges(dir).length, 4 - dir.length); +}); + +test('growth deltas push each named edge outward', () => { + assert.deepEqual(growthDelta('n', 40), { dx: 0, dy: -40 }); + assert.deepEqual(growthDelta('e', 40), { dx: 40, dy: 0 }); + assert.deepEqual(growthDelta('sw', 40), { dx: -40, dy: 40 }); + assert.deepEqual(growthDelta('se', 40), { dx: 40, dy: 40 }); +}); + +test('handle points land inside the strip or corner for every direction', () => { + const rect = { left: 100, top: 200, width: 400, height: 300 }; + rect.right = rect.left + rect.width; + rect.bottom = rect.top + rect.height; + assert.deepEqual(handlePoint(rect, 'n'), { x: 300, y: 201 }); + assert.deepEqual(handlePoint(rect, 'w'), { x: 101, y: 350 }); + assert.deepEqual(handlePoint(rect, 'se'), { x: 496, y: 496 }); + assert.deepEqual(handlePoint(rect, 'nw'), { x: 104, y: 204 }); + for (const dir of RESIZE_DIRECTIONS) { + const { x, y } = handlePoint(rect, dir); + assert.ok(x >= rect.left && x <= rect.right, `${dir} x inside`); + assert.ok(y >= rect.top && y <= rect.bottom, `${dir} y inside`); + } +}); + +test('drift detection reports only pinned edges that moved', () => { + const before = { left: 100, top: 100, width: 400, height: 300 }; + const grewEast = { left: 100, top: 100, width: 440, height: 300 }; + assert.deepEqual(driftedEdges(before, grewEast, 'e'), []); + const slidWest = { left: 60, top: 100, width: 440, height: 300 }; + assert.deepEqual(driftedEdges(before, slidWest, 'e'), ['left']); + assert.deepEqual(driftedEdges(before, slidWest, 'w'), []); + const wobble = { left: 101, top: 100, width: 439, height: 300 }; + assert.deepEqual(driftedEdges(before, wobble, 'e'), []); +}); diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 2b03c1406..448479169 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -243,6 +243,28 @@ test('right layout retains Display allocation during measurement and caps restor ); }); +test('right rail neither measures nor allocates a panel floating out of it', () => { + const f = fixture('right'); + f.expand(f.first, 900); + f.expand(f.second, 300); + f.first.classList.add('panel-floating'); + f.first.style.setProperty('--right-panel-allocated-height', '500px'); + f.first.writes.length = 0; + f.run(); + assert.equal(f.stack.dataset.expandedCount, '1'); + assert.deepEqual( + f.first.writes, + [], + 'a floating panel keeps whatever inline style it left the rail with', + ); + assert.equal(f.first.getAttribute('aria-hidden'), undefined); + assert.equal(f.first.classList.contains('layout-auto-collapsed'), false); + assert.ok( + f.second.style.getPropertyValue('--right-panel-allocated-height'), + 'the docked panel still receives its allocation', + ); +}); + test('right rail aligns to the current left rail and excludes hidden obstacles', () => { const f = fixture('right'); f.options.leftStack.rect.top = 200; From d7d45b48905e3ac317e46f9af0eb567d723998f7 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:18:49 +0100 Subject: [PATCH 04/77] docs(ui): describe portable panel position storage CURRENT-STATE and KNOWN-ISSUES said the panel position key is never written; a portable panel now stores its floating window there, so both documents describe the record and how a header double-click clears it. CHANGELOG notes the CCTV panel's portable mode. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 +++++ docs/CURRENT-STATE.md | 4 ++-- docs/KNOWN-ISSUES.md | 7 ++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f3fd7822..f037c3e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Rail panels can opt into a portable mode: dragging the CCTV panel by its + header lifts it out of the right rail into a floating window that resizes + from any edge or corner, remembers its position and size, and snaps back on + a header double-click. + - Distinguish PARTIAL vessel snapshots from STALE data in the layer panel, with accepted-record counts and unchanged retention, freshness and outage safeguards. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b5727f353..033330624 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3779,8 +3779,8 @@ easier to meet (detection is now on more often), but does not create it. samples and constant elevation during E/N drag; one shared-floor resolution on release; late one-shot shared-cell work is permitted during viewshed idle. The A+B harness intentionally excludes citywide LOD assertions. -- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.` (`'0'` open, `'1'` closed, absent means the panel's own default). -- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.`. +- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.`. The rails lay docked panels out adaptively and write nothing for them; a portable panel (currently CCTV) persists `{ left, top, width, height, floating: true }` under that key once a header drag lifts it out of the rail, and a header double-click removes the record again. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.` (`'0'` open, `'1'` closed, absent means the panel's own default). +- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.`. - Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker. - Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`. - Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring. diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index 92c92c1f9..9857052c7 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -57,9 +57,10 @@ Related keys (current versions): - Panel collapsed state: `godsEyeView.v6.panelCollapsed.` — `'0'` open, `'1'` closed, absent means the panel's own default. A view opened from a share link ignores the stored value entirely. -- Panel positions: `godsEyeView.v8.panelPos.` — the versioned name for a - stored position. The current layout writes none, so deleting one changes - nothing. +- Panel positions: `godsEyeView.v8.panelPos.` — written only for a + portable panel (currently CCTV) lifted out of its rail, as + `{ left, top, width, height, floating: true }`. Deleting it, or double-clicking + the panel header, returns that panel to the rail; docked panels store nothing. - CCTV calibration: `godsEyeView.cctv.calibration.v2` --- From fa5710bcf29664037042f086557aaa6af8df5666 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:44:32 +0100 Subject: [PATCH 05/77] =?UTF-8?q?feat(mapillary):=20street-level=20layer?= =?UTF-8?q?=20core=20=E2=80=94=20coverage,=20sequences,=20viewer=20bridge,?= =?UTF-8?q?=20query=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Camera-driven Mapillary coverage (z0–5 overview points from orbit, z11–14 ground-clamped sequence lines below 60 km), per-sequence image cones, an embedded MapillaryJS viewer bridge with a camera-follow mode, sprite icons for map features, and a query engine that turns a plan into a streamed z14 tile fan-out. Registers an on-globe CC BY-SA 4.0 credit while enabled. Co-Authored-By: Claude Fable 5.1 --- .env.example | 28 +- README.md | 67 +- server/providers/local.js | 2 + server/providers/mapillary.js | 24 + server/providers/mapillary/constants.js | 93 ++ server/providers/mapillary/decode.js | 60 + server/providers/mapillary/features.js | 165 +++ server/providers/mapillary/planner.js | 293 ++++ server/providers/mapillary/routes.js | 79 ++ server/providers/mapillary/sprites.js | 99 ++ server/providers/mapillary/tiles.js | 203 +++ server/providers/mapillary/trim.js | 58 + src/app/catalog.js | 1 + src/app/catalog.test.mjs | 1 + src/app/constructCatalog.js | 14 + src/app/constructCatalog.test.mjs | 2 +- src/app/layers/mapillary.js | 19 + src/data/layerState.js | 1 + src/keySetupCore.mjs | 20 +- src/layers/mapillary/coverage.js | 524 ++++++++ src/layers/mapillary/decode.js | 145 ++ src/layers/mapillary/decode.test.mjs | 25 + src/layers/mapillary/features.js | 313 +++++ src/layers/mapillary/glyphs.js | 167 +++ src/layers/mapillary/index.js | 435 ++++++ src/layers/mapillary/ndjson.js | 61 + src/layers/mapillary/ndjson.test.mjs | 30 + src/layers/mapillary/policy.js | 65 + src/layers/mapillary/query.js | 516 +++++++ src/layers/mapillary/query.test.mjs | 79 ++ src/layers/mapillary/selection.js | 114 ++ src/layers/mapillary/sequences.js | 262 ++++ src/layers/mapillary/source.js | 233 ++++ src/layers/mapillary/state.js | 107 ++ src/layers/mapillary/taxonomy.js | 1605 ++++++++++++++++++++++ src/layers/mapillary/tileMath.js | 163 +++ src/layers/mapillary/tileMath.test.mjs | 87 ++ src/layers/mapillary/values.js | 162 +++ src/layers/mapillary/values.test.mjs | 96 ++ src/layers/mapillary/viewerBridge.js | 361 +++++ src/standalone/layerSources.js | 4 + src/ui/layerPanel.js | 3 +- src/ui/mapillaryControls.js | 586 ++++++++ src/ui/shellElements.js | 1 + src/ui/styles/mapillary-js.css | 1635 +++++++++++++++++++++++ src/ui/styles/mapillary.css | 959 +++++++++++++ 46 files changed, 9935 insertions(+), 32 deletions(-) create mode 100644 server/providers/mapillary.js create mode 100644 server/providers/mapillary/constants.js create mode 100644 server/providers/mapillary/decode.js create mode 100644 server/providers/mapillary/features.js create mode 100644 server/providers/mapillary/planner.js create mode 100644 server/providers/mapillary/routes.js create mode 100644 server/providers/mapillary/sprites.js create mode 100644 server/providers/mapillary/tiles.js create mode 100644 server/providers/mapillary/trim.js create mode 100644 src/app/layers/mapillary.js create mode 100644 src/layers/mapillary/coverage.js create mode 100644 src/layers/mapillary/decode.js create mode 100644 src/layers/mapillary/decode.test.mjs create mode 100644 src/layers/mapillary/features.js create mode 100644 src/layers/mapillary/glyphs.js create mode 100644 src/layers/mapillary/index.js create mode 100644 src/layers/mapillary/ndjson.js create mode 100644 src/layers/mapillary/ndjson.test.mjs create mode 100644 src/layers/mapillary/policy.js create mode 100644 src/layers/mapillary/query.js create mode 100644 src/layers/mapillary/query.test.mjs create mode 100644 src/layers/mapillary/selection.js create mode 100644 src/layers/mapillary/sequences.js create mode 100644 src/layers/mapillary/source.js create mode 100644 src/layers/mapillary/state.js create mode 100644 src/layers/mapillary/taxonomy.js create mode 100644 src/layers/mapillary/tileMath.js create mode 100644 src/layers/mapillary/tileMath.test.mjs create mode 100644 src/layers/mapillary/values.js create mode 100644 src/layers/mapillary/values.test.mjs create mode 100644 src/layers/mapillary/viewerBridge.js create mode 100644 src/ui/mapillaryControls.js create mode 100644 src/ui/styles/mapillary-js.css create mode 100644 src/ui/styles/mapillary.css diff --git a/.env.example b/.env.example index 5bc02df9c..b626be34b 100644 --- a/.env.example +++ b/.env.example @@ -13,8 +13,8 @@ # externally and never touched. This file remains the reference for headless # and self-hosted setups. # -# NOTE ON CLIENT-EXPOSED KEYS: GOOGLE_MAPS_API_KEY and CESIUM_ION_TOKEN are -# injected into the browser bundle by design (they're used client-side) and +# NOTE ON CLIENT-EXPOSED KEYS: GOOGLE_MAPS_API_KEY, CESIUM_ION_TOKEN and +# MAPILLARY_CLIENT_TOKEN are injected into the browser bundle by design (they're used client-side) and # WILL be visible in devtools. Restrict/scope them rather than trying to hide # them (see SECURITY.md). All other keys below stay server-side. @@ -52,6 +52,10 @@ GOOGLE_MAPS_SERVER_API_KEY= # CLIENT-EXPOSED — use a public assets:read token with URL restrictions. CESIUM_ION_TOKEN= +# Optional: switch the voice agent OFF entirely (no GEV MIC control, no +# Realtime session). Typed controls such as Mapillary AI keep working. +# Default is on. Restart the dev server after changing it. +# GEV_VOICE_UI=off # Optional: OpenAI Realtime voice control. Do not prefix with VITE_. OPENAI_API_KEY= OPENAI_REALTIME_MODEL=gpt-realtime-2 @@ -75,6 +79,26 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano # provider-side budget: OpenAI platform -> Settings -> Limits (usage limits). # GEV_RATELIMIT_OPENAI_PER_MIN=30 +# Optional: Mapillary street-level imagery — coverage lines, detected objects +# and traffic signs, the embedded MapillaryJS viewer and the Mapillary AI query. +# Free client token: https://www.mapillary.com/dashboard/developers (register an +# application, copy the "Client Token"). CLIENT-EXPOSED by design: MapillaryJS +# fetches imagery straight from Mapillary in the browser, so this token is in +# the bundle like GOOGLE_MAPS_API_KEY. Mapillary client tokens are meant to be +# public; the dev server also uses it to fetch and cache vector tiles. +MAPILLARY_CLIENT_TOKEN= +# Optional: Anthropic key for Mapillary AI ("show me all fire hydrants in +# Sacramento" -> a query plan drawn on the globe). SERVER-SIDE ONLY: the +# browser posts the sentence to /api/mapillary/plan and never sees the key. +# The SDK also honours an `ant auth login` profile when this is unset. +ANTHROPIC_API_KEY= +# Planner model. Defaults to claude-opus-5 at low effort; model ids drift, so +# override here rather than editing server/providers/mapillary/constants.js. +# ANTHROPIC_PLANNER_MODEL=claude-opus-5 +# Optional: opt-in per-IP rate limit for the planner endpoint (requests/min). +# DEFAULT IS UNLIMITED. Recommended whenever HOST is not localhost. +# GEV_RATELIMIT_ANTHROPIC_PER_MIN=20 + # Optional: OpenSky auth mode (oauth | basic | auto | anon) # Default: oauth — requires client credentials below # Set to "anon" to skip aircraft auth (rate-limited but works) diff --git a/README.md b/README.md index 4224bacc0..28561c222 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an ## 🎙️ Talk to It -> Voice needs an **OpenAI key**. Without one the entire app still runs — the mic button just reports voice is unavailable. The same key drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move. +> Voice needs an **OpenAI key**. Without one the entire app still runs — the mic button just reports voice is unavailable. Prefer typing? Set `GEV_VOICE_UI=off` in `.env` and the mic control disappears altogether; the typed **Mapillary AI** box below still works. The same key drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move. Click **GEV MIC**, grant the microphone, and just talk. This is more than a voice-controlled remote: @@ -269,27 +269,38 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station --- +## 📷 Street Level (Mapillary) + +> Needs a free **Mapillary client token**; the AI query additionally needs an **Anthropic key**. Both go in POWER UP. + +Turn on **Street Level** and Mapillary's coverage appears at every altitude: capture points from orbit, then the sequence web drawn onto the streets in green as you descend (magenta for 360° captures, dimmer for imagery older than two years). The **IMAGERY** filters narrow all of it to 360° or flat captures and to a capture date. Click a sequence for its image cones; click a cone, or press **LOOK HERE**, and the embedded MapillaryJS viewer opens inside the Mapillary dock. **STREET COCKPIT** locks the globe camera to the street-level pose, **⤢ EXPAND** grows the viewer to most of the screen (Esc shrinks it), and **GOOGLE 3D** switches the globe to Photorealistic 3D Tiles when a Google key or Cesium ion token is configured. The Mapillary dock and the CCTV panel are portable windows: drag a header to move one, pull the corner grip to resize, double-click the header to snap it back. + +**Mapillary AI** is the typed box at the top of the dock. _"show me all fire hydrants in Sacramento"_ becomes a plan (place, taxonomy classes, time window, 360° preference), the place is geocoded, and the city is fanned out over z14 map-feature tiles that stream back as NDJSON — results fill in tile by tile from the centre outwards, first as dots, then as Mapillary's own sprite icons for every class. Follow-ups refine the previous plan: _"only the ones seen after 2024"_, _"same thing in Detroit"_. Click any result and it is ringed on the globe, the image that detected it opens, and the detection itself is outlined in the photo. **3D OBJECTS** stands result sets of up to 1,500 up as procedural models — signs as textured plates on poles facing Mapillary's aligned direction, hydrants, street lights, benches, traffic lights and more. The planner runs on Claude with a prompt-cached taxonomy of 43 object classes and 900 traffic-sign families; unknown classes are reported rather than guessed. Works for any place Nominatim can find, capped at 600 tiles per run. + +--- + ## 🛰️ What's on the Globe Fifteen layers and map sources. **Thirteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) -| Layer | What you get | Source | Auth | -| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | -| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search | -| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) | -| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 | -| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | -| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | -| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | -| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | -| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | -| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | -| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | -| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | -| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | -| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | -| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | +| Layer | What you get | Source | Auth | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | +| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search | +| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) | +| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 | +| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | +| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | +| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | +| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | +| 📹 **CCTV Mesh** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📷 **Street Level** | Mapillary coverage drawn onto the streets, click a sequence for its image cones, open any image in the embedded MapillaryJS viewer, and **Street Cockpit** — the globe camera follows the street-level view. Ask **Mapillary AI** in plain English (_"show me all fire hydrants in Sacramento"_) and the detected objects and traffic signs stream onto the globe tile by tile with Mapillary's own icons | Mapillary + Anthropic | 🟡 Mapillary client token · 🔴 Anthropic key for the AI query | +| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | +| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | +| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | +| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | +| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | +| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | +| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | **The basemap ladder — what each tier buys you:** @@ -354,7 +365,7 @@ How the globe handles live data: - **Honest satellites.** SGP4 propagation with orbit rings that stay locked to their satellites via GMST realignment — no drift, no per-second flicker. - **Sits on the real ground.** Entity heights are aligned to work with Google 3D tiles, so aircraft park on aprons and cameras stand on street corners instead of floating. - **Caching and request budgets.** An OpenSky credit governor, a TomTom daily tile budget, and disk-cached TLEs reduce repeated requests. These controls do not replace provider quotas or billing controls. -- **Server-side credentials.** Every API that touches a private key (OpenAI, AISStream, OpenSky OAuth, camera frames) is brokered through a hardened server-side proxy with SSRF protection, response caps, and sanitized errors. The only keys the browser sees are Google Maps and Cesium ion (restrict both at the provider). +- **Server-side credentials.** Every API that touches a private key (OpenAI, AISStream, OpenSky OAuth, camera frames) is brokered through a hardened server-side proxy with SSRF protection, response caps, and sanitized errors. The only keys the browser sees are Google Maps, Cesium ion and the Mapillary client token (restrict them at the provider; Mapillary client tokens are designed to be public). - **No framework.** Vanilla JavaScript, **CesiumJS**, and **Vite** — plus **Google Photorealistic 3D Tiles** for the planet and the **OpenAI Realtime API** for voice. Fast to read, fast to hack on. ``` @@ -388,14 +399,16 @@ and configuration details. Six keys. Four have a free tier, and the two 🔴 ones are metered: -| | Key | Why | Get it | -| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) | -| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it | -| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below | -| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup | -| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free | -| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [developer.tomtom.com](https://developer.tomtom.com) — free tier available | +| | Key | Why | Get it | +| --- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) | +| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it | +| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below | +| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup | +| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free | +| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [developer.tomtom.com](https://developer.tomtom.com) — free tier available | +| 🟡 | **Mapillary** | 📷 Street-level coverage, sequences and the embedded viewer, plus the detected objects + traffic signs behind Mapillary AI. A client token runs in the browser by design (MapillaryJS fetches imagery directly) | [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers) — register an app, copy the client token | +| 🔴 | **Anthropic** | 🧠 Mapillary AI — turns a sentence into a Mapillary query plan (Claude, low effort, prompt-cached; a few cents per hundred questions). Server-side only | [console.anthropic.com](https://console.anthropic.com/settings/keys) — metered | ![Diving from city-scale live congestion straight into an intersection's public camera](docs/media/05-traffic-to-cctv.gif) diff --git a/server/providers/local.js b/server/providers/local.js index 68f61a3cb..273358f93 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -19,6 +19,7 @@ import { aisLiveProxy } from './vessels/ais-live.js'; import { trackBackfillProxies } from './aircraft/tracks.js'; import { openAiRealtimeProxy } from './openai.js'; import { googlePlacesContextProxy } from './places.js'; +import { mapillaryProxy } from './mapillary.js'; import { keySetupEndpoint } from '../standalone/key-setup.js'; /** Construct the local provider plugins in their established order. */ @@ -45,6 +46,7 @@ function localProviderPlugins() { trackBackfillProxies(), openAiRealtimeProxy(), googlePlacesContextProxy(), + mapillaryProxy(), keySetupEndpoint(), ]; } diff --git a/server/providers/mapillary.js b/server/providers/mapillary.js new file mode 100644 index 000000000..51f9512ed --- /dev/null +++ b/server/providers/mapillary.js @@ -0,0 +1,24 @@ +import { installMapillaryRoutes } from './mapillary/routes.js'; + +/** + * Vite plugin: Mapillary street-level data. + * + * Serves cached vector tiles (coverage, point features, traffic signs) with + * the Mapillary token added server-side, fans a city-sized bbox out over + * z14 feature tiles as an NDJSON stream, turns natural-language requests + * into query plans with Claude (ANTHROPIC_API_KEY stays on this machine), + * and proxies the MIT-licensed Mapillary sprite icons. + */ +function mapillaryProxy() { + return { + name: 'mapillary-proxy', + configureServer(server) { + installMapillaryRoutes(server.middlewares); + }, + configurePreviewServer(server) { + installMapillaryRoutes(server.middlewares); + }, + }; +} + +export { mapillaryProxy }; diff --git a/server/providers/mapillary/constants.js b/server/providers/mapillary/constants.js new file mode 100644 index 000000000..3444622a1 --- /dev/null +++ b/server/providers/mapillary/constants.js @@ -0,0 +1,93 @@ +import path from 'node:path'; + +/** Mapillary vector tile root; the path template is /{layer}/2/{z}/{x}/{y}. */ +export const MAPILLARY_TILE_HOST = 'https://tiles.mapillary.com/maps/vtp'; + +/** Public tile layer names this proxy exposes, mapped to Mapillary's ids. */ +export const TILE_LAYERS = Object.freeze({ + // Overview points (z0–5), sequences (z6–14) and image points (z14 only). + // The z14 `image` point layer is ~98% of a 10 MB tile and unused here: + // image positions come from the graph API per sequence. Dropped in transit. + coverage: Object.freeze({ + upstream: 'mly1_public', + minZoom: 0, + maxZoom: 14, + dropLayers: Object.freeze(['image']), + }), + // Point map features such as fire hydrants, benches and utility poles. + points: Object.freeze({ + upstream: 'mly_map_feature_point', + minZoom: 14, + maxZoom: 14, + }), + // Classified traffic-sign detections. + signs: Object.freeze({ + upstream: 'mly_map_feature_traffic_sign', + minZoom: 14, + maxZoom: 14, + }), +}); + +/** Zoom at which Mapillary publishes map-feature tiles. */ +export const FEATURE_TILE_ZOOM = 14; + +/** Disk cache root, alongside the other providers' caches. */ +export const MAPILLARY_CACHE_DIR = path.join( + process.cwd(), + '.gev-cache', + 'mapillary', +); +export const TILE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'tiles'); +export const SPRITE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'sprites'); + +/** + * Tiles change when new imagery is processed, which is far slower than the + * 15-minute upstream Cache-Control. A day keeps a city-wide query from + * re-downloading hundreds of megabytes on every run. + */ +export const TILE_DISK_TTL_MS = 24 * 60 * 60 * 1000; +export const SPRITE_DISK_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +/** A z14 image tile over a dense city is ~11 MB; anything past this is wrong. */ +export const TILE_MAX_BYTES = 48 * 1024 * 1024; +export const SPRITE_MAX_BYTES = 512 * 1024; + +/** In-memory tile cache budget (bytes) and upstream fetch timeout. */ +export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024; +export const TILE_FETCH_TIMEOUT_MS = 60_000; + +/** Executor limits for one natural-language query. */ +export const QUERY_MAX_TILES = 600; +export const QUERY_MAX_FEATURES = 120_000; +export const QUERY_TILE_CONCURRENCY = 6; +export const QUERY_MAX_BODY_BYTES = 32 * 1024; + +/** Sprite SVG source (MIT) — one file per taxonomy value. */ +export const SPRITE_SOURCE_ROOT = + 'https://raw.githubusercontent.com/mapillary/mapillary_sprite_source/master'; + +/** Planner defaults; the model id is external and may be overridden by env. */ +export const PLANNER_MODEL_DEFAULT = 'claude-opus-5'; +export const PLANNER_MAX_TOKENS = 1500; +export const PLANNER_MAX_BODY_BYTES = 32 * 1024; +export const PLANNER_MAX_QUERY_CHARS = 600; + +/** The client token lives in the browser by design; the server adds it to tile URLs too. */ +export function mapillaryToken() { + return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim(); +} + +/** Anthropic credentials are server-side only. */ +export function anthropicConfigured() { + return Boolean( + String(process.env.ANTHROPIC_API_KEY || '').trim() || + String(process.env.ANTHROPIC_AUTH_TOKEN || '').trim(), + ); +} + +export function plannerModel() { + return ( + String(process.env.ANTHROPIC_PLANNER_MODEL || '').trim() || + PLANNER_MODEL_DEFAULT + ); +} diff --git a/server/providers/mapillary/decode.js b/server/providers/mapillary/decode.js new file mode 100644 index 000000000..51a211705 --- /dev/null +++ b/server/providers/mapillary/decode.js @@ -0,0 +1,60 @@ +import { PbfReader } from 'pbf'; +import { VectorTile } from '@mapbox/vector-tile'; +import { tileLocalToLonLat } from '../../../src/layers/mapillary/tileMath.js'; + +/** Vector-tile layer name inside each Mapillary map-feature tile. */ +const FEATURE_LAYER_NAMES = Object.freeze({ + points: 'point', + signs: 'traffic_sign', +}); + +/** + * Decode one map-feature tile into compact rows, keeping only features whose + * value passes `matcher` and whose sighting window overlaps [since, until]. + * Rows are positional arrays to keep the streamed payload small: + * [lon, lat, id, value, firstSeenMs, lastSeenMs]. + * @param {Buffer|Uint8Array} bytes + * @param {{x:number,y:number,z:number,layer:'points'|'signs'}} address + * @param {{matcher?: (value:string)=>boolean, since?: number|null, until?: number|null}} [filter] + * @returns {Array<[number, number, string, string, number, number]>} + */ +export function decodeFeatureRows(bytes, address, filter = {}) { + if (!bytes || !bytes.length) return []; + const layerName = FEATURE_LAYER_NAMES[address.layer]; + const tile = new VectorTile(new PbfReader(bytes)); + const layer = layerName ? tile.layers[layerName] : null; + if (!layer) return []; + const matcher = filter.matcher || (() => true); + const since = Number.isFinite(filter.since) ? filter.since : null; + const until = Number.isFinite(filter.until) ? filter.until : null; + const rows = []; + for (let i = 0; i < layer.length; i++) { + const feature = layer.feature(i); + const value = feature.properties?.value; + if (typeof value !== 'string' || !matcher(value)) continue; + const first = Number(feature.properties.first_seen_at) || 0; + const last = Number(feature.properties.last_seen_at) || first; + if (since !== null && last < since) continue; + if (until !== null && first > until) continue; + const geometry = feature.loadGeometry(); + const point = geometry?.[0]?.[0]; + if (!point) continue; + const [lon, lat] = tileLocalToLonLat( + point.x, + point.y, + layer.extent, + address.x, + address.y, + address.z, + ); + rows.push([ + Number(lon.toFixed(6)), + Number(lat.toFixed(6)), + String(feature.properties.id ?? feature.id ?? ''), + value, + first, + last, + ]); + } + return rows; +} diff --git a/server/providers/mapillary/features.js b/server/providers/mapillary/features.js new file mode 100644 index 000000000..ffa038b35 --- /dev/null +++ b/server/providers/mapillary/features.js @@ -0,0 +1,165 @@ +import { readRequestBody } from '../common/request.js'; +import { + FEATURE_TILE_ZOOM, + QUERY_MAX_BODY_BYTES, + QUERY_MAX_FEATURES, + QUERY_MAX_TILES, + QUERY_TILE_CONCURRENCY, + mapillaryToken, +} from './constants.js'; +import { fetchTile, TileRequestError } from './tiles.js'; +import { decodeFeatureRows } from './decode.js'; +import { + normalizeBbox, + tilesForBbox, +} from '../../../src/layers/mapillary/tileMath.js'; +import { + createValueMatcher, + normalizeValuePattern, +} from '../../../src/layers/mapillary/values.js'; + +function sendJson(res, status, payload) { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(payload)); +} + +/** Parse an ISO date (or epoch ms) into epoch ms, or null when absent/invalid. */ +export function parseEpochMs(value) { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + const parsed = Date.parse(String(value)); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Validate a feature query body into an executable request. + * @returns {{ok:true, request:object}|{ok:false, status:number, error:string, detail?:object}} + */ +export function normalizeFeatureQuery(body) { + const bbox = normalizeBbox(body?.bbox); + if (!bbox) + return { + ok: false, + status: 400, + error: 'A bbox [west,south,east,north] is required', + }; + const layer = body?.layer === 'signs' ? 'signs' : 'points'; + const values = Array.isArray(body?.values) + ? body.values.map(normalizeValuePattern).filter(Boolean).slice(0, 64) + : []; + const since = parseEpochMs(body?.seenAfter); + const until = parseEpochMs(body?.seenBefore); + const { tiles, truncated, total } = tilesForBbox(bbox, FEATURE_TILE_ZOOM, { + limit: QUERY_MAX_TILES, + }); + if (truncated) + return { + ok: false, + status: 413, + error: 'area_too_large', + detail: { tiles: total, limit: QUERY_MAX_TILES }, + }; + return { ok: true, request: { bbox, layer, values, since, until, tiles } }; +} + +/** + * POST /api/mapillary/features — fan a bbox out over z14 map-feature tiles + * and stream the matching features back as NDJSON, one line per tile, so + * the globe fills in progressively while the rest of the city downloads. + */ +export async function handleFeatureQuery(req, res) { + if (req.method !== 'POST') + return sendJson(res, 405, { error: 'Method not allowed' }); + if (!mapillaryToken()) + return sendJson(res, 503, { error: 'no_key', keyRequired: true }); + let body; + try { + body = JSON.parse( + (await readRequestBody(req, QUERY_MAX_BODY_BYTES)) || '{}', + ); + } catch (error) { + return sendJson(res, 400, { error: error?.message || 'Invalid JSON body' }); + } + const normalized = normalizeFeatureQuery(body); + if (!normalized.ok) + return sendJson(res, normalized.status, { + error: normalized.error, + ...normalized.detail, + }); + const { layer, values, since, until, tiles, bbox } = normalized.request; + const matcher = createValueMatcher(values); + + const abandoned = new AbortController(); + req.on?.('aborted', () => abandoned.abort()); + res.on?.('close', () => abandoned.abort()); + + res.statusCode = 200; + res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Accel-Buffering', 'no'); + const write = (line) => { + if (res.writableEnded || abandoned.signal.aborted) return false; + res.write(`${JSON.stringify(line)}\n`); + return true; + }; + write({ + type: 'start', + tiles: tiles.length, + zoom: FEATURE_TILE_ZOOM, + layer, + values, + bbox, + }); + + let total = 0; + let failed = 0; + let truncated = false; + let cursor = 0; + const worker = async () => { + while (cursor < tiles.length && !abandoned.signal.aborted) { + if (total >= QUERY_MAX_FEATURES) { + truncated = true; + return; + } + const index = cursor++; + const tile = tiles[index]; + try { + const { bytes, source } = await fetchTile( + { layer, z: tile.z, x: tile.x, y: tile.y }, + { signal: abandoned.signal }, + ); + const rows = decodeFeatureRows( + bytes, + { ...tile, layer }, + { matcher, since, until }, + ); + total += rows.length; + write({ type: 'tile', i: index, x: tile.x, y: tile.y, source, rows }); + } catch (error) { + if (abandoned.signal.aborted) return; + failed++; + write({ + type: 'tile', + i: index, + x: tile.x, + y: tile.y, + rows: [], + error: + error instanceof TileRequestError + ? error.message + : `tile unavailable (${error?.status || error?.message || 'error'})`, + }); + } + } + }; + await Promise.all( + Array.from( + { length: Math.min(QUERY_TILE_CONCURRENCY, tiles.length) }, + worker, + ), + ); + write({ type: 'done', total, failed, truncated, tiles: tiles.length }); + if (!res.writableEnded) res.end(); +} diff --git a/server/providers/mapillary/planner.js b/server/providers/mapillary/planner.js new file mode 100644 index 000000000..1001b9371 --- /dev/null +++ b/server/providers/mapillary/planner.js @@ -0,0 +1,293 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { z } from 'zod'; +import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'; +import { readRequestBody } from '../common/request.js'; +import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; +import { + PLANNER_MAX_BODY_BYTES, + PLANNER_MAX_QUERY_CHARS, + PLANNER_MAX_TOKENS, + anthropicConfigured, + mapillaryToken, + plannerModel, +} from './constants.js'; +import { + OBJECT_VALUES, + SIGN_FAMILIES, + SIGN_VALUES, +} from '../../../src/layers/mapillary/taxonomy.js'; +import { + createValueMatcher, + isSignValue, + normalizeValuePattern, +} from '../../../src/layers/mapillary/values.js'; + +/** + * The plan the model returns. Every field is required (structured outputs + * reject optional keys); "unknown" is spelled with null. + */ +export const PlanSchema = z.object({ + intent: z.enum([ + 'map_features', + 'traffic_signs', + 'coverage', + 'nearest_image', + 'unsupported', + ]), + place: z.string().nullable(), + use_current_view: z.boolean(), + values: z.array(z.string()), + seen_after: z.string().nullable(), + seen_before: z.string().nullable(), + prefer_pano: z.boolean(), + visualise: z.enum(['icons', 'points', 'count']), + title: z.string(), + answer: z.string(), +}); + +/** + * Frozen system prompt. Nothing volatile lives here so the block can be + * prompt-cached across every query; the date and view context ride in the + * user turn instead. + */ +export const PLANNER_SYSTEM_PROMPT = [ + "You translate natural-language requests about street-level map data into a query plan for God's Eye View, a 3D globe that can draw Mapillary map features (objects detected in street imagery) and traffic signs, show Mapillary coverage, or open the nearest street-level image.", + '', + 'Return ONLY a plan object. Field rules:', + '- intent: "map_features" for objects (hydrants, benches, poles, manholes, traffic lights, road markings…); "traffic_signs" for signs (stop, speed limit, no parking, pedestrian crossing…); "coverage" when the user asks where imagery exists or how recent it is; "nearest_image" when they want to see/open/look at the street view of a place; "unsupported" when the request is not about Mapillary data (then explain in `answer`).', + '- place: the place to search, as a geocodable string ("Sacramento, California"; "Detroit, Michigan"; "Shinjuku, Tokyo"). null when the user means the current view ("here", "around me", "in this area") or gives no place at all.', + '- use_current_view: true when place is null and the current view should be used.', + '- values: Mapillary taxonomy values for the requested things. Use exact values from the lists below. A wildcard `*` matches a run of characters: use `regulatory--stop--*` to cover every regional variant of a sign family, `object--traffic-light--*` for all traffic lights, `marking--discrete--arrow--*` for all arrow markings. Include several values when the request is broad ("street furniture" → benches, trash cans, bike racks, mailboxes). Leave empty only for coverage or nearest_image intents.', + '- seen_after / seen_before: ISO dates (YYYY-MM-DD) when the user constrains time ("since 2024", "before 2022", "in the last two years" relative to today); otherwise null.', + '- prefer_pano: true when the user asks for 360°, panoramic or immersive imagery (nearest_image and coverage intents); otherwise false.', + '- visualise: "icons" by default; "points" when the user asks for density or a very broad class; "count" when they only ask how many.', + '- title: a short HUD title, e.g. "Fire hydrants · Sacramento".', + '- answer: one plain sentence confirming what will be shown, mentioning any interpretation you made. Never claim counts; the data has not been fetched yet.', + '', + 'Follow-ups: when a previous plan is supplied and the new request refines the same subject ("only the ones seen after 2024", "just the panoramas", "same thing in Detroit"), start from the previous plan and change only what the request changes. A request that names a new subject or a new place ("stop signs in downtown Detroit", "now benches here") starts fresh: keep nothing from the previous plan unless the user says "same", "also", "too" or "keep".', + '', + 'Point object values (layer map_features):', + OBJECT_VALUES.join(', '), + '', + 'Traffic-sign families (layer traffic_signs). Each family exists as several regional variants suffixed --g1, --g2…; always request a family with the wildcard form `--*`:', + SIGN_FAMILIES.join(', '), +].join('\n'); + +const KNOWN_VALUES = new Set([...OBJECT_VALUES, ...SIGN_VALUES]); + +/** + * Keep only values that exist in the taxonomy (exact) or match at least one + * taxonomy value (wildcard). Returns the surviving patterns plus what was + * dropped so the UI can say so. + */ +export function reconcilePlanValues(values) { + const kept = []; + const dropped = []; + for (const raw of values || []) { + const pattern = normalizeValuePattern(raw); + if (!pattern) continue; + if (pattern.includes('*')) { + const matches = createValueMatcher([pattern]); + if ([...KNOWN_VALUES].some(matches)) kept.push(pattern); + else dropped.push(pattern); + } else if (KNOWN_VALUES.has(pattern)) kept.push(pattern); + else if (KNOWN_VALUES.has(`${pattern}--g1`)) kept.push(`${pattern}--*`); + else dropped.push(pattern); + } + return { values: [...new Set(kept)], dropped }; +} + +/** + * Turn the model's plan into the executable plan the browser runs: values + * reconciled against the taxonomy, the tile layer derived from the values, + * and an honest downgrade to "unsupported" when nothing usable survived. + */ +export function finalizePlan(plan) { + const { values, dropped } = reconcilePlanValues(plan.values); + let intent = plan.intent; + let layer = null; + if (intent === 'map_features' || intent === 'traffic_signs') { + if (!values.length) { + return { + ...plan, + intent: 'unsupported', + values: [], + dropped, + layer: null, + answer: dropped.length + ? `I could not map "${dropped.join('", "')}" onto a Mapillary object or sign class.` + : plan.answer, + }; + } + const signs = values.filter(isSignValue).length; + layer = signs && signs === values.length ? 'signs' : 'points'; + intent = layer === 'signs' ? 'traffic_signs' : 'map_features'; + if (signs && signs !== values.length) { + // Mixed request: signs live in a different tile set. Keep the object + // half and say so rather than silently dropping either. + return { + ...plan, + intent, + layer, + values: values.filter((value) => !isSignValue(value)), + dropped: [...dropped, ...values.filter(isSignValue)], + answer: `${plan.answer} Traffic signs and objects come from different layers, so this run shows the objects only.`, + }; + } + } + return { ...plan, intent, layer, values, dropped }; +} + +let _client; +function client() { + return (_client ||= new Anthropic()); +} + +let _limiter; +function limiter() { + if (_limiter === undefined) + _limiter = makeOptInRateLimiter( + process.env.GEV_RATELIMIT_ANTHROPIC_PER_MIN, + ); + return _limiter; +} + +function sendJson(res, status, payload) { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(payload)); +} + +/** Build the volatile user turn: the request plus whatever the globe knows. */ +export function buildPlannerUserMessage({ query, context = {}, today }) { + const lines = [`Today is ${today}.`]; + const view = context.view; + if (view && Number.isFinite(view.lat) && Number.isFinite(view.lon)) { + lines.push( + `Current view: ${view.lat.toFixed(4)}, ${view.lon.toFixed(4)}` + + (Number.isFinite(view.heightM) + ? `, camera ${Math.round(view.heightM)} m above ground` + : '') + + (view.label ? `, near ${String(view.label).slice(0, 120)}` : '') + + '.', + ); + } + if (context.previousPlan) { + lines.push( + `Previous plan: ${JSON.stringify(context.previousPlan).slice(0, 1500)}`, + ); + } + lines.push('', `Request: ${query}`); + return lines.join('\n'); +} + +/** + * Ask Claude for a plan. Exported for tests with an injectable client. + * @returns {Promise<{plan: object, usage: object|null, model: string}>} + */ +export async function planQuery( + { query, context }, + { anthropic = client(), model = plannerModel(), now = new Date() } = {}, +) { + const response = await anthropic.messages.parse({ + model, + max_tokens: PLANNER_MAX_TOKENS, + system: [ + { + type: 'text', + text: PLANNER_SYSTEM_PROMPT, + cache_control: { type: 'ephemeral' }, + }, + ], + messages: [ + { + role: 'user', + content: buildPlannerUserMessage({ + query, + context, + today: now.toISOString().slice(0, 10), + }), + }, + ], + output_config: { format: zodOutputFormat(PlanSchema), effort: 'low' }, + }); + if (response.stop_reason === 'refusal') { + return { + plan: finalizePlan({ + intent: 'unsupported', + place: null, + use_current_view: false, + values: [], + seen_after: null, + seen_before: null, + prefer_pano: false, + visualise: 'icons', + title: 'Query declined', + answer: 'The planner declined this request.', + }), + usage: response.usage ?? null, + model, + }; + } + if (!response.parsed_output) + throw Object.assign(new Error('Planner returned no plan'), { status: 502 }); + return { + plan: finalizePlan(response.parsed_output), + usage: response.usage ?? null, + model, + }; +} + +function statusForError(error) { + if (error instanceof Anthropic.AuthenticationError) return 503; + if (error instanceof Anthropic.RateLimitError) return 429; + if (error instanceof Anthropic.APIConnectionError) return 502; + if (error instanceof Anthropic.APIStatusError) return error.status || 502; + return error?.status || 500; +} + +/** POST /api/mapillary/plan — natural language in, executable plan out. */ +export async function handlePlan(req, res) { + if (req.method !== 'POST') + return sendJson(res, 405, { error: 'Method not allowed' }); + if (!mapillaryToken()) + return sendJson(res, 503, { error: 'no_key', keyRequired: 'mapillary' }); + if (!anthropicConfigured()) + return sendJson(res, 503, { error: 'no_key', keyRequired: 'anthropic' }); + const allow = limiter(); + if (allow && !allow(clientKey(req))) { + res.setHeader('Retry-After', '5'); + return sendJson(res, 429, { error: 'Rate limit exceeded' }); + } + let body; + try { + body = JSON.parse( + (await readRequestBody(req, PLANNER_MAX_BODY_BYTES)) || '{}', + ); + } catch (error) { + return sendJson(res, 400, { error: error?.message || 'Invalid JSON body' }); + } + const query = String(body?.query || '').trim(); + if (!query || query.length > PLANNER_MAX_QUERY_CHARS) + return sendJson(res, 400, { + error: `A query of 1–${PLANNER_MAX_QUERY_CHARS} characters is required`, + }); + try { + const result = await planQuery({ + query, + context: + body?.context && typeof body.context === 'object' ? body.context : {}, + }); + sendJson(res, 200, result); + } catch (error) { + const status = statusForError(error); + console.warn('[Mapillary Planner]', error?.message || error); + sendJson(res, status, { + error: + status === 503 + ? 'Anthropic authentication failed' + : error?.message || 'Planner request failed', + }); + } +} diff --git a/server/providers/mapillary/routes.js b/server/providers/mapillary/routes.js new file mode 100644 index 000000000..34347d31d --- /dev/null +++ b/server/providers/mapillary/routes.js @@ -0,0 +1,79 @@ +import { fetchTile, TileRequestError, TileUpstreamError } from './tiles.js'; +import { handleFeatureQuery } from './features.js'; +import { handlePlan } from './planner.js'; +import { handleSprite } from './sprites.js'; +import { + anthropicConfigured, + mapillaryToken, + plannerModel, + QUERY_MAX_TILES, + FEATURE_TILE_ZOOM, +} from './constants.js'; + +function sendJson(res, status, payload) { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(payload)); +} + +/** GET /api/mapillary/status — what this install can do, never a value. */ +function handleStatus(req, res) { + if (req.method !== 'GET') + return sendJson(res, 405, { error: 'Method not allowed' }); + sendJson(res, 200, { + configured: Boolean(mapillaryToken()), + planner: anthropicConfigured(), + plannerModel: anthropicConfigured() ? plannerModel() : null, + limits: { maxTiles: QUERY_MAX_TILES, featureZoom: FEATURE_TILE_ZOOM }, + }); +} + +/** GET /api/mapillary/tiles/{layer}/{z}/{x}/{y} — cached protobuf tile. */ +async function handleTile(req, res) { + if (req.method !== 'GET') + return sendJson(res, 405, { error: 'Method not allowed' }); + const match = + /^\/(coverage|points|signs)\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec( + (req.url || '').split('?')[0], + ); + if (!match) + return sendJson(res, 400, { + error: 'Tile path must be /{layer}/{z}/{x}/{y}', + }); + if (!mapillaryToken()) + return sendJson(res, 503, { error: 'no_key', keyRequired: true }); + const abandoned = new AbortController(); + req.on?.('aborted', () => abandoned.abort()); + res.on?.('close', () => abandoned.abort()); + try { + const { bytes, source } = await fetchTile( + { layer: match[1], z: match[2], x: match[3], y: match[4] }, + { signal: abandoned.signal }, + ); + if (res.writableEnded) return; + res.statusCode = bytes.length ? 200 : 204; + res.setHeader('Content-Type', 'application/x-protobuf'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.setHeader('X-Gev-Cache', source); + res.end(bytes.length ? bytes : undefined); + } catch (error) { + if (res.writableEnded || abandoned.signal.aborted) return; + if (error instanceof TileRequestError) + return sendJson(res, error.status, { error: error.message }); + if (error instanceof TileUpstreamError) + return sendJson(res, error.status >= 500 ? 502 : error.status, { + error: error.message, + }); + sendJson(res, 502, { error: error?.message || 'Tile fetch failed' }); + } +} + +/** Attach every Mapillary route to a connect-style middleware stack. */ +export function installMapillaryRoutes(middlewares) { + middlewares.use('/api/mapillary/status', handleStatus); + middlewares.use('/api/mapillary/tiles', handleTile); + middlewares.use('/api/mapillary/features', handleFeatureQuery); + middlewares.use('/api/mapillary/plan', handlePlan); + middlewares.use('/api/mapillary/sprite', handleSprite); +} diff --git a/server/providers/mapillary/sprites.js b/server/providers/mapillary/sprites.js new file mode 100644 index 000000000..c2f888555 --- /dev/null +++ b/server/providers/mapillary/sprites.js @@ -0,0 +1,99 @@ +import path from 'node:path'; +import { promises as fsp } from 'node:fs'; +import { + SPRITE_DISK_DIR, + SPRITE_DISK_TTL_MS, + SPRITE_MAX_BYTES, + SPRITE_SOURCE_ROOT, +} from './constants.js'; +import { isSignValue } from '../../../src/layers/mapillary/values.js'; + +/** A taxonomy value: lower-case segments joined by `--`, words by `-`. */ +const VALUE_GRAMMAR = + /^[a-z0-9]+(?:-[a-z0-9]+)*(?:--[a-z0-9]+(?:-[a-z0-9]+)*)*$/; + +/** @type {Map>} */ +const _inFlight = new Map(); + +function spritePath(value) { + return path.join(SPRITE_DISK_DIR, `${value}.svg`); +} + +async function readDisk(value) { + try { + const file = spritePath(value); + const stat = await fsp.stat(file); + if (Date.now() - stat.mtimeMs > SPRITE_DISK_TTL_MS) return null; + return await fsp.readFile(file); + } catch { + return null; + } +} + +async function fetchSprite(value) { + const pkg = isSignValue(value) ? 'package_signs' : 'package_objects'; + const response = await fetch(`${SPRITE_SOURCE_ROOT}/${pkg}/${value}.svg`, { + signal: AbortSignal.timeout(20_000), + }); + if (response.status === 404) { + await response.body?.cancel().catch(() => {}); + return null; + } + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw new Error(`sprite source HTTP ${response.status}`); + } + const bytes = Buffer.from(await response.arrayBuffer()); + if ( + bytes.length > SPRITE_MAX_BYTES || + !bytes.subarray(0, 512).toString().includes(' fsp.writeFile(file, bytes)) + .catch(() => {}); + return bytes; +} + +/** + * GET /api/mapillary/sprite/.svg — the Mapillary icon for a taxonomy + * value, fetched once from the MIT sprite source and cached on disk. + */ +export async function handleSprite(req, res) { + const match = /^\/([a-z0-9-]+)\.svg$/.exec((req.url || '').split('?')[0]); + const value = match?.[1]; + if (!value || !VALUE_GRAMMAR.test(value)) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Invalid sprite value' })); + return; + } + try { + let bytes = await readDisk(value); + if (!bytes) { + let pending = _inFlight.get(value); + if (!pending) { + pending = fetchSprite(value).finally(() => _inFlight.delete(value)); + _inFlight.set(value, pending); + } + bytes = await pending; + } + if (!bytes) { + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.end(JSON.stringify({ error: 'No sprite for value' })); + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8'); + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.end(bytes); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: error?.message || 'Sprite unavailable' })); + } +} diff --git a/server/providers/mapillary/tiles.js b/server/providers/mapillary/tiles.js new file mode 100644 index 000000000..3d794fd04 --- /dev/null +++ b/server/providers/mapillary/tiles.js @@ -0,0 +1,203 @@ +import path from 'node:path'; +import { promises as fsp } from 'node:fs'; +import { stripTileLayers } from './trim.js'; +import { + MAPILLARY_TILE_HOST, + TILE_LAYERS, + TILE_DISK_DIR, + TILE_DISK_TTL_MS, + TILE_MAX_BYTES, + TILE_MEMORY_BUDGET_BYTES, + TILE_FETCH_TIMEOUT_MS, + mapillaryToken, +} from './constants.js'; + +/** Thrown for a request this proxy refuses before contacting Mapillary. */ +export class TileRequestError extends Error { + constructor(message, status = 400) { + super(message); + this.name = 'TileRequestError'; + this.status = status; + } +} + +/** Thrown when Mapillary answered with an error status. */ +export class TileUpstreamError extends Error { + constructor(status, message) { + super(message || `Mapillary tiles HTTP ${status}`); + this.name = 'TileUpstreamError'; + this.status = status; + } +} + +/** + * Validate and normalize a tile address. Layer names are the proxy's public + * ids (coverage/points/signs); zoom must be inside the layer's published range. + * @returns {{layer:string, upstream:string, z:number, x:number, y:number, key:string}} + */ +export function normalizeTileAddress({ layer, z, x, y }) { + const spec = TILE_LAYERS[layer]; + if (!spec) throw new TileRequestError(`Unknown tile layer: ${layer}`); + const zi = Number(z); + const xi = Number(x); + const yi = Number(y); + if ( + ![zi, xi, yi].every((v) => Number.isInteger(v) && v >= 0) || + zi < spec.minZoom || + zi > spec.maxZoom + ) + throw new TileRequestError( + `Tile zoom for ${layer} must be ${spec.minZoom}–${spec.maxZoom}`, + ); + const n = 2 ** zi; + if (xi >= n || yi >= n) + throw new TileRequestError('Tile address out of range'); + return { + layer, + upstream: spec.upstream, + dropLayers: spec.dropLayers || [], + z: zi, + x: xi, + y: yi, + key: `${layer}/${zi}/${xi}/${yi}`, + }; +} + +/** @type {Map} insertion-ordered LRU */ +const _memory = new Map(); +let _memoryBytes = 0; +/** @type {Map>} */ +const _inFlight = new Map(); + +function memoryGet(key) { + const hit = _memory.get(key); + if (!hit) return null; + // Re-insert to mark as most recently used. + _memory.delete(key); + _memory.set(key, hit); + return hit.bytes; +} + +function memoryPut(key, bytes) { + if (bytes.length > TILE_MEMORY_BUDGET_BYTES / 2) return; + const existing = _memory.get(key); + if (existing) _memoryBytes -= existing.bytes.length; + _memory.set(key, { bytes, at: Date.now() }); + _memoryBytes += bytes.length; + while (_memoryBytes > TILE_MEMORY_BUDGET_BYTES && _memory.size) { + const [oldest, entry] = _memory.entries().next().value; + _memory.delete(oldest); + _memoryBytes -= entry.bytes.length; + } +} + +function diskPath({ layer, z, x, y }) { + return path.join(TILE_DISK_DIR, layer, String(z), `${x}-${y}.pbf`); +} + +async function readDisk(address) { + const file = diskPath(address); + try { + const stat = await fsp.stat(file); + if (Date.now() - stat.mtimeMs > TILE_DISK_TTL_MS) return null; + return await fsp.readFile(file); + } catch { + return null; + } +} + +function writeDisk(address, bytes) { + const file = diskPath(address); + fsp + .mkdir(path.dirname(file), { recursive: true }) + .then(() => fsp.writeFile(`${file}.tmp`, bytes)) + .then(() => fsp.rename(`${file}.tmp`, file)) + .catch((error) => + console.warn( + '[Mapillary Proxy] tile cache write failed:', + error?.message || error, + ), + ); +} + +async function fetchUpstream(address, signal) { + const token = mapillaryToken(); + if (!token) throw new TileRequestError('Mapillary token not configured', 503); + const url = `${MAPILLARY_TILE_HOST}/${address.upstream}/2/${address.z}/${address.x}/${address.y}?access_token=${encodeURIComponent(token)}`; + const timeout = AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS); + const response = await fetch(url, { + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + headers: { Accept: 'application/x-protobuf' }, + }); + if (response.status === 404 || response.status === 204) { + await response.body?.cancel().catch(() => {}); + return Buffer.alloc(0); + } + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw new TileUpstreamError(response.status); + } + const declared = Number(response.headers.get('content-length')); + if (Number.isFinite(declared) && declared > TILE_MAX_BYTES) { + await response.body?.cancel().catch(() => {}); + throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap'); + } + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > TILE_MAX_BYTES) + throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap'); + return bytes; +} + +/** Strip the layers this proxy never serves for the address's layer spec. */ +function trim(address, bytes) { + if (!address.dropLayers.length || !bytes.length) return bytes; + try { + return stripTileLayers(bytes, address.dropLayers); + } catch (error) { + console.warn( + '[Mapillary Proxy] tile trim failed, serving raw:', + error?.message || error, + ); + return bytes; + } +} + +/** + * Fetch one tile through memory, disk and in-flight coalescing, then + * Mapillary. Returns the raw protobuf bytes (empty for a tile with no data). + * @param {{layer:string,z:number|string,x:number|string,y:number|string}} request + * @param {{signal?: AbortSignal}} [options] + * @returns {Promise<{bytes: Buffer, source: 'memory'|'disk'|'inflight'|'upstream', address: object}>} + */ +export async function fetchTile(request, { signal } = {}) { + const address = normalizeTileAddress(request); + const memory = memoryGet(address.key); + if (memory) return { bytes: memory, source: 'memory', address }; + const disk = await readDisk(address); + if (disk) { + // Older cache files may still hold the untrimmed tile: trim and replace. + const bytes = trim(address, disk); + if (bytes !== disk) writeDisk(address, bytes); + memoryPut(address.key, bytes); + return { bytes, source: 'disk', address }; + } + const pending = _inFlight.get(address.key); + if (pending) return { bytes: await pending, source: 'inflight', address }; + const work = fetchUpstream(address, signal) + .then((raw) => trim(address, raw)) + .then((bytes) => { + memoryPut(address.key, bytes); + writeDisk(address, bytes); + return bytes; + }) + .finally(() => _inFlight.delete(address.key)); + _inFlight.set(address.key, work); + return { bytes: await work, source: 'upstream', address }; +} + +/** Test seam: forget every cached tile held in memory. */ +export function _resetTileMemoryForTest() { + _memory.clear(); + _memoryBytes = 0; + _inFlight.clear(); +} diff --git a/server/providers/mapillary/trim.js b/server/providers/mapillary/trim.js new file mode 100644 index 000000000..6111988df --- /dev/null +++ b/server/providers/mapillary/trim.js @@ -0,0 +1,58 @@ +import { PbfReader, PbfWriter } from 'pbf'; + +/** Field number of `layers` in a Mapbox Vector Tile, and of `name` in a layer. */ +const TILE_LAYER_FIELD = 3; +const LAYER_NAME_FIELD = 1; + +/** Read a layer's name without touching its features. */ +function layerName(layerBytes) { + const pbf = new PbfReader(layerBytes); + let name = ''; + pbf.readFields((tag, _result, reader) => { + if (tag !== LAYER_NAME_FIELD) return; + name = reader.readString(); + reader.pos = reader.length; // the name is all we need + }, null); + return name; +} + +/** + * Drop whole layers from a vector tile at the protobuf level. Nothing inside + * a kept layer is decoded or re-encoded, so a 12 MB tile whose bulk is an + * unused layer trims in a few milliseconds. Returns the input when nothing + * was dropped. + * @param {Buffer|Uint8Array} bytes + * @param {Iterable} dropNames + */ +export function stripTileLayers(bytes, dropNames) { + const drop = new Set(dropNames || []); + if (!bytes?.length || !drop.size) return bytes; + const reader = new PbfReader(bytes); + const writer = new PbfWriter(); + let dropped = false; + reader.readFields((tag, _result, pbf) => { + if (tag !== TILE_LAYER_FIELD) return; // pbf skips what we leave alone + const end = pbf.readVarint() + pbf.pos; + const layerBytes = bytes.subarray(pbf.pos, end); + pbf.pos = end; + if (drop.has(layerName(layerBytes))) { + dropped = true; + return; + } + writer.writeBytesField(TILE_LAYER_FIELD, layerBytes); + }, null); + return dropped ? Buffer.from(writer.finish()) : bytes; +} + +/** Names of the layers inside a vector tile, in order. */ +export function listTileLayers(bytes) { + const names = []; + if (!bytes?.length) return names; + new PbfReader(bytes).readFields((tag, _result, pbf) => { + if (tag !== TILE_LAYER_FIELD) return; + const end = pbf.readVarint() + pbf.pos; + names.push(layerName(bytes.subarray(pbf.pos, end))); + pbf.pos = end; + }, null); + return names; +} diff --git a/src/app/catalog.js b/src/app/catalog.js index 0d22ec9a1..03aaf154b 100644 --- a/src/app/catalog.js +++ b/src/app/catalog.js @@ -4,6 +4,7 @@ const CONTROL_LAYER_IDS = Object.freeze({ militaryFlightsLayer: 'military', satellitesLayer: 'satellites', cctvLayer: 'cctv', + mapillaryLayer: 'mapillary', radioLayer: 'radio', bikeshareLayer: 'bikeshare', transitLayer: 'transit', diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs index 820943049..2260eef0a 100644 --- a/src/app/catalog.test.mjs +++ b/src/app/catalog.test.mjs @@ -93,6 +93,7 @@ test('controls bind catalog instances rather than similarly named defaults', () 'military-awareness', 'military-installations', 'rocket-launches', + 'mapillary', ]; const layers = ids.map((id) => ({ id })); const catalog = createLayerCatalog(layers, metadata(layers)); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index f22ce2cd4..fad78c9df 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -18,6 +18,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js'; import { createApplicationFirms } from './layers/firms.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationCables } from './layers/submarineCables.js'; +import { createApplicationMapillary } from './layers/mapillary.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; import { localGeoJsonServices } from './localGeojsonServices.js'; import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js'; @@ -44,6 +45,18 @@ const SOURCE_METHODS = Object.freeze({ firms: ['getSnapshot'], earthquakes: ['getSnapshot'], cables: ['fetch'], + mapillary: [ + 'getStatus', + 'getTile', + 'queryFeatures', + 'plan', + 'geocode', + 'getImage', + 'getSequenceImages', + 'nearestImages', + 'getMapFeature', + 'spriteUrl', + ], }); /** Construct the current catalog without choosing any source provider. @@ -112,6 +125,7 @@ export function createApplicationCatalog({ military, createApplicationEarthquakes({ source: sources.earthquakes }), createApplicationAlpr({ surface, source: sources.alpr }), + createApplicationMapillary({ surface, source: sources.mapillary }), satellites, createApplicationLaunches({ source: sources.launches, satellites }), createApplicationTraffic({ source: sources.traffic }), diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 6d1c0224f..6095ddd4b 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -39,7 +39,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 21); + assert.equal(first.layers.length, 22); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( diff --git a/src/app/layers/mapillary.js b/src/app/layers/mapillary.js new file mode 100644 index 000000000..311caf517 --- /dev/null +++ b/src/app/layers/mapillary.js @@ -0,0 +1,19 @@ +import { createMapillaryLayer } from '../../layers/mapillary/index.js'; +import * as sprites from '../../data/spriteOrder.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as input from '../../data/inputOwnership.js'; +import * as render from '../../renderGovernor.js'; + +/** Construct the Mapillary layer using the application scene owners and a supplied source. */ +export function createApplicationMapillary({ surface, source }) { + return createMapillaryLayer({ + source, + services: { + sprites, + picking, + input, + render, + ground: surface?.groundFloor ?? null, + }, + }); +} diff --git a/src/data/layerState.js b/src/data/layerState.js index 68eebd777..afcc9072e 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -344,6 +344,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ disposition: 'enabled-only', }), Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }), + Object.freeze({ id: 'mapillary', token: 'y', disposition: 'enabled-only' }), Object.freeze({ id: 'military', token: 'm', diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs index e8bfef92c..74c52d1a2 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -25,7 +25,7 @@ export const KEY_SETUP_APPEND_HEADER = * Provider credentials, in display order — most magic per * minute first. `tier` mirrors the README's color legend: 'metered' (🔴) is a * billing-enabled account, 'free' (🟡) is a register-and-paste key. - * `clientExposed` marks the two keys that are injected into the browser + * `clientExposed` marks the keys that are injected into the browser * bundle by design (restrict them at the provider, per SECURITY.md). * `hidden` keeps advanced configuration out of the panel and missing-key count. */ @@ -98,6 +98,24 @@ export const KEY_SETUP_KEYS = Object.freeze([ envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']), tier: 'free', }), + Object.freeze({ + id: 'mapillary', + title: 'MAPILLARY', + unlocks: + 'Street-level imagery, detected objects + traffic signs, and the Mapillary AI query', + getUrl: 'https://www.mapillary.com/dashboard/developers', + envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']), + tier: 'free', + clientExposed: true, + }), + Object.freeze({ + id: 'anthropic', + title: 'ANTHROPIC', + unlocks: 'Mapillary AI — type a question, get it drawn on the globe', + getUrl: 'https://console.anthropic.com/settings/keys', + envVars: Object.freeze(['ANTHROPIC_API_KEY']), + tier: 'metered', + }), Object.freeze({ id: 'launch-library', title: 'LAUNCH LIBRARY', diff --git a/src/layers/mapillary/coverage.js b/src/layers/mapillary/coverage.js new file mode 100644 index 000000000..8cfab5490 --- /dev/null +++ b/src/layers/mapillary/coverage.js @@ -0,0 +1,524 @@ +import * as Cesium from 'cesium'; +import { decodeCoverageTile } from './decode.js'; +import { + coverageZoomForHeight, + overviewZoomForHeight, + tilesForBbox, +} from './tileMath.js'; +import { + COLORS, + COVERAGE_LINE_WIDTH_PX, + COVERAGE_MAX_SEQUENCES, + COVERAGE_MAX_TILES, + COVERAGE_MOVE_DEBOUNCE_MS, + COVERAGE_OVERVIEW_MAX_TILES, + COVERAGE_OVERVIEW_POINT_PX, + COVERAGE_RECENT_DAYS, + PICK_PREFIX, +} from './policy.js'; + +/** Old-zoom tiles are kept at most this long after a zoom change. */ +const STALE_TILE_MAX_MS = 6000; +/** + * Sequences per ground primitive. Ground polyline geometry is built on a + * worker in proportion to the instance count, so smaller batches put the + * first lines on screen sooner instead of one big batch arriving late. + */ +const SEQUENCE_PRIMITIVE_BATCH = 120; + +const PER_TILE_SEQUENCE_CAP = Math.floor( + COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES, +); + +/** Colour for a sequence: brand green, dimmer with age, magenta for panoramas. */ +function sequenceColor(sequence, { selected = false, now = Date.now() } = {}) { + if (selected) return Cesium.Color.fromCssColorString(COLORS.selected); + if (sequence.isPano) + return Cesium.Color.fromCssColorString(COLORS.pano).withAlpha(0.9); + const ageDays = (now - (sequence.capturedAt || 0)) / 86_400_000; + return ageDays <= COVERAGE_RECENT_DAYS + ? Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92) + : Cesium.Color.fromCssColorString(COLORS.coverageOld).withAlpha(0.7); +} + +/** + * Whether one capture passes the imagery filter: panorama mode + * ('all' | 'pano' | 'flat') and an optional earliest capture time. + */ +export function passesImageryFilter(record, filter) { + if (!filter) return true; + if (filter.pano === 'pano' && !record.isPano) return false; + if (filter.pano === 'flat' && record.isPano) return false; + if ( + Number.isFinite(filter.sinceMs) && + filter.sinceMs > 0 && + (record.capturedAt || 0) < filter.sinceMs + ) + return false; + return true; +} + +/** + * Camera height above the surface under the camera, in metres. Falls back to + * the ellipsoidal height when the globe has no height sample yet. + */ +function cameraHeightAboveGround(viewer) { + const carto = viewer?.camera?.positionCartographic; + if (!carto) return null; + const ground = viewer.scene?.globe?.getHeight?.(carto); + return carto.height - (Number.isFinite(ground) ? ground : 0); +} + +/** + * Visible bbox as [west, south, east, north] degrees, or null when the camera + * does not see the ground. A grid of screen rays is cast onto the ellipsoid + * and only the rays that hit count, so a view that includes the horizon (or a + * canvas whose frustum is stale) cannot inflate the box to the whole world; + * `computeViewRectangle` is the fallback when too few rays land. + */ +export function visibleBbox(viewer, { grid = 5 } = {}) { + const scene = viewer?.scene; + const camera = viewer?.camera; + if (!scene || !camera) return null; + const width = scene.canvas?.clientWidth || scene.canvas?.width || 0; + const height = scene.canvas?.clientHeight || scene.canvas?.height || 0; + const hits = []; + if (width > 0 && height > 0) { + const ellipsoid = scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; + const point = new Cesium.Cartesian2(); + for (let i = 0; i <= grid; i++) { + for (let j = 0; j <= grid; j++) { + point.x = (width * i) / grid; + point.y = (height * j) / grid; + let cartesian = null; + try { + cartesian = camera.pickEllipsoid(point, ellipsoid); + } catch { + cartesian = null; + } + if (!cartesian) continue; + const carto = Cesium.Cartographic.fromCartesian(cartesian, ellipsoid); + if (carto) hits.push(carto); + } + } + } + if (hits.length >= 4) { + let west = Infinity; + let south = Infinity; + let east = -Infinity; + let north = -Infinity; + for (const carto of hits) { + const lon = Cesium.Math.toDegrees(carto.longitude); + const lat = Cesium.Math.toDegrees(carto.latitude); + west = Math.min(west, lon); + east = Math.max(east, lon); + south = Math.min(south, lat); + north = Math.max(north, lat); + } + if (east - west > 0 && north - south > 0) return [west, south, east, north]; + } + const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid); + if (!rectangle) return null; + return [ + Cesium.Math.toDegrees(rectangle.west), + Cesium.Math.toDegrees(rectangle.south), + Cesium.Math.toDegrees(rectangle.east), + Cesium.Math.toDegrees(rectangle.north), + ]; +} + +/** + * Camera-driven coverage: z0–5 `overview` points from orbit down to 60 km, + * then z11–14 sequence polylines clamped to terrain and 3D tiles. Decoded + * tiles are kept so the imagery filter can rebuild without refetching. + */ +export function createCoverage({ state, source }) { + const { render } = state.services; + + function requestRender() { + render?.governorRequestRender?.('mapillary-coverage'); + } + + function ensureTerrainReady() { + return (state.coverage.terrainReady ||= + Cesium.GroundPolylinePrimitive.initializeTerrainHeights()); + } + + function tileKey({ x, y, z }) { + return `${z}/${x}/${y}`; + } + + function filter() { + return state.coverage.filter; + } + + /** One or more ground primitives for a tile's sequences, in draw batches. */ + function buildSequencePrimitives(sequences) { + const now = Date.now(); + const instances = []; + for (const sequence of sequences) { + if (!passesImageryFilter(sequence, filter())) continue; + const flat = []; + for (const [lon, lat] of sequence.coordinates) flat.push(lon, lat); + let positions; + try { + positions = Cesium.Cartesian3.fromDegreesArray(flat); + } catch { + continue; + } + if (positions.length < 2) continue; + instances.push( + new Cesium.GeometryInstance({ + geometry: new Cesium.GroundPolylineGeometry({ + positions, + width: COVERAGE_LINE_WIDTH_PX, + }), + id: `${PICK_PREFIX.sequence}${sequence.id}`, + attributes: { + color: Cesium.ColorGeometryInstanceAttribute.fromColor( + sequenceColor(sequence, { + now, + selected: sequence.id === state.sequence.selectedId, + }), + ), + }, + }), + ); + } + const primitives = []; + for (let i = 0; i < instances.length; i += SEQUENCE_PRIMITIVE_BATCH) + primitives.push( + new Cesium.GroundPolylinePrimitive({ + geometryInstances: instances.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), + appearance: new Cesium.PolylineColorAppearance(), + classificationType: Cesium.ClassificationType.BOTH, + asynchronous: true, + allowPicking: true, + }), + ); + return { primitives, count: instances.length }; + } + + function buildOverviewCollection(points) { + const collection = new Cesium.PointPrimitiveCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + const green = Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha( + 0.85, + ); + const pink = Cesium.Color.fromCssColorString(COLORS.pano).withAlpha(0.9); + let count = 0; + for (const point of points) { + if (!passesImageryFilter(point, filter())) continue; + collection.add({ + position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat), + color: point.isPano ? pink : green, + pixelSize: COVERAGE_OVERVIEW_POINT_PX, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }); + count++; + } + return { collection, count }; + } + + function attachPrimitive(entry) { + const scene = state.viewer?.scene; + if (!scene) return; + if (entry.kind === 'sequence') { + const { primitives, count } = buildSequencePrimitives(entry.sequenceList); + entry.primitives = primitives; + entry.count = count; + for (const primitive of primitives) { + primitive.show = !state.coverage.resting; + scene.groundPrimitives.add(primitive); + } + } else { + const { collection, count } = buildOverviewCollection(entry.points); + entry.primitive = collection; + entry.count = count; + // Orbit-level points never rest: results are invisible at that scale. + collection.show = true; + scene.primitives.add(collection); + } + } + + function detachPrimitive(entry) { + const scene = state.viewer?.scene; + for (const primitive of entry.primitives || []) { + try { + scene?.groundPrimitives?.remove(primitive); + } catch { + /* already gone */ + } + } + entry.primitives = []; + if (entry.primitive) { + try { + scene?.primitives?.remove(entry.primitive); + } catch { + /* already gone */ + } + entry.primitive = null; + } + } + + function removeTile(key) { + const entry = state.coverage.tiles.get(key); + if (!entry) return; + detachPrimitive(entry); + state.coverage.tiles.delete(key); + } + + async function loadTile(tile, generation, kind) { + const key = tileKey(tile); + if (state.coverage.tiles.has(key) || state.coverage.pending.has(key)) + return; + const controller = new AbortController(); + state.coverage.pending.set(key, controller); + state.coverage.loading++; + notify(); + try { + // Fetch and the one-time terrain-height table load run side by side. + const fetching = source.getTile('coverage', tile.z, tile.x, tile.y, { + signal: controller.signal, + }); + if (kind === 'sequence') await ensureTerrainReady(); + const bytes = await fetching; + // A refresh that still wants this tile must not throw the bytes away; + // only an abort (tile no longer wanted, or zoom changed) does. + if ( + controller.signal.aborted || + kind !== state.coverage.kind || + tile.z !== state.coverage.zoom + ) + return; + const decoded = decodeCoverageTile(bytes, tile); + const entry = { kind, primitive: null, primitives: [], count: 0 }; + if (kind === 'sequence') { + // Newest first, capped per tile so a dense city stays within budget. + const sequences = decoded.sequences + .sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0)) + .slice(0, PER_TILE_SEQUENCE_CAP); + entry.sequenceList = sequences; + entry.sequences = new Map(sequences.map((s) => [s.id, s])); + entry.total = decoded.sequences.length; + } else { + entry.points = decoded.overview; + entry.sequences = new Map(); + entry.total = decoded.overview.length; + } + attachPrimitive(entry); + state.coverage.tiles.set(key, entry); + state.coverage.lastError = null; + requestRender(); + } catch (error) { + if (controller.signal.aborted) return; + state.coverage.lastError = error?.message || 'Coverage tile failed'; + if (error?.keyRequired) state.keyRequired = true; + } finally { + state.coverage.pending.delete(key); + state.coverage.loading = Math.max(0, state.coverage.loading - 1); + if (!state.coverage.pending.size) purgeStale(); + notify(); + } + } + + /** Drop the previous zoom's tiles once the new ones are on screen. */ + function purgeStale() { + clearTimeout(state.coverage.staleTimer); + state.coverage.staleTimer = null; + if (!state.coverage.stale.size) return; + for (const entry of state.coverage.stale.values()) detachPrimitive(entry); + state.coverage.stale.clear(); + requestRender(); + } + + /** + * Move every loaded tile to the stale set instead of removing it, so the + * old zoom stays visible while the new zoom streams in (no blank globe). + */ + function retire() { + for (const controller of state.coverage.pending.values()) + controller.abort(); + state.coverage.pending.clear(); + state.coverage.loading = 0; + for (const [key, entry] of state.coverage.tiles) { + const previous = state.coverage.stale.get(key); + if (previous) detachPrimitive(previous); + state.coverage.stale.set(key, entry); + } + state.coverage.tiles.clear(); + clearTimeout(state.coverage.staleTimer); + state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS); + } + + function notify() { + state.notify?.(); + } + + /** Recompute the tile set for the current camera and reconcile primitives. */ + function refresh() { + const viewer = state.viewer; + if (!viewer || !state.enabled || state.keyRequired) return; + const height = cameraHeightAboveGround(viewer); + const sequenceZoom = coverageZoomForHeight(height); + const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height); + const zoom = sequenceZoom ?? overviewZoom; + const kind = sequenceZoom ? 'sequence' : 'overview'; + let bbox = visibleBbox(viewer); + if (kind === 'overview' && (!bbox || zoom <= 1)) + bbox = [-180, -85, 180, 85]; + if (!zoom || !bbox) { + state.coverage.hint = + 'Point the camera at the globe for Mapillary coverage'; + clear(); + notify(); + return; + } + state.coverage.hint = ''; + if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) { + retire(); + state.coverage.zoom = zoom; + state.coverage.kind = kind; + } + state.coverage.generation++; + const generation = state.coverage.generation; + const { tiles } = tilesForBbox(bbox, zoom, { + limit: + kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES, + }); + const wanted = new Set(tiles.map(tileKey)); + for (const key of [...state.coverage.tiles.keys()]) + if (!wanted.has(key)) removeTile(key); + for (const [key, controller] of [...state.coverage.pending]) + if (!wanted.has(key)) { + controller.abort(); + state.coverage.pending.delete(key); + } + for (const tile of tiles) loadTile(tile, generation, kind); + notify(); + } + + function scheduleRefresh() { + clearTimeout(state.coverage.debounceTimer); + state.coverage.debounceTimer = setTimeout( + refresh, + COVERAGE_MOVE_DEBOUNCE_MS, + ); + } + + function attach(viewer) { + detach(); + const remove = viewer.camera.changed.addEventListener(scheduleRefresh); + const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh); + state.coverage.removeCameraListener = () => { + remove(); + removeEnd(); + }; + refresh(); + } + + function detach() { + clearTimeout(state.coverage.debounceTimer); + state.coverage.debounceTimer = null; + state.coverage.removeCameraListener?.(); + state.coverage.removeCameraListener = null; + } + + function clear() { + state.coverage.generation++; + for (const controller of state.coverage.pending.values()) + controller.abort(); + state.coverage.pending.clear(); + state.coverage.loading = 0; + for (const key of [...state.coverage.tiles.keys()]) removeTile(key); + purgeStale(); + state.coverage.zoom = null; + state.coverage.kind = null; + requestRender(); + } + + /** Apply a new imagery filter and rebuild every loaded tile from its cache. */ + function setFilter(next) { + const current = state.coverage.filter; + const pano = ['all', 'pano', 'flat'].includes(next?.pano) + ? next.pano + : current.pano; + const sinceMs = + next && 'sinceMs' in next + ? Number.isFinite(next.sinceMs) && next.sinceMs > 0 + ? next.sinceMs + : null + : current.sinceMs; + if (pano === current.pano && sinceMs === current.sinceMs) return; + state.coverage.filter = { pano, sinceMs }; + purgeStale(); + for (const entry of state.coverage.tiles.values()) { + detachPrimitive(entry); + attachPrimitive(entry); + } + requestRender(); + notify(); + } + + /** Look a sequence up across loaded tiles. */ + function findSequence(id) { + for (const entry of state.coverage.tiles.values()) { + const hit = entry.sequences.get(id); + if (hit) return hit; + } + return null; + } + + /** Recolour one sequence in place (selection highlight). */ + function recolorSequence(id, selected) { + const instanceId = `${PICK_PREFIX.sequence}${id}`; + for (const entry of state.coverage.tiles.values()) { + const sequence = entry.sequences.get(id); + if (!sequence) continue; + for (const primitive of entry.primitives || []) { + if (!primitive.ready) continue; + try { + const attributes = + primitive.getGeometryInstanceAttributes(instanceId); + if (attributes) + attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue( + sequenceColor(sequence, { selected }), + attributes.color, + ); + } catch { + /* instance not in this primitive */ + } + } + } + requestRender(); + } + + /** Rest (hide) the street-level sequence web while results are shown; orbit points stay. */ + function setResting(resting) { + state.coverage.resting = resting === true; + for (const entry of state.coverage.tiles.values()) + if (entry.kind === 'sequence') + for (const primitive of entry.primitives || []) + primitive.show = !state.coverage.resting; + requestRender(); + } + + function sequenceCount() { + let count = 0; + for (const entry of state.coverage.tiles.values()) count += entry.count; + return count; + } + + return { + attach, + detach, + refresh, + clear, + findSequence, + recolorSequence, + sequenceCount, + setResting, + setFilter, + }; +} diff --git a/src/layers/mapillary/decode.js b/src/layers/mapillary/decode.js new file mode 100644 index 000000000..a4c305650 --- /dev/null +++ b/src/layers/mapillary/decode.js @@ -0,0 +1,145 @@ +import { PbfReader } from 'pbf'; +import { VectorTile } from '@mapbox/vector-tile'; +import { tileLocalToLonLat } from './tileMath.js'; + +/** + * Decode a Mapillary coverage tile (`mly1_public`) in the browser. + * Sequences are LineStrings with capture metadata; the `image` layer is only + * present at z14 and is skipped unless asked for (it can hold >150k points). + * @param {Uint8Array} bytes + * @param {{x:number,y:number,z:number}} address + * @param {{images?: boolean}} [options] + * @returns {{sequences: Array, images: Array}} + */ +export function decodeCoverageTile(bytes, address, { images = false } = {}) { + const result = { sequences: [], images: [], overview: [] }; + if (!bytes || !bytes.length) return result; + const tile = new VectorTile(new PbfReader(bytes)); + const { x, y, z } = address; + const sequenceLayer = tile.layers.sequence; + if (sequenceLayer) { + for (let i = 0; i < sequenceLayer.length; i++) { + const feature = sequenceLayer.feature(i); + const props = feature.properties || {}; + const coordinates = []; + for (const ring of feature.loadGeometry()) { + for (const point of ring) { + coordinates.push( + tileLocalToLonLat(point.x, point.y, sequenceLayer.extent, x, y, z), + ); + } + } + if (coordinates.length < 2) continue; + result.sequences.push({ + id: String(props.id ?? feature.id ?? `${x}/${y}/${i}`), + imageId: props.image_id != null ? String(props.image_id) : null, + capturedAt: Number(props.captured_at) || 0, + isPano: props.is_pano === true, + onFoot: props.foot === true, + quality: Number.isFinite(props.quality_score) + ? props.quality_score + : null, + coordinates, + }); + } + } + // z0–5 tiles carry an `overview` point layer instead of sequences. + const overviewLayer = tile.layers.overview; + if (overviewLayer) { + for (let i = 0; i < overviewLayer.length; i++) { + const feature = overviewLayer.feature(i); + const props = feature.properties || {}; + const point = feature.loadGeometry()?.[0]?.[0]; + if (!point) continue; + const [lon, lat] = tileLocalToLonLat( + point.x, + point.y, + overviewLayer.extent, + x, + y, + z, + ); + result.overview.push({ + id: String(props.id ?? feature.id ?? i), + lon, + lat, + capturedAt: Number(props.captured_at) || 0, + isPano: props.is_pano === true, + sequenceId: props.sequence_id ? String(props.sequence_id) : null, + }); + } + } + const imageLayer = images ? tile.layers.image : null; + if (imageLayer) { + for (let i = 0; i < imageLayer.length; i++) { + const feature = imageLayer.feature(i); + const props = feature.properties || {}; + const point = feature.loadGeometry()?.[0]?.[0]; + if (!point) continue; + const [lon, lat] = tileLocalToLonLat( + point.x, + point.y, + imageLayer.extent, + x, + y, + z, + ); + result.images.push({ + id: String(props.id ?? feature.id), + lon, + lat, + compassAngle: Number(props.compass_angle) || 0, + capturedAt: Number(props.captured_at) || 0, + isPano: props.is_pano === true, + sequenceId: props.sequence_id ? String(props.sequence_id) : null, + }); + } + } + return result; +} + +/** Decode a base64 string into bytes without Node's Buffer. */ +function base64ToBytes(base64) { + if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') + return new Uint8Array(Buffer.from(base64, 'base64')); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +/** + * Decode a Mapillary detection `geometry` (a base64 vector tile whose single + * layer holds the segmentation polygon in a 4096 grid) into polygons of + * image-normalized [x, y] points in [0, 1], the coordinate space the + * MapillaryJS tag component expects. + * @param {string} base64 + * @returns {Array>} One ring per polygon. + */ +export function decodeDetectionPolygons(base64) { + if (!base64) return []; + let tile; + try { + tile = new VectorTile(new PbfReader(base64ToBytes(base64))); + } catch { + return []; + } + const polygons = []; + for (const layer of Object.values(tile.layers)) { + for (let i = 0; i < layer.length; i++) { + const feature = layer.feature(i); + for (const ring of feature.loadGeometry()) { + if (ring.length < 3) continue; + const points = ring.map((point) => [ + Math.min(1, Math.max(0, point.x / layer.extent)), + Math.min(1, Math.max(0, point.y / layer.extent)), + ]); + const [fx, fy] = points[0]; + const [lx, ly] = points[points.length - 1]; + if (fx !== lx || fy !== ly) points.push([fx, fy]); + polygons.push(points); + } + } + } + return polygons; +} diff --git a/src/layers/mapillary/decode.test.mjs b/src/layers/mapillary/decode.test.mjs new file mode 100644 index 000000000..0392c0df2 --- /dev/null +++ b/src/layers/mapillary/decode.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { decodeDetectionPolygons } from './decode.js'; + +// A real detection geometry from graph.mapillary.com (construction--barrier--curb). +const SAMPLE = + 'Gjp4AgoGbXB5LW9yKIAgEisYAwgBIiUJzi+gKHoACAMAAwADAAMAAwADAAMAAAcEAAQABAAEAAQABAAP'; + +test('a detection geometry decodes into a closed, normalized polygon', () => { + const polygons = decodeDetectionPolygons(SAMPLE); + assert.equal(polygons.length, 1); + const ring = polygons[0]; + assert.ok(ring.length >= 4); + for (const [x, y] of ring) { + assert.ok(x >= 0 && x <= 1, `x in range: ${x}`); + assert.ok(y >= 0 && y <= 1, `y in range: ${y}`); + } + assert.deepEqual(ring[0], ring[ring.length - 1]); + assert.ok(Math.abs(ring[0][0] - 3047 / 4096) < 1e-9); +}); + +test('garbage input yields no polygons instead of throwing', () => { + assert.deepEqual(decodeDetectionPolygons('not base64!!'), []); + assert.deepEqual(decodeDetectionPolygons(''), []); +}); diff --git a/src/layers/mapillary/features.js b/src/layers/mapillary/features.js new file mode 100644 index 000000000..d89efcbb6 --- /dev/null +++ b/src/layers/mapillary/features.js @@ -0,0 +1,313 @@ +import * as Cesium from 'cesium'; +import { + featureDotGlyph, + rasterizeSprite, + selectionRingGlyph, +} from './glyphs.js'; +import { + COLORS, + FEATURE_ICON_LIMIT, + FEATURE_ICON_MAX, + FEATURE_ICON_SIZE_PX, + FEATURE_POINT_SIZE_PX, + PICK_PREFIX, +} from './policy.js'; +import { colorForValue } from './values.js'; + +/** + * Query-result rendering: fast points while a query streams, Mapillary + * sprite icons once it completes (clamped to the ground for modest sets, + * height-sampled for city-sized ones), plus a selection ring. + */ +export function createFeatures({ state, source }) { + const { render, sprites } = state.services; + /** @type {Map>} */ + const spriteImages = new Map(); + + function requestRender() { + render?.governorRequestRender?.('mapillary-features'); + } + + function ensureCollections(viewer) { + const scene = viewer.scene; + if (!state.features.points) { + state.features.points = new Cesium.PointPrimitiveCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + scene.primitives.add(state.features.points); + sprites?.registerSpriteCollection?.('mapillary', state.features.points); + } + if (!state.features.collection) { + state.features.collection = new Cesium.BillboardCollection({ scene }); + scene.primitives.add(state.features.collection); + sprites?.registerSpriteCollection?.( + 'mapillary', + state.features.collection, + ); + } + if (!state.features.highlight) { + state.features.highlight = new Cesium.BillboardCollection({ scene }); + scene.primitives.add(state.features.highlight); + sprites?.registerSpriteCollection?.( + 'mapillary', + state.features.highlight, + ); + } + } + + function groundPosition(lon, lat, lift = 1) { + const carto = Cesium.Cartographic.fromDegrees(lon, lat); + const height = state.viewer?.scene?.globe?.getHeight?.(carto); + return Cesium.Cartesian3.fromDegrees( + lon, + lat, + Number.isFinite(height) ? height + lift : lift, + ); + } + + const colorCache = new Map(); + function cesiumColor(value) { + let color = colorCache.get(value); + if (!color) { + color = Cesium.Color.fromCssColorString(colorForValue(value)); + colorCache.set(value, color); + } + return color; + } + + /** Sprite canvas for a value, cached; falls back to a coloured dot. */ + function spriteImage(value) { + let pending = spriteImages.get(value); + if (!pending) { + pending = fetch(source.spriteUrl(value)) + .then((response) => { + if (!response.ok) throw new Error(`sprite ${response.status}`); + return response.text(); + }) + .then((svg) => rasterizeSprite(svg, { size: 60 })) + .catch(() => + featureDotGlyph({ size: 60, color: colorForValue(value) }), + ); + spriteImages.set(value, pending); + } + return pending; + } + + /** Raw sprite SVG text for a value (3D sign faces), or null. */ + async function spriteSvg(value) { + try { + const response = await fetch(source.spriteUrl(value)); + return response.ok ? await response.text() : null; + } catch { + return null; + } + } + + /** Reset the result set and its primitives. */ + function reset() { + state.features.points?.removeAll(); + state.features.collection?.removeAll(); + state.features.highlight?.removeAll(); + Object.assign(state.features, { + rows: [], + byId: new Map(), + billboardById: new Map(), + counts: new Map(), + earliest: null, + latest: null, + total: 0, + truncated: false, + failed: 0, + progress: { done: 0, tiles: 0 }, + iconMode: false, + selectedId: null, + iconsHidden: false, + }); + if (state.features.points) state.features.points.show = true; + if (state.features.collection) state.features.collection.show = true; + requestRender(); + } + + /** Append streamed rows: [lon, lat, id, value, first, last]. */ + function appendRows(rows) { + const points = state.features.points; + if (!points) return; + const features = state.features; + for (const row of rows) { + const [lon, lat, id, value, first, last] = row; + if (features.byId.has(id)) continue; + features.byId.set(id, row); + features.rows.push(row); + features.counts.set(value, (features.counts.get(value) || 0) + 1); + if (first) + features.earliest = features.earliest + ? Math.min(features.earliest, first) + : first; + if (last) + features.latest = features.latest + ? Math.max(features.latest, last) + : last; + points.add({ + id: `${PICK_PREFIX.feature}${id}`, + position: groundPosition(lon, lat), + color: cesiumColor(value), + outlineColor: Cesium.Color.fromBytes(255, 255, 255, 200), + outlineWidth: 1.5, + pixelSize: FEATURE_POINT_SIZE_PX, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + scaleByDistance: new Cesium.NearFarScalar(500, 1.2, 40_000, 0.5), + }); + } + features.total = features.rows.length; + requestRender(); + } + + /** + * Once a query completes, swap the dots for Mapillary icons. Sets up to + * FEATURE_ICON_LIMIT clamp to terrain and 3D tiles; larger sets sit on the + * sampled globe height (clamping tens of thousands of billboards stalls the + * frame). Beyond FEATURE_ICON_MAX the points stay. + */ + async function upgradeToIcons() { + const features = state.features; + if (!features.collection || features.rows.length > FEATURE_ICON_MAX) return; + const values = [...features.counts.keys()]; + const images = new Map( + await Promise.all( + values.map(async (value) => [value, await spriteImage(value)]), + ), + ); + if (features.loading || !state.viewer) return; + const clamp = features.rows.length <= FEATURE_ICON_LIMIT; + features.collection.removeAll(); + features.billboardById = new Map(); + for (const [lon, lat, id, value] of features.rows) { + const billboard = features.collection.add({ + id: `${PICK_PREFIX.feature}${id}`, + position: clamp + ? Cesium.Cartesian3.fromDegrees(lon, lat) + : groundPosition(lon, lat, 0.5), + image: images.get(value), + imageId: `mly-sprite:${value}`, + width: FEATURE_ICON_SIZE_PX, + height: FEATURE_ICON_SIZE_PX, + ...(clamp + ? { heightReference: Cesium.HeightReference.CLAMP_TO_GROUND } + : {}), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + scaleByDistance: new Cesium.NearFarScalar(300, 1.1, 20_000, 0.35), + }); + features.billboardById.set(id, billboard); + } + features.points.show = false; + features.collection.show = !features.iconsHidden; + features.iconMode = true; + if (features.selectedId) select(features.selectedId); + requestRender(); + } + + /** Highlight one result: scale its icon and draw a ring behind it. */ + function select(id) { + const features = state.features; + clearSelection({ keepId: true }); + const row = features.byId.get(id); + if (!row) { + features.selectedId = null; + return null; + } + features.selectedId = id; + const billboard = features.billboardById?.get(id); + if (billboard) billboard.scale = 1.45; + const [lon, lat] = row; + const clamp = features.rows.length <= FEATURE_ICON_LIMIT; + features.highlight?.add({ + id: `${PICK_PREFIX.feature}${id}`, + position: clamp + ? Cesium.Cartesian3.fromDegrees(lon, lat) + : groundPosition(lon, lat, 0.5), + image: selectionRingGlyph({ size: 64, color: COLORS.selected }), + imageId: 'mly-selection-ring', + width: 58, + height: 58, + ...(clamp + ? { heightReference: Cesium.HeightReference.CLAMP_TO_GROUND } + : {}), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + }); + requestRender(); + return row; + } + + function clearSelection({ keepId = false } = {}) { + const features = state.features; + if (features.selectedId) { + const previous = features.billboardById?.get(features.selectedId); + if (previous) previous.scale = 1; + } + features.highlight?.removeAll(); + if (!keepId) features.selectedId = null; + requestRender(); + } + + function setVisible(visible) { + const features = state.features; + if (features.points) features.points.show = visible && !features.iconMode; + if (features.collection) + features.collection.show = + visible && features.iconMode && !features.iconsHidden; + if (features.highlight) features.highlight.show = visible; + requestRender(); + } + + /** Hide the flat icons while 3D objects stand in for them. */ + function setIconsHidden(hidden) { + state.features.iconsHidden = hidden === true; + if (state.features.collection) + state.features.collection.show = + state.features.iconMode && !state.features.iconsHidden; + requestRender(); + } + + /** Fly the camera to frame the query bbox. */ + function frame(bbox = state.features.bbox, { duration = 2.2 } = {}) { + if (!bbox || !state.viewer) return; + const [west, south, east, north] = bbox; + state.viewer.camera.flyTo({ + destination: Cesium.Rectangle.fromDegrees(west, south, east, north), + duration, + }); + } + + function findRow(id) { + return state.features.byId.get(id) || null; + } + + function destroy(viewer) { + for (const key of ['points', 'collection', 'highlight']) { + const collection = state.features[key]; + if (collection) { + sprites?.unregisterSpriteCollection?.('mapillary', collection); + viewer?.scene?.primitives?.remove(collection); + state.features[key] = null; + } + } + } + + return { + ensureCollections, + reset, + appendRows, + upgradeToIcons, + setVisible, + setIconsHidden, + frame, + findRow, + select, + clearSelection, + spriteImage, + spriteSvg, + destroy, + }; +} diff --git a/src/layers/mapillary/glyphs.js b/src/layers/mapillary/glyphs.js new file mode 100644 index 000000000..b649a6942 --- /dev/null +++ b/src/layers/mapillary/glyphs.js @@ -0,0 +1,167 @@ +/** + * Canvas glyphs for billboards: a camera cone for images, a ring for + * panoramas, a position marker for the street-level viewer, and a fallback + * dot for map features whose sprite is unavailable. Pure canvas drawing so + * it can run without Cesium. + */ + +const _cache = new Map(); + +function canvas(size) { + const element = document.createElement('canvas'); + element.width = size; + element.height = size; + return element; +} + +/** Cone glyph pointing "up"; rotate the billboard by the compass angle. */ +export function imageConeGlyph({ + size = 32, + color = '#e8eaed', + pano = false, +} = {}) { + const key = `cone:${size}:${color}:${pano}`; + if (_cache.has(key)) return _cache.get(key); + const element = canvas(size); + const ctx = element.getContext('2d'); + const c = size / 2; + if (!pano) { + ctx.beginPath(); + ctx.moveTo(c, c); + ctx.arc(c, c, size * 0.46, -Math.PI / 2 - 0.62, -Math.PI / 2 + 0.62); + ctx.closePath(); + ctx.fillStyle = color; + ctx.globalAlpha = 0.42; + ctx.fill(); + ctx.globalAlpha = 1; + } else { + ctx.beginPath(); + ctx.arc(c, c, size * 0.4, 0, Math.PI * 2); + ctx.strokeStyle = color; + ctx.globalAlpha = 0.5; + ctx.lineWidth = Math.max(1.5, size * 0.09); + ctx.stroke(); + ctx.globalAlpha = 1; + } + ctx.beginPath(); + ctx.arc(c, c, size * 0.16, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.lineWidth = Math.max(1, size * 0.05); + ctx.strokeStyle = 'rgba(10,10,15,0.85)'; + ctx.stroke(); + _cache.set(key, element); + return element; +} + +/** Pulsing-looking marker for the image the viewer currently shows. */ +export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) { + const key = `pos:${size}:${color}`; + if (_cache.has(key)) return _cache.get(key); + const element = canvas(size); + const ctx = element.getContext('2d'); + const c = size / 2; + ctx.beginPath(); + ctx.moveTo(c, c); + ctx.arc(c, c, size * 0.48, -Math.PI / 2 - 0.55, -Math.PI / 2 + 0.55); + ctx.closePath(); + ctx.fillStyle = color; + ctx.globalAlpha = 0.55; + ctx.fill(); + ctx.globalAlpha = 1; + ctx.beginPath(); + ctx.arc(c, c, size * 0.2, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = '#0a0a0f'; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(c, c, size * 0.3, 0, Math.PI * 2); + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.globalAlpha = 0.8; + ctx.stroke(); + _cache.set(key, element); + return element; +} + +/** Fallback feature dot when no sprite exists for a value. */ +export function featureDotGlyph({ size = 30, color = '#00d4ff' } = {}) { + const key = `dot:${size}:${color}`; + if (_cache.has(key)) return _cache.get(key); + const element = canvas(size); + const ctx = element.getContext('2d'); + const c = size / 2; + ctx.beginPath(); + ctx.arc(c, c, size * 0.42, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(10,10,15,0.82)'; + ctx.fill(); + ctx.beginPath(); + ctx.arc(c, c, size * 0.26, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + _cache.set(key, element); + return element; +} + +/** + * Wrap a Mapillary sprite SVG (viewBox only, no intrinsic size) into a fixed + * square canvas with a dark backing disc so it reads over imagery. + * @param {string} svgText + * @param {{size?: number}} [options] + * @returns {Promise} + */ +export function rasterizeSprite(svgText, { size = 60 } = {}) { + const sized = svgText.replace( + /]*)>/i, + (match, attributes) => + ``, + ); + const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(sized)}`; + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + const element = canvas(size); + const ctx = element.getContext('2d'); + const c = size / 2; + ctx.beginPath(); + ctx.arc(c, c, size * 0.5, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(10,10,15,0.78)'; + ctx.fill(); + ctx.lineWidth = 1.5; + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; + ctx.stroke(); + const inset = size * 0.14; + ctx.drawImage(image, inset, inset, size - inset * 2, size - inset * 2); + resolve(element); + }; + image.onerror = () => reject(new Error('sprite rasterization failed')); + image.src = url; + }); +} + +/** Selection ring drawn behind the picked map feature. */ +export function selectionRingGlyph({ size = 64, color = '#00d4ff' } = {}) { + const key = `ring:${size}:${color}`; + if (_cache.has(key)) return _cache.get(key); + const element = canvas(size); + const ctx = element.getContext('2d'); + const c = size / 2; + ctx.beginPath(); + ctx.arc(c, c, size * 0.46, 0, Math.PI * 2); + ctx.strokeStyle = color; + ctx.lineWidth = Math.max(2, size * 0.06); + ctx.globalAlpha = 0.95; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(c, c, size * 0.34, 0, Math.PI * 2); + ctx.lineWidth = Math.max(1, size * 0.03); + ctx.globalAlpha = 0.5; + ctx.stroke(); + ctx.globalAlpha = 1; + _cache.set(key, element); + return element; +} diff --git a/src/layers/mapillary/index.js b/src/layers/mapillary/index.js new file mode 100644 index 000000000..9cd211b5b --- /dev/null +++ b/src/layers/mapillary/index.js @@ -0,0 +1,435 @@ +import { createState } from './state.js'; +import { createCoverage } from './coverage.js'; +import { createSequences } from './sequences.js'; +import { createFeatures } from './features.js'; +import { createViewerBridge } from './viewerBridge.js'; +import { createQuery } from './query.js'; +import { createSelection } from './selection.js'; +import * as Cesium from 'cesium'; +import { + COLORS, + COVERAGE_RECENT_DAYS, + MAPILLARY_CREDIT_HTML, + MAPILLARY_KEY_ID, + MAPILLARY_LAYER_ID, +} from './policy.js'; +import { groupCountsByLabel, humanizeValue } from './values.js'; + +export { MAPILLARY_LAYER_ID } from './policy.js'; + +const SOURCE_METHODS = [ + 'getStatus', + 'getTile', + 'queryFeatures', + 'plan', + 'geocode', + 'getImage', + 'getSequenceImages', + 'nearestImages', + 'getMapFeature', + 'spriteUrl', +]; + +/** + * Construct the Mapillary street-level layer: camera-driven coverage, + * per-sequence image cones, the embedded viewer, and the natural-language + * feature query. Application services are injected; nothing here reads the + * DOM except the viewer host the UI hands over. + */ +export function createMapillaryLayer({ source, services = {} }) { + if (!SOURCE_METHODS.every((method) => typeof source?.[method] === 'function')) + throw new TypeError('A Mapillary source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, source, parts }; + parts.coverage = createCoverage(context); + parts.sequences = createSequences(context); + parts.features = createFeatures(context); + parts.viewer = createViewerBridge(context); + parts.query = createQuery(context); + parts.selection = createSelection(context); + + // Street-level actions shared by clicks, the query engine and the UI. + parts.street = { + async openImage(imageId, { keepFeature = false } = {}) { + const host = state.street.host; + if (!host) { + state.street.error = 'Open the Mapillary panel to view imagery'; + notify(); + return; + } + if (!keepFeature) { + parts.features.clearSelection(); + state.street.feature = null; + } + await parts.viewer.open(imageId, host); + }, + async openFeature(featureId) { + const row = parts.features.findRow(featureId); + parts.features.select(featureId); + state.street.loading = true; + state.street.error = null; + state.street.feature = row + ? { + id: featureId, + value: row[3], + label: humanizeValue(row[3]), + firstSeen: row[4], + lastSeen: row[5], + } + : { id: featureId }; + notify(); + try { + const detail = await source.getMapFeature(featureId); + const images = detail?.images?.data || []; + state.street.feature = { + ...state.street.feature, + value: detail.object_value, + label: humanizeValue(detail.object_value), + firstSeen: + Date.parse(detail.first_seen_at) || state.street.feature.firstSeen, + lastSeen: + Date.parse(detail.last_seen_at) || state.street.feature.lastSeen, + imageCount: images.length, + }; + if (!images.length) throw new Error('No image detected this feature'); + await parts.street.openImage(images[0].id, { keepFeature: true }); + await parts.viewer.highlightDetections( + images[0].id, + detail.object_value, + humanizeValue(detail.object_value), + ); + } catch (error) { + state.street.error = error?.message || 'Feature detail unavailable'; + state.street.loading = false; + notify(); + } + }, + }; + + /** Run low-priority work when the browser is idle (or soon, headless). */ + function scheduleIdle(task) { + if (typeof globalThis.requestIdleCallback === 'function') + globalThis.requestIdleCallback(() => task(), { timeout: 1500 }); + else setTimeout(task, 200); + } + + let notifyQueued = false; + function notify() { + if (notifyQueued) return; + notifyQueued = true; + queueMicrotask(() => { + notifyQueued = false; + const snapshot = getUIState(); + for (const listener of [...state.listeners]) { + try { + listener(snapshot); + } catch (error) { + console.warn('[Data:Mapillary] listener error:', error); + } + } + }); + } + state.notify = notify; + + async function refreshStatus() { + try { + state.status = await source.getStatus(); + state.statusError = null; + state.keyRequired = state.status?.configured !== true; + } catch (error) { + state.statusError = error?.message || 'status unavailable'; + state.keyRequired = !source.hasToken(); + } + notify(); + } + + /** Colour key for the coverage web, in the order the panel lists it. */ + function coverageLegend() { + const years = Math.round(COVERAGE_RECENT_DAYS / 365); + return [ + { key: 'recent', label: `Recent (≤${years} yr)`, color: COLORS.coverage }, + { key: 'older', label: 'Older', color: COLORS.coverageOld }, + { key: 'pano', label: '360°', color: COLORS.pano }, + { key: 'selected', label: 'Selected', color: COLORS.selected }, + ]; + } + + /** CC BY-SA attribution shown on the globe while the layer is on. */ + function presentCredit(viewer) { + if (state.credit || !viewer?.creditDisplay) return; + try { + state.credit = new Cesium.Credit(MAPILLARY_CREDIT_HTML, true); + viewer.creditDisplay.addStaticCredit(state.credit); + } catch { + state.credit = null; + } + } + + function hideCredit(viewer) { + if (!state.credit) return; + try { + viewer?.creditDisplay?.removeStaticCredit?.(state.credit); + } catch { + /* credit display already torn down */ + } + state.credit = null; + } + + function getUIState() { + return { + enabled: state.enabled, + keyRequired: state.keyRequired, + planner: state.status?.planner === true, + plannerModel: state.status?.plannerModel || null, + coverage: { + zoom: state.coverage.zoom, + kind: state.coverage.kind, + filter: { ...state.coverage.filter }, + legend: coverageLegend(), + loading: state.coverage.loading > 0, + sequences: parts.coverage.sequenceCount(), + hint: state.coverage.hint, + error: state.coverage.lastError, + }, + sequence: { + selectedId: state.sequence.selectedId, + images: state.sequence.images.length, + loading: state.sequence.loading, + }, + features: { + total: state.features.total, + title: state.features.title, + loading: state.features.loading, + progress: { ...state.features.progress }, + counts: groupCountsByLabel(state.features.counts.entries()), + truncated: state.features.truncated, + failed: state.features.failed, + iconMode: state.features.iconMode, + hasBbox: Boolean(state.features.bbox), + selectedId: state.features.selectedId, + bbox: state.features.bbox ? [...state.features.bbox] : null, + }, + street: { + open: state.street.open, + follow: state.street.follow, + loading: state.street.loading, + error: state.street.error, + imageId: state.street.imageId, + position: state.street.position ? { ...state.street.position } : null, + bearing: state.street.bearing, + isPano: state.street.isPano, + capturedAt: state.street.capturedAt, + sequenceId: state.street.sequenceId, + creator: state.street.creator || null, + renderMode: state.street.renderMode, + feature: state.street.feature ? { ...state.street.feature } : null, + highlight: state.street.highlight + ? { ...state.street.highlight } + : null, + }, + query: { + busy: state.query.busy, + stage: state.query.stage, + prompt: state.query.prompt, + place: state.query.place, + answer: state.query.answer, + error: state.query.error, + title: state.query.title || '', + usage: state.query.usage, + history: state.query.history.map(({ prompt, total }) => ({ + prompt, + total, + })), + }, + }; + } + + const layer = { + id: MAPILLARY_LAYER_ID, + name: 'Street Level', + icon: '📷', + source: 'Mapillary', + updateInterval: 0, + statsRefreshInterval: 1000, + requiresKeyId: MAPILLARY_KEY_ID, + + init(viewer) { + if (state.initialized) + throw new Error('Mapillary layer is already initialized'); + state.viewer = viewer; + state.initialized = true; + parts.sequences.ensureCollections(viewer); + parts.features.ensureCollections(viewer); + parts.sequences.setVisible(false); + parts.features.setVisible(false); + refreshStatus(); + console.log('[Data:Mapillary] Initialized'); + }, + + enable(viewer) { + state.enabled = true; + presentCredit(viewer); + parts.sequences.setVisible(true); + parts.features.setVisible(true); + parts.selection.install(viewer); + parts.coverage.attach(viewer); + if (!state.status) refreshStatus(); + scheduleIdle(() => parts.viewer.prewarm()); + notify(); + }, + + disable() { + state.enabled = false; + hideCredit(state.viewer); + parts.query.abort(); + parts.features.clearSelection(); + parts.coverage.setResting(false); + parts.coverage.detach(); + parts.coverage.clear(); + parts.sequences.clearSelection(); + parts.selection.uninstall(); + parts.viewer.destroy(); + parts.sequences.setVisible(false); + parts.features.setVisible(false); + notify(); + }, + + async update() { + return state.enabled; + }, + + destroy(viewer = state.viewer) { + layer.disable(); + parts.features.destroy(viewer); + parts.sequences.destroy(viewer); + state.listeners.clear(); + state.viewer = null; + state.initialized = false; + state.destroyed = true; + }, + + getStats() { + const loading = + state.coverage.loading > 0 || + state.features.loading || + state.query.busy; + let loadingLabel = ''; + if (state.keyRequired) loadingLabel = 'KEY REQUIRED'; + else if (state.query.busy) loadingLabel = `AI · ${state.query.stage}`; + else if (state.features.loading) + loadingLabel = `tiles ${state.features.progress.done}/${state.features.progress.tiles}`; + else if (state.coverage.loading > 0) loadingLabel = 'loading coverage...'; + else if (state.features.total) + loadingLabel = `${state.features.total.toLocaleString()} features`; + else if (state.coverage.hint && state.enabled) + loadingLabel = state.coverage.hint; + return { + count: state.features.total || parts.coverage.sequenceCount(), + sequences: parts.coverage.sequenceCount(), + features: state.features.total, + loading, + keyRequired: state.keyRequired, + error: state.keyRequired + ? 'KEY REQUIRED' + : state.coverage.lastError || state.query.error, + loadingLabel, + }; + }, + + // ── Public surface used by the panel and the voice/analyst seams ───── + subscribe(listener) { + if (typeof listener !== 'function') return () => {}; + state.listeners.add(listener); + return () => state.listeners.delete(listener); + }, + getUIState, + /** The DOM element the MapillaryJS viewer renders into. */ + attachViewerHost(element) { + state.street.host = element || null; + if (element && state.street.open) parts.viewer.resize(); + if (element && state.enabled) + scheduleIdle(() => parts.viewer.prewarm(element)); + }, + runQuery: (prompt) => parts.query.run(prompt), + /** Stop a running query (planning, geocoding or streaming). */ + abortQuery: () => parts.query.abort(), + /** Imagery filter for coverage, cones and nearest-image lookups. */ + setCoverageFilter(next) { + parts.coverage.setFilter(next); + parts.sequences.rerender(); + }, + getCoverageFilter: () => ({ ...state.coverage.filter }), + selectFeature: (id) => parts.features.select(id), + clearFeatureSelection: () => parts.features.clearSelection(), + /** Execute a ready-made plan (voice agent, tests) without the planner. */ + runPlan: (plan, options) => parts.query.runPlan(plan, options), + clearQuery: () => parts.query.clear(), + frameResults: () => parts.features.frame(), + openImage: (imageId) => parts.street.openImage(imageId), + openFeature: (featureId) => parts.street.openFeature(featureId), + async openNearest(point) { + const view = point || parts.query.viewContext(); + if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon)) + return false; + state.street.loading = true; + state.street.error = null; + notify(); + try { + const images = await source.nearestImages({ + lat: view.lat, + lon: view.lon, + radius: 50, + limit: 5, + }); + if (!images.length) + throw new Error( + 'No Mapillary imagery within 50 m of the view centre', + ); + await parts.street.openImage(images[0].id); + return true; + } catch (error) { + state.street.error = error?.message || 'Nearest image unavailable'; + state.street.loading = false; + notify(); + return false; + } + }, + /** + * Close the image and deselect it everywhere on the map: position + * marker, highlighted sequence and its cones, result ring and outline. + */ + closeViewer() { + parts.viewer.close(); + parts.features.clearSelection(); + parts.sequences.clearSelection(); + }, + setViewerRenderMode: (mode) => parts.viewer.setRenderMode(mode), + setFollow: (enabled) => parts.viewer.setFollow(enabled), + lookAtImage: () => parts.viewer.lookAtPosition(), + resizeViewer: () => parts.viewer.resize(), + selectSequence: (id) => parts.sequences.select(id), + clearSequence: () => parts.sequences.clearSelection(), + refreshCoverage: () => parts.coverage.refresh(), + + /** Result rows for the analyst query engine: plain JSON-safe objects. */ + getAnalystRecords(maxCount = 2000) { + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + return state.features.rows + .slice(0, limit) + .map(([lon, lat, id, value, first, last]) => ({ + id, + type: 'mapillary-feature', + value, + label: humanizeValue(value), + lat, + lon, + firstSeen: first ? new Date(first).toISOString() : null, + lastSeen: last ? new Date(last).toISOString() : null, + })); + }, + }; + return layer; +} diff --git a/src/layers/mapillary/ndjson.js b/src/layers/mapillary/ndjson.js new file mode 100644 index 000000000..76bc6049b --- /dev/null +++ b/src/layers/mapillary/ndjson.js @@ -0,0 +1,61 @@ +/** + * Incremental NDJSON parsing for the streamed feature query. The server + * writes one JSON object per line; chunks arrive split at arbitrary byte + * boundaries, so a parser keeps the unfinished tail between pushes. + */ +export function createNdjsonParser(onRecord, onError = () => {}) { + let tail = ''; + const emitLine = (line) => { + const text = line.trim(); + if (!text) return; + try { + onRecord(JSON.parse(text)); + } catch (error) { + onError(error, text); + } + }; + return { + /** Feed one decoded text chunk. */ + push(chunk) { + tail += chunk; + let index; + while ((index = tail.indexOf('\n')) >= 0) { + emitLine(tail.slice(0, index)); + tail = tail.slice(index + 1); + } + }, + /** Flush a final line that had no trailing newline. */ + end() { + if (tail) emitLine(tail); + tail = ''; + }, + }; +} + +/** + * Read a fetch Response body as NDJSON, invoking `onRecord` per object. + * Resolves when the stream ends; rejects on network failure or abort. + */ +export async function readNdjsonResponse(response, onRecord, { signal } = {}) { + const parser = createNdjsonParser(onRecord); + const decoder = new TextDecoder(); + const reader = response.body?.getReader?.(); + if (!reader) { + parser.push(await response.text()); + parser.end(); + return; + } + const abort = () => reader.cancel().catch(() => {}); + signal?.addEventListener('abort', abort, { once: true }); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + parser.push(decoder.decode(value, { stream: true })); + } + parser.push(decoder.decode()); + parser.end(); + } finally { + signal?.removeEventListener('abort', abort); + } +} diff --git a/src/layers/mapillary/ndjson.test.mjs b/src/layers/mapillary/ndjson.test.mjs new file mode 100644 index 000000000..f5c81dd58 --- /dev/null +++ b/src/layers/mapillary/ndjson.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createNdjsonParser } from './ndjson.js'; + +test('records split across chunks are reassembled in order', () => { + const seen = []; + const parser = createNdjsonParser((record) => seen.push(record)); + parser.push('{"type":"start","tiles":2}\n{"type":"ti'); + parser.push('le","i":0,"rows":[[1,2,"a","v",0,0]]}\n'); + parser.push('{"type":"done"}'); + parser.end(); + assert.deepEqual( + seen.map((record) => record.type), + ['start', 'tile', 'done'], + ); + assert.deepEqual(seen[1].rows, [[1, 2, 'a', 'v', 0, 0]]); +}); + +test('a malformed line is reported and skipped', () => { + const seen = []; + const errors = []; + const parser = createNdjsonParser( + (record) => seen.push(record), + (error, line) => errors.push(line), + ); + parser.push('not json\n{"ok":true}\n\n'); + parser.end(); + assert.deepEqual(seen, [{ ok: true }]); + assert.deepEqual(errors, ['not json']); +}); diff --git a/src/layers/mapillary/policy.js b/src/layers/mapillary/policy.js new file mode 100644 index 000000000..746bbc0c8 --- /dev/null +++ b/src/layers/mapillary/policy.js @@ -0,0 +1,65 @@ +/** Identity and tuning for the Mapillary street-level layer. */ +export const MAPILLARY_LAYER_ID = 'mapillary'; +export const MAPILLARY_KEY_ID = 'mapillary'; +export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com'; + +/** Stable id prefixes for picked primitives. */ +export const PICK_PREFIX = Object.freeze({ + sequence: 'mly:seq:', + image: 'mly:img:', + feature: 'mly:feat:', + object: 'mly:obj:', + position: 'mly:pos', +}); + +/** Brand green for coverage; panoramas get a distinct hue; selection is GEV cyan. */ +export const COLORS = Object.freeze({ + coverage: '#05cb63', + coverageOld: '#2e7d5b', + pano: '#ff4fd8', + selected: '#00d4ff', + image: '#e8eaed', + position: '#ffb300', +}); + +/** Camera-driven coverage refresh. */ +export const COVERAGE_MOVE_DEBOUNCE_MS = 320; +export const COVERAGE_MAX_TILES = 9; +/** Overview (z0–5) coverage points seen from orbit. */ +export const COVERAGE_OVERVIEW_MAX_TILES = 16; +export const COVERAGE_OVERVIEW_POINT_PX = 2.5; +export const COVERAGE_MAX_SEQUENCES = 6000; +export const COVERAGE_LINE_WIDTH_PX = 2.5; +/** Sequences newer than this many days draw at full brightness. */ +export const COVERAGE_RECENT_DAYS = 730; + +/** Per-sequence image cones after a sequence is selected. */ +export const SEQUENCE_IMAGES_LIMIT = 2000; +export const IMAGE_CONE_SIZE_PX = 26; +export const IMAGE_CONE_MIN_SPACING_M = 3; + +/** Nearest-image search when the user asks to look at a place. */ +export const NEAREST_RADIUS_M = 50; +export const NEAREST_LIMIT = 8; + +/** Query-result rendering: clamped icons up to this many features, unclamped + * icons up to FEATURE_ICON_MAX, coloured points beyond. */ +export const FEATURE_ICON_LIMIT = 6000; +export const FEATURE_ICON_MAX = 80_000; +export const FEATURE_ICON_SIZE_PX = 30; +export const FEATURE_POINT_SIZE_PX = 9; + +/** + * On-globe credit shown while the layer is enabled. Mapillary imagery and + * derived data are CC BY-SA 4.0 and require visible attribution. + */ +export const MAPILLARY_CREDIT_HTML = + 'Street Level: imagery © Mapillary contributors, CC BY-SA 4.0'; + +/** Fields requested from the graph API. */ +export const IMAGE_FIELDS = + 'id,captured_at,compass_angle,computed_compass_angle,geometry,computed_geometry,computed_altitude,is_pano,sequence,thumb_256_url,creator,quality_score'; +export const SEQUENCE_IMAGE_FIELDS = + 'id,captured_at,compass_angle,geometry,is_pano,computed_altitude'; +export const FEATURE_FIELDS = + 'id,object_value,geometry,first_seen_at,last_seen_at,images,aligned_direction'; diff --git a/src/layers/mapillary/query.js b/src/layers/mapillary/query.js new file mode 100644 index 000000000..71059752c --- /dev/null +++ b/src/layers/mapillary/query.js @@ -0,0 +1,516 @@ +import * as Cesium from 'cesium'; +import { passesImageryFilter, visibleBbox } from './coverage.js'; +import { countTilesForBbox, normalizeBbox } from './tileMath.js'; +import { groupCountsByLabel } from './values.js'; + +/** Fallback half-size (degrees) around a geocoded point with no bounds. */ +const POINT_PLACE_HALF_DEG = 0.02; +/** + * Smallest place box worth querying (degrees, ~1.1 km). Nominatim returns a + * node-sized box for many neighbourhoods and landmarks; a query on that would + * cover one intersection and read as "nothing here". + */ +const MIN_PLACE_SPAN_DEG = 0.01; +/** Radii (metres) of the probe rings used when nothing sits within 50 m. */ +const NEAREST_RING_M = [90, 180, 300]; +/** Cap for a "current view" query when the server does not report one. */ +const DEFAULT_MAX_TILES = 600; +const FEATURE_ZOOM = 14; +/** A current-view query wider than this is the horizon, not an area. */ +const MAX_VIEW_SPAN_DEG = 4; + +/** Shrink a bbox around its centre until it fits the tile budget. */ +export function fitBboxToTileBudget(bbox, maxTiles, zoom = FEATURE_ZOOM) { + let box = normalizeBbox(bbox); + if (!box) return null; + let guard = 0; + while ( + countTilesForBbox([box.west, box.south, box.east, box.north], zoom) > + maxTiles && + guard++ < 40 + ) { + const cx = (box.west + box.east) / 2; + const cy = (box.south + box.north) / 2; + const hw = ((box.east - box.west) / 2) * 0.85; + const hh = ((box.north - box.south) / 2) * 0.85; + box = { west: cx - hw, east: cx + hw, south: cy - hh, north: cy + hh }; + } + return [box.west, box.south, box.east, box.north]; +} + +/** Approximate metres between two lon/lat points (small distances). */ +function metresBetween(a, b) { + const lat = ((a.lat + b.lat) / 2) * (Math.PI / 180); + const dx = (b.lon - a.lon) * 111_320 * Math.cos(lat); + const dy = (b.lat - a.lat) * 110_540; + return Math.hypot(dx, dy); +} + +/** Grow a bbox symmetrically until both sides reach `minSpan` degrees. */ +export function enforceMinimumSpan(bbox, minSpan) { + const [west, south, east, north] = bbox; + if (east - west >= minSpan && north - south >= minSpan) return bbox; + const cx = (west + east) / 2; + const cy = (south + north) / 2; + const hw = Math.max((east - west) / 2, minSpan / 2); + const hh = Math.max((north - south) / 2, minSpan / 2); + return [cx - hw, cy - hh, cx + hw, cy + hh]; +} + +/** Turn a geocode result into a bbox, label and centre. */ +export function bboxFromGeocode(result) { + const geometry = result?.geometry; + const location = geometry?.location; + if ( + !location || + !Number.isFinite(location.lat) || + !Number.isFinite(location.lng) + ) + return null; + const bounds = geometry.bounds || geometry.viewport; + const bbox = bounds + ? [ + bounds.southwest.lng, + bounds.southwest.lat, + bounds.northeast.lng, + bounds.northeast.lat, + ] + : [ + location.lng - POINT_PLACE_HALF_DEG, + location.lat - POINT_PLACE_HALF_DEG, + location.lng + POINT_PLACE_HALF_DEG, + location.lat + POINT_PLACE_HALF_DEG, + ]; + return { + bbox: enforceMinimumSpan(bbox, MIN_PLACE_SPAN_DEG), + label: result.formatted_address || '', + center: { lat: location.lat, lon: location.lng }, + }; +} + +/** "1 tile" / "3 tiles". */ +function plural(count, singular, pluralForm = `${singular}s`) { + return `${count.toLocaleString()} ${count === 1 ? singular : pluralForm}`; +} + +/** Pluralise a humanised class label for a count ("fire hydrant" → "fire hydrants"). */ +export function pluralLabel(label, count) { + const word = String(label || '').toLowerCase(); + if (count === 1 || !word) return word; + if (/(s|x|z|ch|sh)$/.test(word)) return `${word}es`; + if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`; + return `${word}s`; +} + +/** + * Compose the one-line result summary shown under the query box. Returns an + * empty string for zero results so the caller can replace, not append to, + * the planner's optimistic answer. + */ +export function summarizeResults(features) { + const total = features.total || 0; + if (!total) return ''; + const values = groupCountsByLabel(features.counts.entries()); + const classes = values + .slice(0, 3) + .map( + ({ label, count }) => + `${count.toLocaleString()} ${pluralLabel(label, count)}`, + ) + .join(', '); + const more = + values.length > 3 + ? ` and ${plural(values.length - 3, 'more class', 'more classes')}` + : ''; + const years = + features.earliest && features.latest + ? ` · seen ${new Date(features.earliest).getFullYear()}–${new Date(features.latest).getFullYear()}` + : ''; + const truncated = features.truncated ? ' · capped' : ''; + const failed = features.failed + ? ` · ${plural(features.failed, 'tile')} failed` + : ''; + // One class: "834 fire hydrants · seen 2017–2021". Several: lead with the total. + const head = + values.length === 1 + ? classes + : `${plural(total, 'feature')}: ${classes}${more}`; + return `${head}${years}${truncated}${failed}`; +} + +/** What the panel says when a query found nothing where it looked. */ +export function noResultsAnswer(plan, area) { + const subject = (plan?.title || 'matching features') + .replace(/\s*·.*$/, '') + .toLowerCase(); + const where = area?.label ? ` in ${area.label}` : ' here'; + return `No ${subject} mapped${where} yet. Try a larger area, another place, or set SINCE back to any date.`; +} + +/** Natural-language query orchestration: plan → resolve area → stream → draw. */ +export function createQuery({ state, source, parts }) { + function notify() { + state.notify?.(); + } + + function viewContext() { + const viewer = state.viewer; + if (!viewer) return {}; + const carto = viewer.camera.positionCartographic; + const bbox = visibleBbox(viewer); + const center = bbox + ? { lat: (bbox[1] + bbox[3]) / 2, lon: (bbox[0] + bbox[2]) / 2 } + : { + lat: Cesium.Math.toDegrees(carto.latitude), + lon: Cesium.Math.toDegrees(carto.longitude), + }; + const ground = viewer.scene.globe?.getHeight?.(carto); + return { + lat: center.lat, + lon: center.lon, + heightM: carto.height - (Number.isFinite(ground) ? ground : 0), + }; + } + + function maxTiles() { + return state.status?.limits?.maxTiles || DEFAULT_MAX_TILES; + } + + async function resolveArea(plan, signal) { + if (plan.place) { + const results = await source.geocode(plan.place, { signal }); + const resolved = bboxFromGeocode(results[0]); + if (!resolved) + throw new Error(`Could not find "${plan.place}" on the map`); + return { + ...resolved, + bbox: fitBboxToTileBudget(resolved.bbox, maxTiles()), + fromPlace: true, + }; + } + const bbox = visibleBbox(state.viewer); + // The horizon (or a camera still in flight) yields a continent-sized + // rectangle; querying that would silently pick a box in the middle of it. + if ( + !bbox || + bbox[2] - bbox[0] > MAX_VIEW_SPAN_DEG || + bbox[3] - bbox[1] > MAX_VIEW_SPAN_DEG + ) + throw new Error('Zoom in closer to the ground, or name a place'); + const view = viewContext(); + return { + bbox: fitBboxToTileBudget(bbox, maxTiles()), + label: 'current view', + center: { lat: view.lat, lon: view.lon }, + fromPlace: false, + }; + } + + async function runFeatures(plan, area, signal) { + const features = state.features; + parts.features.reset(); + Object.assign(features, { + bbox: area.bbox, + title: plan.title, + layer: plan.layer, + values: plan.values, + loading: true, + }); + state.query.stage = 'fetching'; + notify(); + // Results are the subject now: rest the coverage web until CLEAR so tens + // of thousands of markers are not read against a web of green lines. + parts.coverage.setResting(true); + // A named place is somewhere else: fly there. The current view already + // shows the area, so leave the camera where the user put it. + if (area.fromPlace) parts.features.frame(area.bbox); + let lastNotify = 0; + await source.queryFeatures( + { + bbox: area.bbox, + layer: plan.layer, + values: plan.values, + seenAfter: plan.seen_after, + seenBefore: plan.seen_before, + }, + (record) => { + if (record.type === 'start') + features.progress = { done: 0, tiles: record.tiles }; + else if (record.type === 'tile') { + features.progress.done++; + if (record.rows?.length) parts.features.appendRows(record.rows); + const now = Date.now(); + if (now - lastNotify > 120) { + lastNotify = now; + notify(); + } + } else if (record.type === 'done') { + features.truncated = record.truncated === true; + features.failed = record.failed || 0; + } + }, + { signal }, + ); + features.loading = false; + notify(); + await parts.features.upgradeToIcons(); + const summary = summarizeResults(features); + state.query.answer = summary + ? `${plan.answer} ${summary}`.trim() + : noResultsAnswer(plan, area); + notify(); + } + + async function runNearest(plan, area, signal) { + const found = await nearestImageAround(area.center, signal, { + preferPano: + plan.prefer_pano === true || state.coverage.filter.pano === 'pano', + }); + if (!found) + throw new Error( + `No Mapillary imagery within ${NEAREST_RING_M[NEAREST_RING_M.length - 1] + 50} m of ${area.label || 'that point'}`, + ); + await parts.street.openImage(found.image.id); + state.query.answer = `${plan.answer} Opened the closest image, about ${Math.round(found.distanceM)} m away.`; + } + + /** + * Mapillary's radius search stops at 50 m, which misses a landmark whose + * geocoded centre sits inside a large footprint. Probe the centre first, + * then rings of points around it, and keep the closest hit overall. + */ + async function nearestImageAround( + center, + signal, + { preferPano = false } = {}, + ) { + const rings = [[center]]; + for (const ring of NEAREST_RING_M) { + const probes = []; + for (let k = 0; k < 8; k++) { + const angle = (k / 8) * Math.PI * 2; + probes.push({ + lat: center.lat + (ring * Math.cos(angle)) / 110_540, + lon: + center.lon + + (ring * Math.sin(angle)) / + (111_320 * Math.cos((center.lat * Math.PI) / 180)), + }); + } + rings.push(probes); + } + for (const probes of rings) { + const results = await Promise.all( + probes.map((probe) => + source + .nearestImages( + { lat: probe.lat, lon: probe.lon, radius: 50, limit: 3 }, + { signal }, + ) + .catch(() => []), + ), + ); + let best = null; + let fallback = null; + for (const images of results) + for (const image of images) { + const coordinates = + image?.computed_geometry?.coordinates || + image?.geometry?.coordinates; + if (!coordinates) continue; + const distanceM = metresBetween(center, { + lon: coordinates[0], + lat: coordinates[1], + }); + const record = { + isPano: image.is_pano === true, + capturedAt: Number(image.captured_at) || 0, + }; + // The dock's imagery filter is a hard constraint; a 360° preference + // from the request only breaks ties within the filtered set. + const candidate = { + image, + distanceM, + score: distanceM + (preferPano && !record.isPano ? 1000 : 0), + }; + if (!fallback || distanceM < fallback.distanceM) fallback = candidate; + if (!passesImageryFilter(record, state.coverage.filter)) continue; + if (!best || candidate.score < best.score) best = candidate; + } + if (best) return best; + if ( + fallback && + state.coverage.filter.pano === 'all' && + !state.coverage.filter.sinceMs + ) + return fallback; + } + return null; + } + + /** + * Execute an already-built plan (from the planner, the voice agent, or a + * test). Resolves the area, streams features or opens imagery, and records + * the turn in the history. + */ + async function execute(plan, { prompt = plan?.title || '', signal } = {}) { + if (!plan || typeof plan !== 'object') + throw new Error('A plan is required'); + state.query.lastPlan = plan; + state.query.answer = plan.answer || ''; + state.query.title = plan.title || ''; + notify(); + if (plan.intent === 'unsupported') { + state.query.stage = 'idle'; + } else if (plan.intent === 'coverage') { + state.query.stage = 'resolving'; + notify(); + const area = await resolveArea(plan, signal); + state.query.place = area?.label || plan.place || null; + parts.features.frame(area.bbox, { duration: 2.4 }); + state.query.stage = 'idle'; + } else if (plan.intent === 'nearest_image') { + state.query.stage = 'resolving'; + notify(); + const area = await resolveArea(plan, signal); + state.query.place = area?.label || plan.place || null; + await runNearest(plan, area, signal); + state.query.stage = 'idle'; + } else { + if (!plan.layer) plan = { ...plan, layer: 'points' }; + state.query.stage = 'resolving'; + notify(); + const area = await resolveArea(plan, signal); + state.query.place = area?.label || plan.place || null; + await runFeatures(plan, area, signal); + state.query.stage = 'done'; + } + state.query.history.push({ prompt, plan, total: state.features.total }); + } + + /** Run one natural-language request end to end. */ + async function run(prompt) { + const text = String(prompt || '').trim(); + if (!text) return; + abort(); + const controller = new AbortController(); + state.query.abort = controller; + Object.assign(state.query, { + busy: true, + stage: 'planning', + prompt: text, + answer: '', + error: null, + usage: null, + }); + notify(); + try { + const { plan, usage, model } = await source.plan( + { + query: text, + context: { view: viewContext(), previousPlan: state.query.lastPlan }, + }, + { signal: controller.signal }, + ); + if (controller.signal.aborted) return; + state.query.usage = usage ? { ...usage, model } : null; + await execute(plan, { prompt: text, signal: controller.signal }); + } catch (error) { + if (controller.signal.aborted) return; + state.features.loading = false; + state.query.stage = 'error'; + state.query.error = describeError(error); + } finally { + if (state.query.abort === controller) { + state.query.busy = false; + state.query.abort = null; + } + notify(); + } + } + + /** Execute a supplied plan with the same lifecycle bookkeeping as run(). */ + async function runPlan(plan, { prompt } = {}) { + abort(); + const controller = new AbortController(); + state.query.abort = controller; + Object.assign(state.query, { + busy: true, + stage: 'resolving', + prompt: prompt || plan?.title || '', + answer: '', + error: null, + usage: null, + }); + notify(); + try { + await execute(plan, { prompt, signal: controller.signal }); + } catch (error) { + if (controller.signal.aborted) return; + state.features.loading = false; + state.query.stage = 'error'; + state.query.error = describeError(error); + } finally { + if (state.query.abort === controller) { + state.query.busy = false; + state.query.abort = null; + } + notify(); + } + } + + function describeError(error) { + if ( + error?.keyRequired === 'anthropic' || + error?.payload?.keyRequired === 'anthropic' + ) + return 'Mapillary AI needs an Anthropic key — add one in POWER UP.'; + if ( + error?.keyRequired === 'mapillary' || + error?.payload?.keyRequired === 'mapillary' || + error?.payload?.keyRequired === true + ) + return 'Add a Mapillary client token in POWER UP first.'; + if (error?.payload?.error === 'area_too_large') { + const { tiles, limit } = error.payload.detail || error.payload; + const times = + Number.isFinite(tiles) && Number.isFinite(limit) && limit > 0 + ? ` (about ${Math.max(2, Math.round(tiles / limit))}× what one query covers)` + : ''; + return `That area is too large for one query${times}. Zoom in closer to the ground or name a smaller place.`; + } + return error?.message || 'Query failed'; + } + + function abort() { + state.query.abort?.abort(); + state.query.abort = null; + state.query.busy = false; + state.features.loading = false; + } + + /** Drop the drawn results and the conversational context. */ + function clear() { + abort(); + parts.features.reset(); + parts.coverage.setResting(false); + Object.assign(state.query, { + stage: 'idle', + prompt: '', + answer: '', + error: null, + lastPlan: null, + usage: null, + title: '', + }); + Object.assign(state.features, { + bbox: null, + title: '', + layer: null, + values: [], + }); + notify(); + } + + return { run, runPlan, execute, clear, abort, viewContext }; +} diff --git a/src/layers/mapillary/query.test.mjs b/src/layers/mapillary/query.test.mjs new file mode 100644 index 000000000..e30ef47f1 --- /dev/null +++ b/src/layers/mapillary/query.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + bboxFromGeocode, + enforceMinimumSpan, + fitBboxToTileBudget, + summarizeResults, +} from './query.js'; +import { countTilesForBbox } from './tileMath.js'; + +test('bboxFromGeocode prefers bounds and falls back to a point box', () => { + const withBounds = bboxFromGeocode({ + geometry: { + location: { lat: 38.58, lng: -121.49 }, + bounds: { + southwest: { lat: 38.44, lng: -121.56 }, + northeast: { lat: 38.69, lng: -121.36 }, + }, + }, + formatted_address: 'Sacramento, California', + }); + assert.deepEqual(withBounds.bbox, [-121.56, 38.44, -121.36, 38.69]); + assert.equal(withBounds.label, 'Sacramento, California'); + const point = bboxFromGeocode({ + geometry: { location: { lat: 42.33, lng: -83.05 } }, + }); + assert.ok(point.bbox[0] < -83.05 && point.bbox[2] > -83.05); + assert.equal(bboxFromGeocode(null), null); +}); + +test('fitBboxToTileBudget shrinks an oversized box around its centre', () => { + const california = [-125, 32, -114, 42]; + const fitted = fitBboxToTileBudget(california, 600); + assert.ok(countTilesForBbox(fitted, 14) <= 600); + const cx = (fitted[0] + fitted[2]) / 2; + assert.ok(Math.abs(cx - -119.5) < 1e-6); + const small = [-121.5, 38.5, -121.4, 38.6]; + assert.deepEqual(fitBboxToTileBudget(small, 600), small); +}); + +test('summarizeResults reports totals, top classes and the sighting window', () => { + const text = summarizeResults({ + total: 1234, + counts: new Map([ + ['object--fire-hydrant', 1000], + ['object--bench', 200], + ['object--mailbox', 20], + ['object--trash-can', 14], + ]), + earliest: Date.UTC(2019, 0, 1), + latest: Date.UTC(2026, 5, 1), + truncated: true, + failed: 2, + }); + assert.match( + text, + /^1,234 features: 1,000 fire hydrant, 200 bench, 20 mailbox and 1 more classes/, + ); + assert.match(text, /seen 2019–2026 · capped · 2 tiles failed$/); + assert.equal( + summarizeResults({ total: 0, counts: new Map() }), + 'No matching features in this area.', + ); +}); + +test('node-sized geocode bounds are grown to a queryable neighbourhood box', () => { + const tiny = bboxFromGeocode({ + geometry: { + location: { lat: 42.3292, lng: -83.0527 }, + bounds: { + southwest: { lat: 42.329, lng: -83.053 }, + northeast: { lat: 42.3294, lng: -83.0524 }, + }, + }, + }); + assert.ok(tiny.bbox[2] - tiny.bbox[0] >= 0.01 - 1e-9); + assert.ok(tiny.bbox[3] - tiny.bbox[1] >= 0.01 - 1e-9); + assert.deepEqual(enforceMinimumSpan([0, 0, 1, 1], 0.5), [0, 0, 1, 1]); +}); diff --git a/src/layers/mapillary/selection.js b/src/layers/mapillary/selection.js new file mode 100644 index 000000000..1c3ab24a8 --- /dev/null +++ b/src/layers/mapillary/selection.js @@ -0,0 +1,114 @@ +import * as Cesium from 'cesium'; +import { MAPILLARY_LAYER_ID, PICK_PREFIX } from './policy.js'; + +/** Click handling for coverage lines, image cones and query results. */ +export function createSelection({ state, parts }) { + const { picking, input } = state.services; + + function ownsPick(pickedId) { + return ( + typeof pickedId === 'string' && + Object.values(PICK_PREFIX).some((prefix) => pickedId.startsWith(prefix)) + ); + } + + function onClick(click) { + const viewer = state.viewer; + if (!viewer || !state.enabled) return; + if (input?.isPointerFree && !input.isPointerFree()) return; + const picked = viewer.scene.pick(click.position); + const id = picking?.resolvePickId + ? picking.resolvePickId(picked) + : picked?.id; + if (!ownsPick(id)) return; + if (id.startsWith(PICK_PREFIX.sequence)) { + parts.sequences.select(id.slice(PICK_PREFIX.sequence.length)); + } else if (id.startsWith(PICK_PREFIX.image)) { + parts.street.openImage(id.slice(PICK_PREFIX.image.length)); + } else if (id.startsWith(PICK_PREFIX.feature)) { + parts.street.openFeature(id.slice(PICK_PREFIX.feature.length)); + } else if (id.startsWith(PICK_PREFIX.object)) { + // 3D object entities are id'd `mly:obj::`. + parts.street.openFeature(id.split(':')[2]); + } + } + + /** + * Esc clears the selected sequence, but only once nothing closer to the + * user wants it: an expanded viewer, an open panel or a text field. + */ + function onKeyDown(event) { + if (event.key !== 'Escape' || event.defaultPrevented) return; + if ( + event.target?.closest?.( + '.panel-collapsible, [role="dialog"], input, textarea, select', + ) + ) + return; + if (!state.sequence.selectedId) return; + event.preventDefault(); + parts.sequences.clearSelection(); + } + + let hoverQueued = false; + let hoverCursor = false; + /** Pointer cursor over anything this layer owns, so lines read as clickable. */ + function onMove(movement) { + const viewer = state.viewer; + if (!viewer || !state.enabled || hoverQueued) return; + hoverQueued = true; + requestAnimationFrame(() => { + hoverQueued = false; + const canvas = viewer.scene?.canvas; + if (!canvas || viewer.isDestroyed?.()) return; + let hit = false; + try { + const picked = viewer.scene.pick(movement.endPosition); + const id = picking?.resolvePickId + ? picking.resolvePickId(picked) + : picked?.id; + hit = ownsPick(id); + } catch { + hit = false; + } + if (hit && !hoverCursor) { + canvas.style.cursor = 'pointer'; + hoverCursor = true; + } else if (!hit && hoverCursor) { + canvas.style.cursor = ''; + hoverCursor = false; + } + }); + } + + function install(viewer) { + if (state.clickHandler) return; + state.clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + state.clickHandler.setInputAction( + onClick, + Cesium.ScreenSpaceEventType.LEFT_CLICK, + ); + state.clickHandler.setInputAction( + onMove, + Cesium.ScreenSpaceEventType.MOUSE_MOVE, + ); + document.addEventListener('keydown', onKeyDown); + picking?.registerPickOwner?.(MAPILLARY_LAYER_ID, ownsPick); + } + + function uninstall() { + if (hoverCursor && state.viewer?.scene?.canvas) { + state.viewer.scene.canvas.style.cursor = ''; + hoverCursor = false; + } + if (state.clickHandler && !state.clickHandler.isDestroyed()) + state.clickHandler.destroy(); + state.clickHandler = null; + document.removeEventListener('keydown', onKeyDown); + picking?.unregisterPickOwner?.(MAPILLARY_LAYER_ID); + } + + return { install, uninstall, ownsPick }; +} diff --git a/src/layers/mapillary/sequences.js b/src/layers/mapillary/sequences.js new file mode 100644 index 000000000..96b7c1513 --- /dev/null +++ b/src/layers/mapillary/sequences.js @@ -0,0 +1,262 @@ +import * as Cesium from 'cesium'; +import { imageConeGlyph } from './glyphs.js'; +import { passesImageryFilter } from './coverage.js'; +import { + COLORS, + IMAGE_CONE_MIN_SPACING_M, + IMAGE_CONE_SIZE_PX, + PICK_PREFIX, +} from './policy.js'; + +/** How many recently viewed sequences keep their image list in memory. */ +const SEQUENCE_CACHE_SIZE = 40; + +/** Approximate metres between two lon/lat points (small distances). */ +function metresBetween(a, b) { + const lat = ((a.lat + b.lat) / 2) * (Math.PI / 180); + const dx = (b.lon - a.lon) * 111_320 * Math.cos(lat); + const dy = (b.lat - a.lat) * 110_540; + return Math.hypot(dx, dy); +} + +/** Normalize a graph image record into the shape the cones use. */ +function normalizeSequenceImage(record) { + const coordinates = record?.geometry?.coordinates; + if (!Array.isArray(coordinates) || coordinates.length < 2) return null; + return { + id: String(record.id), + lon: Number(coordinates[0]), + lat: Number(coordinates[1]), + compassAngle: Number(record.compass_angle) || 0, + capturedAt: Number(record.captured_at) || 0, + isPano: record.is_pano === true, + altitude: Number.isFinite(record.computed_altitude) + ? record.computed_altitude + : null, + }; +} + +/** Drop images closer than the spacing to the previous kept one. */ +function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { + const kept = []; + let last = null; + for (const image of images) { + if (!last || metresBetween(last, image) >= spacingM) { + kept.push(image); + last = image; + } + } + return kept; +} + +/** Image cones for one selected sequence, plus the viewer position marker. */ +export function createSequences({ state, source, parts }) { + const { render, sprites } = state.services; + + function requestRender() { + render?.governorRequestRender?.('mapillary-sequence'); + } + + function ensureCollections(viewer) { + if (!state.sequence.collection) { + state.sequence.collection = new Cesium.BillboardCollection({ + scene: viewer.scene, + }); + viewer.scene.primitives.add(state.sequence.collection); + sprites?.registerSpriteCollection?.( + 'mapillary', + state.sequence.collection, + ); + } + if (!state.street.markerCollection) { + state.street.markerCollection = new Cesium.BillboardCollection({ + scene: viewer.scene, + }); + viewer.scene.primitives.add(state.street.markerCollection); + sprites?.registerSpriteCollection?.( + 'mapillary', + state.street.markerCollection, + ); + } + } + + function clearCones() { + state.sequence.collection?.removeAll(); + state.sequence.images = []; + } + + function renderCones(images) { + const collection = state.sequence.collection; + if (!collection) return; + collection.removeAll(); + const cone = imageConeGlyph({ size: 32, color: COLORS.image }); + const ring = imageConeGlyph({ size: 32, color: COLORS.pano, pano: true }); + for (const image of images) { + if (!passesImageryFilter(image, state.coverage.filter)) continue; + collection.add({ + id: `${PICK_PREFIX.image}${image.id}`, + position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat), + image: image.isPano ? ring : cone, + imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone', + width: IMAGE_CONE_SIZE_PX, + height: IMAGE_CONE_SIZE_PX, + rotation: -Cesium.Math.toRadians(image.compassAngle), + alignedAxis: Cesium.Cartesian3.UNIT_Z, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35), + }); + } + requestRender(); + } + + function remember(sequenceId, images) { + const { cache } = state.sequence; + cache.delete(sequenceId); + cache.set(sequenceId, images); + while (cache.size > SEQUENCE_CACHE_SIZE) + cache.delete(cache.keys().next().value); + } + + /** Select a sequence: highlight its line and load its image cones. */ + async function select(sequenceId) { + if (!sequenceId || !state.viewer) return; + if ( + state.sequence.selectedId === sequenceId && + state.sequence.images.length + ) + return; + if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId) + parts.coverage.recolorSequence(state.sequence.selectedId, false); + state.sequence.abort?.abort(); + state.sequence.selectedId = sequenceId; + parts.coverage.recolorSequence(sequenceId, true); + const cached = state.sequence.cache.get(sequenceId); + if (cached) { + state.sequence.abort = null; + state.sequence.loading = false; + state.sequence.images = cached; + renderCones(cached); + state.notify?.(); + return; + } + const controller = new AbortController(); + state.sequence.abort = controller; + state.sequence.loading = true; + state.notify?.(); + try { + const records = await source.getSequenceImages(sequenceId, { + signal: controller.signal, + }); + if (controller.signal.aborted) return; + const images = thinImages( + records + .map(normalizeSequenceImage) + .filter(Boolean) + .sort((a, b) => a.capturedAt - b.capturedAt), + ); + remember(sequenceId, images); + state.sequence.images = images; + renderCones(images); + } catch (error) { + if (!controller.signal.aborted) + state.street.error = error?.message || 'Sequence images unavailable'; + } finally { + if (state.sequence.abort === controller) { + state.sequence.loading = false; + state.sequence.abort = null; + } + state.notify?.(); + } + } + + function clearSelection() { + state.sequence.abort?.abort(); + state.sequence.abort = null; + if (state.sequence.selectedId) + parts.coverage.recolorSequence(state.sequence.selectedId, false); + state.sequence.selectedId = null; + state.sequence.loading = false; + clearCones(); + requestRender(); + state.notify?.(); + } + + /** Move (or create) the viewer position marker. */ + function setMarker(position, bearing) { + const collection = state.street.markerCollection; + if (!collection) return; + if (!position) { + collection.removeAll(); + state.street.marker = null; + requestRender(); + return; + } + const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat); + if (!state.street.marker) { + state.street.marker = collection.add({ + id: PICK_PREFIX.position, + position: cartesian, + image: (id) => + import('./glyphs.js').then((m) => + m.positionMarkerGlyph({ color: COLORS.position }), + ), + imageId: 'mly-position', + width: 40, + height: 40, + alignedAxis: Cesium.Cartesian3.UNIT_Z, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + }); + } else state.street.marker.position = cartesian; + state.street.marker.rotation = -Cesium.Math.toRadians(bearing || 0); + requestRender(); + } + + /** Re-draw the current sequence's cones (after an imagery filter change). */ + function rerender() { + if (state.sequence.images.length) renderCones(state.sequence.images); + } + + function hide(visible) { + if (state.sequence.collection) state.sequence.collection.show = visible; + if (state.street.markerCollection) + state.street.markerCollection.show = visible; + requestRender(); + } + + function destroy(viewer) { + state.sequence.abort?.abort(); + for (const key of ['collection']) { + const collection = state.sequence[key]; + if (collection) { + sprites?.unregisterSpriteCollection?.('mapillary', collection); + viewer?.scene?.primitives?.remove(collection); + state.sequence[key] = null; + } + } + if (state.street.markerCollection) { + sprites?.unregisterSpriteCollection?.( + 'mapillary', + state.street.markerCollection, + ); + viewer?.scene?.primitives?.remove(state.street.markerCollection); + state.street.markerCollection = null; + state.street.marker = null; + } + state.sequence.images = []; + state.sequence.selectedId = null; + } + + return { + ensureCollections, + select, + clearSelection, + setMarker, + rerender, + setVisible: hide, + destroy, + }; +} diff --git a/src/layers/mapillary/source.js b/src/layers/mapillary/source.js new file mode 100644 index 000000000..c8043b163 --- /dev/null +++ b/src/layers/mapillary/source.js @@ -0,0 +1,233 @@ +import { readNdjsonResponse } from './ndjson.js'; +import { + FEATURE_FIELDS, + IMAGE_FIELDS, + MAPILLARY_GRAPH_HOST, + NEAREST_LIMIT, + NEAREST_RADIUS_M, + SEQUENCE_IMAGE_FIELDS, + SEQUENCE_IMAGES_LIMIT, +} from './policy.js'; + +/** Error carrying the HTTP status and any server-provided payload. */ +export class MapillarySourceError extends Error { + constructor(message, { status = 0, payload = null } = {}) { + super(message); + this.name = 'MapillarySourceError'; + this.status = status; + this.payload = payload; + this.keyRequired = payload?.keyRequired ?? payload?.error === 'no_key'; + } +} + +async function readJsonOrThrow(response, label) { + let payload = null; + try { + payload = await response.json(); + } catch { + /* status is authoritative */ + } + if (!response.ok) + throw new MapillarySourceError( + payload?.error?.message || + payload?.error || + `${label} HTTP ${response.status}`, + { status: response.status, payload }, + ); + return payload; +} + +/** + * Every network path the Mapillary layer uses. Tiles, the feature stream, + * the planner and sprites go through the local dev-server proxy; entity + * lookups go straight to graph.mapillary.com with the client token, exactly + * as the embedded MapillaryJS viewer does. + * @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options + */ +export function createMapillarySource({ + token = '', + fetchImpl = (...args) => globalThis.fetch(...args), + endpoints = {}, +} = {}) { + const urls = { + status: '/api/mapillary/status', + tiles: '/api/mapillary/tiles', + features: '/api/mapillary/features', + plan: '/api/mapillary/plan', + sprite: '/api/mapillary/sprite', + geocode: '/api/geocode', + graph: MAPILLARY_GRAPH_HOST, + ...endpoints, + }; + + async function graph(path, params = {}, { signal } = {}) { + if (!token) + throw new MapillarySourceError('Mapillary token not configured', { + status: 503, + payload: { keyRequired: true }, + }); + const search = new URLSearchParams({ access_token: token, ...params }); + const response = await fetchImpl(`${urls.graph}/${path}?${search}`, { + signal, + }); + return readJsonOrThrow(response, 'Mapillary graph'); + } + + return { + get token() { + return token; + }, + hasToken: () => Boolean(token), + + async getStatus({ signal } = {}) { + const response = await fetchImpl(urls.status, { + signal, + cache: 'no-store', + }); + return readJsonOrThrow(response, 'Mapillary status'); + }, + + /** Raw protobuf bytes for one tile; an empty array means no data. */ + async getTile(layer, z, x, y, { signal } = {}) { + const response = await fetchImpl( + `${urls.tiles}/${layer}/${z}/${x}/${y}`, + { + signal, + }, + ); + if (response.status === 204) return new Uint8Array(0); + if (!response.ok) { + let payload = null; + try { + payload = await response.json(); + } catch { + /* ignore */ + } + throw new MapillarySourceError( + payload?.error || `Tile HTTP ${response.status}`, + { + status: response.status, + payload, + }, + ); + } + return new Uint8Array(await response.arrayBuffer()); + }, + + /** + * Stream a feature query. `onEvent` receives each NDJSON record + * ({type:'start'|'tile'|'done'}); resolves with the done record. + */ + async queryFeatures(body, onEvent, { signal } = {}) { + const response = await fetchImpl(urls.features, { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) await readJsonOrThrow(response, 'Feature query'); + let done = null; + await readNdjsonResponse( + response, + (record) => { + if (record?.type === 'done') done = record; + onEvent?.(record); + }, + { signal }, + ); + return done; + }, + + /** Natural language → plan, via the server-side Claude planner. */ + async plan(body, { signal } = {}) { + const response = await fetchImpl(urls.plan, { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return readJsonOrThrow(response, 'Planner'); + }, + + /** Place name → geocode results through the app's existing Nominatim route. */ + async geocode(query, { signal } = {}) { + const response = await fetchImpl( + `${urls.geocode}?${new URLSearchParams({ q: query })}`, + { signal }, + ); + const payload = await readJsonOrThrow(response, 'Geocode'); + return Array.isArray(payload?.results) ? payload.results : []; + }, + + getImage(id, { signal, fields = IMAGE_FIELDS } = {}) { + return graph(String(id), { fields }, { signal }); + }, + + async getSequenceImages( + sequenceId, + { signal, limit = SEQUENCE_IMAGES_LIMIT } = {}, + ) { + const payload = await graph( + 'images', + { + sequence_ids: String(sequenceId), + fields: SEQUENCE_IMAGE_FIELDS, + limit: String(limit), + }, + { signal }, + ); + return Array.isArray(payload?.data) ? payload.data : []; + }, + + async nearestImages( + { lat, lon, radius = NEAREST_RADIUS_M, limit = NEAREST_LIMIT }, + { signal } = {}, + ) { + const payload = await graph( + 'images', + { + lat: String(lat), + lng: String(lon), + radius: String(Math.min(50, Math.max(1, radius))), + limit: String(Math.min(100, Math.max(1, limit))), + fields: IMAGE_FIELDS, + }, + { signal }, + ); + return Array.isArray(payload?.data) ? payload.data : []; + }, + + getMapFeature(id, { signal } = {}) { + return graph(String(id), { fields: FEATURE_FIELDS }, { signal }); + }, + + /** Batched entity lookup (`/?ids=`), up to 50 ids per call. */ + async getMapFeaturesBatch( + ids, + { signal, fields = 'id,object_value,aligned_direction' } = {}, + ) { + const list = [...new Set((ids || []).map(String))].slice(0, 50); + if (!list.length) return {}; + const payload = await graph( + '', + { ids: list.join(','), fields }, + { signal }, + ); + return payload && typeof payload === 'object' ? payload : {}; + }, + + /** Detections (segmentation polygons + values) inside one image. */ + async getImageDetections(imageId, { signal } = {}) { + const payload = await graph( + `${String(imageId)}/detections`, + { fields: 'id,value,geometry,created_at' }, + { signal }, + ); + return Array.isArray(payload?.data) ? payload.data : []; + }, + + spriteUrl(value) { + return `${urls.sprite}/${encodeURIComponent(value)}.svg`; + }, + }; +} diff --git a/src/layers/mapillary/state.js b/src/layers/mapillary/state.js new file mode 100644 index 000000000..f0115d217 --- /dev/null +++ b/src/layers/mapillary/state.js @@ -0,0 +1,107 @@ +/** Mutable per-layer state, created once per layer instance. */ +export function createState({ services }) { + return { + services, + viewer: null, + enabled: false, + initialized: false, + destroyed: false, + status: null, + statusError: null, + keyRequired: false, + /** Cesium.Credit shown while enabled (CC BY-SA attribution). */ + credit: null, + listeners: new Set(), + notifyScheduled: false, + + coverage: { + zoom: null, + /** @type {Map, count: number}>} */ + tiles: new Map(), + /** Tiles from the previous zoom, kept on screen until replacements land. */ + stale: new Map(), + staleTimer: null, + pending: new Map(), + generation: 0, + loading: 0, + lastError: null, + debounceTimer: null, + removeCameraListener: null, + terrainReady: null, + hint: '', + resting: false, + kind: null, + filter: { pano: 'all', sinceMs: null }, + }, + + sequence: { + selectedId: null, + images: [], + /** @type {Map>} recent sequences' thinned images */ + cache: new Map(), + collection: null, + loading: false, + abort: null, + }, + + features: { + collection: null, + points: null, + rows: [], + byId: new Map(), + counts: new Map(), + earliest: null, + latest: null, + bbox: null, + title: '', + layer: null, + values: [], + total: 0, + truncated: false, + failed: 0, + loading: false, + progress: { done: 0, tiles: 0 }, + abort: null, + iconMode: false, + iconsHidden: false, + selectedId: null, + billboardById: new Map(), + highlight: null, + }, + + street: { + bridge: null, + container: null, + open: false, + follow: false, + imageId: null, + position: null, + bearing: null, + tilt: null, + isPano: false, + capturedAt: null, + sequenceId: null, + loading: false, + error: null, + marker: null, + markerCollection: null, + /** 'letterbox' shows the whole image; 'fill' crops it to the frame. */ + renderMode: 'letterbox', + }, + + query: { + busy: false, + stage: 'idle', + prompt: '', + place: null, + answer: '', + error: null, + lastPlan: null, + history: [], + abort: null, + usage: null, + }, + + clickHandler: null, + }; +} diff --git a/src/layers/mapillary/taxonomy.js b/src/layers/mapillary/taxonomy.js new file mode 100644 index 000000000..e5fcbd9a9 --- /dev/null +++ b/src/layers/mapillary/taxonomy.js @@ -0,0 +1,1605 @@ +/** + * Mapillary map-feature taxonomy, derived from the class names in + * mapillary/mapillary_sprite_source (MIT). OBJECT_VALUES is the point-object + * layer (`mly_map_feature_point`, property `value`); SIGN_VALUES is the + * traffic-sign layer (`mly_map_feature_traffic_sign`). Sign values end in a + * regional group suffix (`--g1`, `--g3`); a wildcard such as + * `regulatory--stop--*` matches every regional variant of one sign. + * Regenerate from the sprite repository rather than editing by hand. + */ +export const OBJECT_VALUES = Object.freeze([ + 'construction--barrier--temporary', + 'construction--flat--crosswalk-plain', + 'construction--flat--driveway', + 'construction--flat--flat-driveway', + 'marking--discrete--arrow--left', + 'marking--discrete--arrow--right', + 'marking--discrete--arrow--split-left-or-straight', + 'marking--discrete--arrow--split-right-or-straight', + 'marking--discrete--arrow--straight', + 'marking--discrete--crosswalk-zebra', + 'marking--discrete--give-way-row', + 'marking--discrete--give-way-single', + 'marking--discrete--other-marking', + 'marking--discrete--stop-line', + 'marking--discrete--symbol--bicycle', + 'marking--discrete--text', + 'object--banner', + 'object--bench', + 'object--bike-rack', + 'object--catch-basin', + 'object--cctv-camera', + 'object--fire-hydrant', + 'object--junction-box', + 'object--mailbox', + 'object--manhole', + 'object--parking-meter', + 'object--phone-booth', + 'object--sign--advertisement', + 'object--sign--information', + 'object--sign--store', + 'object--street-light', + 'object--support--pole', + 'object--support--traffic-sign-frame', + 'object--support--utility-pole', + 'object--traffic-cone', + 'object--traffic-light--cyclists', + 'object--traffic-light--general-horizontal', + 'object--traffic-light--general-single', + 'object--traffic-light--general-upright', + 'object--traffic-light--other', + 'object--traffic-light--pedestrians', + 'object--trash-can', + 'object--water-valve', +]); + +export const SIGN_VALUES = Object.freeze([ + 'complementary--accident-area--g1', + 'complementary--accident-area--g2', + 'complementary--accident-area--g3', + 'complementary--accident-area--g4', + 'complementary--advisory-exit-or-ramp-speed--g1', + 'complementary--bicycles--g1', + 'complementary--bicycles-and-pedestrians-detour--g1', + 'complementary--bicycles-or-pedestrians-detour--g1', + 'complementary--bicycles-turn-right--g1', + 'complementary--bike-route--g1', + 'complementary--bike-route--g3', + 'complementary--both-directions--g1', + 'complementary--both-directions--g2', + 'complementary--buses--g1', + 'complementary--buses-and-trucks--g1', + 'complementary--camera--g1', + 'complementary--camera--g2', + 'complementary--camera--g3', + 'complementary--camera--g4', + 'complementary--camera--g5', + 'complementary--caravan-trailers--g2', + 'complementary--caravan-trailers--g3', + 'complementary--caravans--g2', + 'complementary--carts--g1', + 'complementary--chevron-left--g1', + 'complementary--chevron-left--g2', + 'complementary--chevron-left--g3', + 'complementary--chevron-left--g4', + 'complementary--chevron-left--g5', + 'complementary--chevron-right--g1', + 'complementary--chevron-right--g2', + 'complementary--chevron-right--g3', + 'complementary--chevron-right--g4', + 'complementary--chevron-right--g5', + 'complementary--dangerous-or-pollutant-good--g1', + 'complementary--dead-end--g1', + 'complementary--detour--g1', + 'complementary--disabled-persons--g1', + 'complementary--distance--g1', + 'complementary--distance--g2', + 'complementary--distance--g3', + 'complementary--end-of-road-works--g1', + 'complementary--except-bicycles--g1', + 'complementary--except-bicycles--g2', + 'complementary--except-buses--g1', + 'complementary--except-carts--g1', + 'complementary--except-motorcycles--g1', + 'complementary--except-motorcycles--g2', + 'complementary--except-polluting-level-green--g1', + 'complementary--except-polluting-level-green-yellow--g1', + 'complementary--except-polluting-level-green-yellow-red--g1', + 'complementary--except-tractors--g1', + 'complementary--except-tractors--g2', + 'complementary--except-trailers--g1', + 'complementary--except-trailers--g2', + 'complementary--except-trains--g1', + 'complementary--except-trams--g1', + 'complementary--except-trucks--g1', + 'complementary--except-vehicles--g1', + 'complementary--except-vehicles--g2', + 'complementary--go-left--g1', + 'complementary--go-right--g1', + 'complementary--go-straight-or-turn-left--g1', + 'complementary--go-straight-or-turn-right--g1', + 'complementary--height-limit--g1', + 'complementary--height-limit--g2', + 'complementary--including-bicycles-and-motorcycles--g1', + 'complementary--including-buses-vehicles--g1', + 'complementary--keep-left--g1', + 'complementary--keep-right--g1', + 'complementary--lane-control--g1', + 'complementary--lane-control--g2', + 'complementary--lane-control--g3', + 'complementary--maximum-speed-limit-10--g1', + 'complementary--maximum-speed-limit-15--g1', + 'complementary--maximum-speed-limit-20--g1', + 'complementary--maximum-speed-limit-25--g1', + 'complementary--maximum-speed-limit-30--g1', + 'complementary--maximum-speed-limit-35--g1', + 'complementary--maximum-speed-limit-40--g1', + 'complementary--maximum-speed-limit-45--g1', + 'complementary--maximum-speed-limit-50--g1', + 'complementary--maximum-speed-limit-55--g1', + 'complementary--maximum-speed-limit-60--g1', + 'complementary--maximum-speed-limit-65--g1', + 'complementary--maximum-speed-limit-70--g1', + 'complementary--maximum-speed-limit-75--g1', + 'complementary--maximum-speed-limit-80--g1', + 'complementary--maximum-speed-limit-85--g1', + 'complementary--maximum-speed-limit-90--g1', + 'complementary--maximum-speed-limit-95--g1', + 'complementary--motorcycles--g1', + 'complementary--motorcycles--g2', + 'complementary--motorcycles--g3', + 'complementary--motorcycles--g4', + 'complementary--obstacle-delineator--g1', + 'complementary--obstacle-delineator--g2', + 'complementary--obstacle-delineator--g3', + 'complementary--one-direction-left--g1', + 'complementary--one-direction-right--g1', + 'complementary--pass-left--g1', + 'complementary--pass-right--g1', + 'complementary--pedestrians-and-bicycles--g1', + 'complementary--pedestrians-left--g1', + 'complementary--pedestrians-right--g1', + 'complementary--photo-enforced--g1', + 'complementary--playground--g1', + 'complementary--priority-route-at-intersection--g1', + 'complementary--priority-route-at-intersection--g2', + 'complementary--priority-route-at-intersection--g3', + 'complementary--priority-route-at-intersection--g4', + 'complementary--priority-route-at-intersection--g5', + 'complementary--priority-route-at-intersection--g6', + 'complementary--railroad--g1', + 'complementary--railroad--g2', + 'complementary--railroad--g3', + 'complementary--restriction-in-both-directions--g1', + 'complementary--roundabout-go-left--g1', + 'complementary--roundabout-go-right--g1', + 'complementary--roundabout-go-straight--g1', + 'complementary--slippery-for-caravan-trailers--g1', + 'complementary--snow--g2', + 'complementary--snow--g4', + 'complementary--snow--g5', + 'complementary--snowmobiles--g1', + 'complementary--soft-shoulder--g1', + 'complementary--soft-shoulder--g2', + 'complementary--steep-ascent--g1', + 'complementary--steep-descent--g1', + 'complementary--time-restrictions--g1', + 'complementary--time-restrictions--g3', + 'complementary--tow-away-zone--g1', + 'complementary--tow-away-zone--g3', + 'complementary--tractors--g1', + 'complementary--traffic-queues--g1', + 'complementary--trailers--g1', + 'complementary--trailers--g2', + 'complementary--trailers--g3', + 'complementary--trailers--g4', + 'complementary--trains--g1', + 'complementary--trams--g1', + 'complementary--trees--g1', + 'complementary--trucks--g1', + 'complementary--trucks--g2', + 'complementary--trucks--g3', + 'complementary--trucks-and-trailers--g1', + 'complementary--trucks-buses-trailers--g1', + 'complementary--trucks-go-left--g1', + 'complementary--trucks-go-left-ahead--g1', + 'complementary--trucks-go-right--g1', + 'complementary--trucks-go-right-ahead--g1', + 'complementary--trucks-go-straight--g1', + 'complementary--trucks-turn-left--g1', + 'complementary--trucks-turn-right--g1', + 'complementary--turn-left--g1', + 'complementary--turn-left--g2', + 'complementary--turn-right--g1', + 'complementary--turn-right--g2', + 'complementary--two-way-traffic--g1', + 'complementary--two-way-traffic--g2', + 'complementary--two-way-traffic--g3', + 'complementary--two-way-traffic--g4', + 'complementary--two-way-traffic--g5', + 'complementary--vehicles--g1', + 'complementary--vehicles--g2', + 'complementary--vehicles-or-buses--g1', + 'complementary--weekends-or-holidays--g1', + 'complementary--weight-limit--g1', + 'complementary--when-foggy--g1', + 'complementary--when-rainy--g1', + 'complementary--when-rainy--g2', + 'complementary--when-rainy--g3', + 'complementary--when-snowy--g1', + 'complementary--when-snowy--g2', + 'complementary--when-snowy-or-rainy--g1', + 'complementary--when-snowy-or-rainy--g2', + 'complementary--when-wet--g1', + 'complementary--width-limit--g1', + 'complementary--working-days--g1', + 'information--airport--g1', + 'information--airport--g2', + 'information--bicycle-lane--g1', + 'information--bicycles-both-ways--g1', + 'information--bicycles-crossing--g1', + 'information--bicycles-crossing--g2', + 'information--bicycles-crossing--g3', + 'information--bike-route--g1', + 'information--bike-route--g2', + 'information--built-up-area--g1', + 'information--built-up-area--g2', + 'information--bus-lane-straight--g1', + 'information--bus-stop--g1', + 'information--bus-stop--g2', + 'information--camera--g1', + 'information--camera--g2', + 'information--camera--g3', + 'information--camp--g1', + 'information--camp--g2', + 'information--car-pool-lane--g1', + 'information--caravan-parking--g1', + 'information--caravan-trailer-parking--g1', + 'information--cargo-loading-zone--g1', + 'information--central-lane--g1', + 'information--charging-station--g1', + 'information--children--g1', + 'information--children--g2', + 'information--children-crossing--g5', + 'information--cycling-two-abreast-permitted--g1', + 'information--dead-end--g1', + 'information--dead-end--g2', + 'information--dead-end--g3', + 'information--dead-end--g4', + 'information--dead-end-except-bicycles--g1', + 'information--dead-end-except-bicycles-and-pedestrians--g1', + 'information--dead-end-except-bicycles-and-pedestrians--g2', + 'information--dead-end-left--g1', + 'information--dead-end-left--g2', + 'information--dead-end-right--g1', + 'information--dead-end-right--g2', + 'information--dead-end-right--g3', + 'information--directions--g1', + 'information--disabled-persons--g1', + 'information--disabled-persons--g2', + 'information--disabled-persons--g3', + 'information--emergency-facility--g1', + 'information--end-of-advisory-maximum-speed-limit-20--g1', + 'information--end-of-advisory-maximum-speed-limit-40--g1', + 'information--end-of-advisory-maximum-speed-limit-60--g1', + 'information--end-of-advisory-maximum-speed-limit-70--g1', + 'information--end-of-advisory-maximum-speed-limit-80--g1', + 'information--end-of-advisory-maximum-speed-limit-90--g1', + 'information--end-of-bicycle-lane--g1', + 'information--end-of-built-up-area--g1', + 'information--end-of-built-up-area--g2', + 'information--end-of-built-up-area--g3', + 'information--end-of-built-up-area--g4', + 'information--end-of-car-pool-lane--g1', + 'information--end-of-limited-access-road--g1', + 'information--end-of-living-street--g1', + 'information--end-of-living-street--g2', + 'information--end-of-minimum-speed-10--g1', + 'information--end-of-minimum-speed-100--g1', + 'information--end-of-minimum-speed-110--g1', + 'information--end-of-minimum-speed-120--g1', + 'information--end-of-minimum-speed-130--g1', + 'information--end-of-minimum-speed-20--g1', + 'information--end-of-minimum-speed-25--g1', + 'information--end-of-minimum-speed-30--g1', + 'information--end-of-minimum-speed-35--g1', + 'information--end-of-minimum-speed-40--g1', + 'information--end-of-minimum-speed-50--g1', + 'information--end-of-minimum-speed-60--g1', + 'information--end-of-minimum-speed-70--g1', + 'information--end-of-minimum-speed-75--g1', + 'information--end-of-minimum-speed-80--g1', + 'information--end-of-minimum-speed-90--g1', + 'information--end-of-motorway--g1', + 'information--end-of-overtaking-permitted-heavy-good-vehicles--g1', + 'information--end-of-road-works--g1', + 'information--end-of-tunnel--g1', + 'information--end-of-tunnel--g2', + 'information--end-of-two-way-traffic--g1', + 'information--equestrians-permitted--g1', + 'information--exit-ahead--g1', + 'information--exit-ahead--g2', + 'information--exit-ahead--g3', + 'information--flight-port--g1', + 'information--food--g1', + 'information--food--g2', + 'information--gas-station--g1', + 'information--gas-station--g2', + 'information--gas-station--g3', + 'information--general-speed-limit-at-city-border--g1', + 'information--go-left--g1', + 'information--go-right--g1', + 'information--go-straight--g1', + 'information--go-straight-or-left--g1', + 'information--go-straight-or-right--g1', + 'information--go-straight-or-turn-left--g1', + 'information--go-straight-or-turn-right--g1', + 'information--hazardous-goods-vehicles-lane--g1', + 'information--height-limit--g1', + 'information--height-limit--g2', + 'information--highway-directions--g1', + 'information--highway-exit--g1', + 'information--highway-interchange--g1', + 'information--highway-interstate-route--g1', + 'information--highway-interstate-route--g2', + 'information--highway-preferential-lane--g1', + 'information--highway-preferential-lane--g2', + 'information--highway-reference-location--g1', + 'information--highway-reference-location--g2', + 'information--hiking--g1', + 'information--hospital--g1', + 'information--hurricane-evacuation-route--g1', + 'information--interstate-route--g1', + 'information--lane-control-intersections--g1', + 'information--lane-control-left-turn--g1', + 'information--lane-control-multiple-lanes--g1', + 'information--lane-control-multiple-lanes--g2', + 'information--lane-control-right-turn--g1', + 'information--limited-access-road--g1', + 'information--litter-container--g1', + 'information--living-street--g1', + 'information--living-street--g2', + 'information--living-street--g3', + 'information--lodging--g1', + 'information--lodging--g2', + 'information--minimum-speed-10--g1', + 'information--minimum-speed-100--g1', + 'information--minimum-speed-110--g1', + 'information--minimum-speed-120--g1', + 'information--minimum-speed-130--g1', + 'information--minimum-speed-20--g1', + 'information--minimum-speed-25--g1', + 'information--minimum-speed-30--g1', + 'information--minimum-speed-35--g1', + 'information--minimum-speed-40--g1', + 'information--minimum-speed-50--g1', + 'information--minimum-speed-60--g1', + 'information--minimum-speed-70--g1', + 'information--minimum-speed-75--g1', + 'information--minimum-speed-80--g1', + 'information--minimum-speed-90--g1', + 'information--motorway--g1', + 'information--motorway-exit-ahead--g1', + 'information--motorway-exit-ahead--g2', + 'information--motorway-exit-ahead--g3', + 'information--overtaking-allowed-heavy-good-vehicles--g1', + 'information--parallel-parking--g1', + 'information--park-and-ride--g1', + 'information--park-and-ride--g2', + 'information--parking--g1', + 'information--parking--g2', + 'information--parking--g3', + 'information--parking--g4', + 'information--parking--g5', + 'information--parking--g6', + 'information--parking-area--g1', + 'information--parking-with-restrictions--g1', + 'information--pass-on-either-side--g1', + 'information--passenger-loading-zone--g1', + 'information--pedestrians-crossing--g1', + 'information--pedestrians-crossing--g2', + 'information--pedestrians-crossing--g3', + 'information--pedestrians-only--g4', + 'information--pedestrians-permitted--g1', + 'information--perpendicular-parking--g1', + 'information--picnic-site--g1', + 'information--playground--g1', + 'information--recreational-vehicle-sanitary-station--g1', + 'information--recycle-collection-center--g1', + 'information--rest-area--g1', + 'information--road-bump--g1', + 'information--road-skating--g1', + 'information--safety-zone--g1', + 'information--safety-zone--g2', + 'information--safety-zone--g3', + 'information--shared-path-vehicles-and-motorcycles--g1', + 'information--stairs--g1', + 'information--stairs--g2', + 'information--stairs--g3', + 'information--stairs--g4', + 'information--stop-line--g1', + 'information--stop-permitted--g1', + 'information--street-name-one-line--g1', + 'information--street-name-three-lines--g1', + 'information--street-name-two-lines--g1', + 'information--subway--g1', + 'information--telephone--g1', + 'information--telephone--g2', + 'information--telephone-device-for-the-deaf--g1', + 'information--toll-station--g1', + 'information--tourism-information--g1', + 'information--tourist-attraction--g1', + 'information--traffic-merges-left--g1', + 'information--traffic-merges-right--g1', + 'information--trail-crossing--g1', + 'information--trail-crossing--g2', + 'information--trail-crossing--g3', + 'information--trailer-camping--g1', + 'information--train-or-light-rail-station--g1', + 'information--tram-bus-stop--g1', + 'information--tram-bus-stop--g2', + 'information--trams-crossing--g1', + 'information--truck-lane-left--g1', + 'information--truck-parking--g1', + 'information--truck-trailer-lane-right--g1', + 'information--truck-trailer-lane-straight--g1', + 'information--trucks-both-ways--g1', + 'information--trucks-only--g1', + 'information--tsunami-evacuation_route--g1', + 'information--tunnel--g1', + 'information--tunnel--g2', + 'information--tunnel--g3', + 'information--tunnel-ahead--g1', + 'information--turn-left--g1', + 'information--turn-left-ahead--g1', + 'information--turn-right--g1', + 'information--turn-right-ahead--g1', + 'information--urban-area--g1', + 'information--vehicles-on-rails--g1', + 'information--water-protection-zone--g1', + 'information--weight-and-height-limit--g1', + 'information--weight-limit--g1', + 'information--wireless-internet--g1', + 'regulatory--advisory-maximum-speed-limit--g1', + 'regulatory--all-directions-permitted--g1', + 'regulatory--all-way--g1', + 'regulatory--atvs-permitted--g1', + 'regulatory--axle-limit--g1', + 'regulatory--axle-limit--g2', + 'regulatory--bicycle-lane-left--g1', + 'regulatory--bicycle-parking--g1', + 'regulatory--bicycles-and-buses-only--g1', + 'regulatory--bicycles-only--g1', + 'regulatory--bicycles-only--g2', + 'regulatory--bicycles-only--g3', + 'regulatory--bicycles-only--g4', + 'regulatory--bicycles-push-button--g1', + 'regulatory--bicycles-push-button--g2', + 'regulatory--bicycles-stop-on-red--g1', + 'regulatory--bicycles-wrong-way--g1', + 'regulatory--bicycles-yield-or-use-signal--g1', + 'regulatory--bike-route--g1', + 'regulatory--building-direction--g1', + 'regulatory--bus-priority-lane--g1', + 'regulatory--buses-and-taxi-only--g1', + 'regulatory--buses-only--g1', + 'regulatory--buses-only--g2', + 'regulatory--circular-intersection--g1', + 'regulatory--circular-intersection--g2', + 'regulatory--circular-intersection--g3', + 'regulatory--circular-intersection--g4', + 'regulatory--cross-only-on-green--g1', + 'regulatory--cross-only-on-pedestrian-signal--g1', + 'regulatory--crosswalk-stop-on-red--g1', + 'regulatory--cycling-restriction--g1', + 'regulatory--cyclists-dismount-and-walk--g1', + 'regulatory--detour-left--g1', + 'regulatory--detour-right--g1', + 'regulatory--divided-highway-crossing--g1', + 'regulatory--divided-highway-ends--g1', + 'regulatory--divided-highway-starts--g1', + 'regulatory--do-not-block-intersection--g1', + 'regulatory--do-not-pass--g1', + 'regulatory--do-not-stop-on-tracks--g1', + 'regulatory--dual-lanes-all-directions-on-left--g1', + 'regulatory--dual-lanes-all-directions-on-right--g1', + 'regulatory--dual-lanes-bicyclists-and-pedestrians--g1', + 'regulatory--dual-lanes-go-left-or-right--g1', + 'regulatory--dual-lanes-go-straight-on-left--g1', + 'regulatory--dual-lanes-go-straight-on-right--g1', + 'regulatory--dual-lanes-turn-left--g1', + 'regulatory--dual-lanes-turn-left-no-u-turn--g1', + 'regulatory--dual-lanes-turn-left-or-straight--g1', + 'regulatory--dual-lanes-turn-right-or-straight--g1', + 'regulatory--dual-path-bicycles-and-pedestrians--g1', + 'regulatory--dual-path-bicycles-and-pedestrians--g2', + 'regulatory--dual-path-bicycles-and-pedestrians--g3', + 'regulatory--dual-path-equestrians-and-pedestrians--g1', + 'regulatory--dual-path-equestrians-and-pedestrians-bicycles--g1', + 'regulatory--dual-path-pedestrians-and-bicycles--g1', + 'regulatory--dual-path-pedestrians-and-bicycles--g2', + 'regulatory--dual-path-pedestrians-and-equestrians--g1', + 'regulatory--dual-path-pedestrians-bicycles-and-equestrians--g1', + 'regulatory--dual-speed-limits--g1', + 'regulatory--dual-speed-limits--g2', + 'regulatory--end-of-bicycles-only--g1', + 'regulatory--end-of-bicycles-only--g2', + 'regulatory--end-of-bus-and-taxi-only--g1', + 'regulatory--end-of-buses-only--g1', + 'regulatory--end-of-buses-only--g2', + 'regulatory--end-of-cycling-restriction--g1', + 'regulatory--end-of-dual-path-bicycles-and-pedestrians--g1', + 'regulatory--end-of-dual-path-pedestrians-and-bicycles--g1', + 'regulatory--end-of-equestrians-only--g1', + 'regulatory--end-of-low-beam-headlights--g1', + 'regulatory--end-of-maximum-speed-limit--g1', + 'regulatory--end-of-maximum-speed-limit-10--g1', + 'regulatory--end-of-maximum-speed-limit-10--g2', + 'regulatory--end-of-maximum-speed-limit-100--g1', + 'regulatory--end-of-maximum-speed-limit-100--g2', + 'regulatory--end-of-maximum-speed-limit-110--g1', + 'regulatory--end-of-maximum-speed-limit-110--g2', + 'regulatory--end-of-maximum-speed-limit-120--g1', + 'regulatory--end-of-maximum-speed-limit-120--g2', + 'regulatory--end-of-maximum-speed-limit-130--g1', + 'regulatory--end-of-maximum-speed-limit-130--g2', + 'regulatory--end-of-maximum-speed-limit-20--g1', + 'regulatory--end-of-maximum-speed-limit-20--g2', + 'regulatory--end-of-maximum-speed-limit-25--g1', + 'regulatory--end-of-maximum-speed-limit-25--g2', + 'regulatory--end-of-maximum-speed-limit-30--g1', + 'regulatory--end-of-maximum-speed-limit-30--g2', + 'regulatory--end-of-maximum-speed-limit-35--g1', + 'regulatory--end-of-maximum-speed-limit-35--g2', + 'regulatory--end-of-maximum-speed-limit-40--g1', + 'regulatory--end-of-maximum-speed-limit-40--g2', + 'regulatory--end-of-maximum-speed-limit-50--g1', + 'regulatory--end-of-maximum-speed-limit-50--g2', + 'regulatory--end-of-maximum-speed-limit-60--g1', + 'regulatory--end-of-maximum-speed-limit-60--g2', + 'regulatory--end-of-maximum-speed-limit-65--g1', + 'regulatory--end-of-maximum-speed-limit-65--g2', + 'regulatory--end-of-maximum-speed-limit-70--g1', + 'regulatory--end-of-maximum-speed-limit-70--g2', + 'regulatory--end-of-maximum-speed-limit-75--g1', + 'regulatory--end-of-maximum-speed-limit-75--g2', + 'regulatory--end-of-maximum-speed-limit-80--g1', + 'regulatory--end-of-maximum-speed-limit-80--g2', + 'regulatory--end-of-maximum-speed-limit-90--g1', + 'regulatory--end-of-maximum-speed-limit-90--g2', + 'regulatory--end-of-mopeds-and-bicycles-only--g1', + 'regulatory--end-of-no-heavy-goods-vehicles--g1', + 'regulatory--end-of-no-horn--g1', + 'regulatory--end-of-no-overtaking--g1', + 'regulatory--end-of-no-overtaking--g2', + 'regulatory--end-of-no-overtaking--g3', + 'regulatory--end-of-no-overtaking--g4', + 'regulatory--end-of-no-overtaking--g5', + 'regulatory--end-of-no-overtaking-by-heavy-goods-vehicles--g1', + 'regulatory--end-of-no-overtaking-by-heavy-goods-vehicles--g2', + 'regulatory--end-of-no-overtaking-by-motorcycles--g1', + 'regulatory--end-of-no-parking--g1', + 'regulatory--end-of-no-parking--g2', + 'regulatory--end-of-no-parking-or-stopping--g1', + 'regulatory--end-of-one-way-straight--g1', + 'regulatory--end-of-parking-zone--g1', + 'regulatory--end-of-parking-zone--g2', + 'regulatory--end-of-pedestrians-only--g1', + 'regulatory--end-of-pedestrians-only--g2', + 'regulatory--end-of-pedestrians-only--g3', + 'regulatory--end-of-pedestrians-only--g4', + 'regulatory--end-of-priority-road--g1', + 'regulatory--end-of-prohibition--g1', + 'regulatory--end-of-school-zone--g1', + 'regulatory--end-of-shared-path-bicycles-and-pedestrians--g1', + 'regulatory--end-of-shared-path-pedestrians-and-bicycles--g1', + 'regulatory--end-of-snow-chains--g1', + 'regulatory--end-of-snow-chains--g2', + 'regulatory--end-of-snowmobiles-only--g1', + 'regulatory--end-of-speed-limit-zone--g1', + 'regulatory--end-of-speed-limit-zone--g2', + 'regulatory--end-of-speed-limit-zone--g3', + 'regulatory--end-of-tractors-only--g1', + 'regulatory--end-of-trams-and-buses-only--g1', + 'regulatory--end-of-trams-only--g1', + 'regulatory--end-of-trucks-and-buses-only--g1', + 'regulatory--end-of-trucks-only--g1', + 'regulatory--end-of-trucks-only--g2', + 'regulatory--equestrians-only--g1', + 'regulatory--except-railroad-crossing--g1', + 'regulatory--fine-for-littering--g1', + 'regulatory--give-way-to-bicycles--g1', + 'regulatory--give-way-to-oncoming-traffic--g1', + 'regulatory--give-way-to-oncoming-traffic--g2', + 'regulatory--go-left-bicycles--g1', + 'regulatory--go-right-bicycles--g1', + 'regulatory--go-straight--g1', + 'regulatory--go-straight--g3', + 'regulatory--go-straight-bicycles--g1', + 'regulatory--go-straight-or-turn-left--g1', + 'regulatory--go-straight-or-turn-left--g2', + 'regulatory--go-straight-or-turn-left--g3', + 'regulatory--go-straight-or-turn-right--g1', + 'regulatory--go-straight-or-turn-right--g2', + 'regulatory--go-straight-or-turn-right--g3', + 'regulatory--heavy-goods-vehicles-permitted--g1', + 'regulatory--height-limit--g1', + 'regulatory--high-beam-headlights--g1', + 'regulatory--horn--g1', + 'regulatory--in-street-pedestrian-crossing--g1', + 'regulatory--keep-left--g1', + 'regulatory--keep-left--g2', + 'regulatory--keep-left--g3', + 'regulatory--keep-left--g4', + 'regulatory--keep-left--g5', + 'regulatory--keep-left--g6', + 'regulatory--keep-left--g7', + 'regulatory--keep-right--g1', + 'regulatory--keep-right--g2', + 'regulatory--keep-right--g3', + 'regulatory--keep-right--g4', + 'regulatory--keep-right--g5', + 'regulatory--keep-right--g6', + 'regulatory--keep-right--g7', + 'regulatory--keep-right--g8', + 'regulatory--keep-right--g9', + 'regulatory--lane-control--g1', + 'regulatory--left-turn-yield-on-green--g1', + 'regulatory--length-limit--g1', + 'regulatory--length-limit--g2', + 'regulatory--light-rail-divided-highway--g1', + 'regulatory--light-rail-do-not-pass--g1', + 'regulatory--light-rail-only--g1', + 'regulatory--look--g1', + 'regulatory--low-beam-headlights--g1', + 'regulatory--low-beam-headlights--g2', + 'regulatory--low-beam-headlights--g3', + 'regulatory--low-speed-vehicle-permitted--g1', + 'regulatory--maximum-speed-limit-10--g1', + 'regulatory--maximum-speed-limit-10--g3', + 'regulatory--maximum-speed-limit-100--g1', + 'regulatory--maximum-speed-limit-100--g3', + 'regulatory--maximum-speed-limit-110--g1', + 'regulatory--maximum-speed-limit-110--g3', + 'regulatory--maximum-speed-limit-120--g1', + 'regulatory--maximum-speed-limit-120--g3', + 'regulatory--maximum-speed-limit-130--g1', + 'regulatory--maximum-speed-limit-130--g3', + 'regulatory--maximum-speed-limit-15--g1', + 'regulatory--maximum-speed-limit-15--g3', + 'regulatory--maximum-speed-limit-20--g1', + 'regulatory--maximum-speed-limit-20--g3', + 'regulatory--maximum-speed-limit-25--g1', + 'regulatory--maximum-speed-limit-25--g2', + 'regulatory--maximum-speed-limit-30--g1', + 'regulatory--maximum-speed-limit-30--g3', + 'regulatory--maximum-speed-limit-35--g1', + 'regulatory--maximum-speed-limit-35--g2', + 'regulatory--maximum-speed-limit-40--g1', + 'regulatory--maximum-speed-limit-40--g3', + 'regulatory--maximum-speed-limit-45--g1', + 'regulatory--maximum-speed-limit-45--g3', + 'regulatory--maximum-speed-limit-5--g1', + 'regulatory--maximum-speed-limit-5--g3', + 'regulatory--maximum-speed-limit-50--g1', + 'regulatory--maximum-speed-limit-50--g3', + 'regulatory--maximum-speed-limit-55--g2', + 'regulatory--maximum-speed-limit-60--g1', + 'regulatory--maximum-speed-limit-60--g3', + 'regulatory--maximum-speed-limit-65--g1', + 'regulatory--maximum-speed-limit-65--g2', + 'regulatory--maximum-speed-limit-70--g1', + 'regulatory--maximum-speed-limit-70--g3', + 'regulatory--maximum-speed-limit-75--g2', + 'regulatory--maximum-speed-limit-80--g1', + 'regulatory--maximum-speed-limit-80--g3', + 'regulatory--maximum-speed-limit-85--g2', + 'regulatory--maximum-speed-limit-90--g1', + 'regulatory--maximum-speed-limit-90--g3', + 'regulatory--maximum-speed-limit-led-10--g1', + 'regulatory--maximum-speed-limit-led-10--g2', + 'regulatory--maximum-speed-limit-led-10--g3', + 'regulatory--maximum-speed-limit-led-100--g1', + 'regulatory--maximum-speed-limit-led-110--g1', + 'regulatory--maximum-speed-limit-led-120--g1', + 'regulatory--maximum-speed-limit-led-130--g1', + 'regulatory--maximum-speed-limit-led-15--g2', + 'regulatory--maximum-speed-limit-led-15--g3', + 'regulatory--maximum-speed-limit-led-20--g1', + 'regulatory--maximum-speed-limit-led-20--g2', + 'regulatory--maximum-speed-limit-led-20--g3', + 'regulatory--maximum-speed-limit-led-25--g1', + 'regulatory--maximum-speed-limit-led-25--g2', + 'regulatory--maximum-speed-limit-led-25--g3', + 'regulatory--maximum-speed-limit-led-30--g1', + 'regulatory--maximum-speed-limit-led-30--g2', + 'regulatory--maximum-speed-limit-led-30--g3', + 'regulatory--maximum-speed-limit-led-35--g1', + 'regulatory--maximum-speed-limit-led-35--g2', + 'regulatory--maximum-speed-limit-led-35--g3', + 'regulatory--maximum-speed-limit-led-40--g1', + 'regulatory--maximum-speed-limit-led-40--g2', + 'regulatory--maximum-speed-limit-led-40--g3', + 'regulatory--maximum-speed-limit-led-45--g2', + 'regulatory--maximum-speed-limit-led-45--g3', + 'regulatory--maximum-speed-limit-led-5--g2', + 'regulatory--maximum-speed-limit-led-5--g3', + 'regulatory--maximum-speed-limit-led-50--g1', + 'regulatory--maximum-speed-limit-led-50--g2', + 'regulatory--maximum-speed-limit-led-50--g3', + 'regulatory--maximum-speed-limit-led-55--g2', + 'regulatory--maximum-speed-limit-led-55--g3', + 'regulatory--maximum-speed-limit-led-60--g1', + 'regulatory--maximum-speed-limit-led-60--g2', + 'regulatory--maximum-speed-limit-led-60--g3', + 'regulatory--maximum-speed-limit-led-65--g2', + 'regulatory--maximum-speed-limit-led-65--g3', + 'regulatory--maximum-speed-limit-led-70--g1', + 'regulatory--maximum-speed-limit-led-70--g2', + 'regulatory--maximum-speed-limit-led-70--g3', + 'regulatory--maximum-speed-limit-led-75--g1', + 'regulatory--maximum-speed-limit-led-75--g2', + 'regulatory--maximum-speed-limit-led-75--g3', + 'regulatory--maximum-speed-limit-led-80--g1', + 'regulatory--maximum-speed-limit-led-80--g2', + 'regulatory--maximum-speed-limit-led-80--g3', + 'regulatory--maximum-speed-limit-led-85--g2', + 'regulatory--maximum-speed-limit-led-85--g3', + 'regulatory--maximum-speed-limit-led-90--g1', + 'regulatory--minimum-safe-distance--g1', + 'regulatory--minimum-safe-distance--g2', + 'regulatory--mopeds-and-bicycles-only--g1', + 'regulatory--motorcycles-and-bicycles-only--g1', + 'regulatory--motorcycles-only--g1', + 'regulatory--motorcycles-only--g2', + 'regulatory--night-speed-limit-10--g1', + 'regulatory--night-speed-limit-15--g1', + 'regulatory--night-speed-limit-20--g1', + 'regulatory--night-speed-limit-25--g1', + 'regulatory--night-speed-limit-30--g1', + 'regulatory--night-speed-limit-35--g1', + 'regulatory--night-speed-limit-40--g1', + 'regulatory--night-speed-limit-45--g1', + 'regulatory--night-speed-limit-5--g1', + 'regulatory--night-speed-limit-50--g1', + 'regulatory--night-speed-limit-55--g1', + 'regulatory--night-speed-limit-60--g1', + 'regulatory--night-speed-limit-65--g1', + 'regulatory--night-speed-limit-70--g1', + 'regulatory--night-speed-limit-75--g1', + 'regulatory--night-speed-limit-80--g1', + 'regulatory--night-speed-limit-85--g1', + 'regulatory--no-abnormal-vehicles--g1', + 'regulatory--no-atvs--g1', + 'regulatory--no-bicycles--g1', + 'regulatory--no-bicycles--g2', + 'regulatory--no-bicycles--g3', + 'regulatory--no-bicycles-carts-or-hand-carts--g1', + 'regulatory--no-bicycles-mopeds-or-motorcycles--g1', + 'regulatory--no-bicycles-mopeds-or-motorcycles--g2', + 'regulatory--no-bicycles-or-hand-carts--g1', + 'regulatory--no-bicycles-or-motorcycles--g1', + 'regulatory--no-bicycles-tractors-or-carts--g1', + 'regulatory--no-buses--g1', + 'regulatory--no-buses--g2', + 'regulatory--no-buses--g3', + 'regulatory--no-caravan-trailers--g1', + 'regulatory--no-caravans--g1', + 'regulatory--no-caravans-or-caravan-trailers--g1', + 'regulatory--no-cargo-loading--g1', + 'regulatory--no-carts--g1', + 'regulatory--no-carts--g2', + 'regulatory--no-carts--g3', + 'regulatory--no-carts-or-tractors--g1', + 'regulatory--no-construction-vehicles--g1', + 'regulatory--no-entry--g1', + 'regulatory--no-equestrians--g1', + 'regulatory--no-go-straight-or-turn-left--g1', + 'regulatory--no-go-straight-or-turn-right--g1', + 'regulatory--no-good-trailers--g2', + 'regulatory--no-goods-vehicle-trailers--g1', + 'regulatory--no-hand-carts--g1', + 'regulatory--no-hand-carts--g2', + 'regulatory--no-hand-carts-or-bicycles--g1', + 'regulatory--no-hawkers--g1', + 'regulatory--no-heavy-goods-vehicles--g1', + 'regulatory--no-heavy-goods-vehicles--g2', + 'regulatory--no-heavy-goods-vehicles--g3', + 'regulatory--no-heavy-goods-vehicles--g4', + 'regulatory--no-heavy-goods-vehicles--g5', + 'regulatory--no-heavy-goods-vehicles-motorcycles-or-bicycles--g1', + 'regulatory--no-heavy-goods-vehicles-motorcycles-or-bicycles--g2', + 'regulatory--no-heavy-goods-vehicles-or-buses--g1', + 'regulatory--no-heavy-goods-vehicles-or-tractors--g1', + 'regulatory--no-heavy-goods-vehicles-or-trailers--g1', + 'regulatory--no-horizontal-turn--g1', + 'regulatory--no-horn--g1', + 'regulatory--no-horn--g2', + 'regulatory--no-lane-change-to-left--g1', + 'regulatory--no-lane-change-to-right--g1', + 'regulatory--no-learner-drivers--g1', + 'regulatory--no-left-or-u-turn--g1', + 'regulatory--no-left-turn--g1', + 'regulatory--no-left-turn--g2', + 'regulatory--no-left-turn--g3', + 'regulatory--no-left-turn--g4', + 'regulatory--no-left-turn--g5', + 'regulatory--no-low-speed-vehicles--g1', + 'regulatory--no-mopeds-or-bicycles--g1', + 'regulatory--no-motor-vehicle-trailers--g1', + 'regulatory--no-motor-vehicles--g1', + 'regulatory--no-motor-vehicles--g3', + 'regulatory--no-motor-vehicles--g4', + 'regulatory--no-motor-vehicles--g5', + 'regulatory--no-motor-vehicles--g6', + 'regulatory--no-motor-vehicles--g7', + 'regulatory--no-motor-vehicles-except-motorcycles--g1', + 'regulatory--no-motor-vehicles-except-motorcycles--g2', + 'regulatory--no-motor-vehicles-except-motorcycles--g3', + 'regulatory--no-motor-vehicles-or-bicycles--g1', + 'regulatory--no-motor-vehicles-or-buses--g1', + 'regulatory--no-motor-vehicles-or-carts--g1', + 'regulatory--no-motorcycles--g1', + 'regulatory--no-motorcycles--g2', + 'regulatory--no-overtaking--g1', + 'regulatory--no-overtaking--g2', + 'regulatory--no-overtaking--g4', + 'regulatory--no-overtaking--g5', + 'regulatory--no-overtaking--g6', + 'regulatory--no-overtaking--g7', + 'regulatory--no-overtaking-atvs--g1', + 'regulatory--no-overtaking-by-heavy-goods-vehicles--g1', + 'regulatory--no-parking--g1', + 'regulatory--no-parking--g2', + 'regulatory--no-parking--g3', + 'regulatory--no-parking--g4', + 'regulatory--no-parking--g5', + 'regulatory--no-parking--g6', + 'regulatory--no-parking--g7', + 'regulatory--no-parking--g8', + 'regulatory--no-parking--g9', + 'regulatory--no-parking-bicycles-or-motorcycles--g1', + 'regulatory--no-parking-bus-stop--g1', + 'regulatory--no-parking-or-no-stopping--g1', + 'regulatory--no-parking-or-no-stopping--g2', + 'regulatory--no-parking-or-no-stopping--g3', + 'regulatory--no-parking-or-no-stopping--g4', + 'regulatory--no-parking-or-no-stopping--g5', + 'regulatory--no-passenger-loading--g1', + 'regulatory--no-pedestrians--g1', + 'regulatory--no-pedestrians--g2', + 'regulatory--no-pedestrians--g3', + 'regulatory--no-pedestrians--g4', + 'regulatory--no-pedestrians--g5', + 'regulatory--no-pedestrians--g6', + 'regulatory--no-pedestrians-bicycles-animals-or-hand-carts--g1', + 'regulatory--no-pedestrians-or-bicycles--g1', + 'regulatory--no-pedestrians-or-bicycles--g2', + 'regulatory--no-pedestrians-or-bicycles--g3', + 'regulatory--no-rickshaws--g1', + 'regulatory--no-rickshaws--g2', + 'regulatory--no-rickshaws--g3', + 'regulatory--no-right-turn--g1', + 'regulatory--no-right-turn--g2', + 'regulatory--no-right-turn--g3', + 'regulatory--no-right-turn-on-red--g1', + 'regulatory--no-skiing--g1', + 'regulatory--no-snowmobiles--g1', + 'regulatory--no-snowmobiles--g2', + 'regulatory--no-snowmobiles-or-atvs--g1', + 'regulatory--no-stopping--g1', + 'regulatory--no-stopping--g2', + 'regulatory--no-stopping--g3', + 'regulatory--no-stopping--g5', + 'regulatory--no-stopping--g6', + 'regulatory--no-stopping--g7', + 'regulatory--no-stopping-on-pavement--g1', + 'regulatory--no-straight-through--g1', + 'regulatory--no-straight-through--g2', + 'regulatory--no-studded-snow-chains--g1', + 'regulatory--no-through-trucks--g1', + 'regulatory--no-tour-buses--g1', + 'regulatory--no-tractors--g1', + 'regulatory--no-tractors-mopeds-or-bicycles--g1', + 'regulatory--no-tractors-or-carts--g1', + 'regulatory--no-trailers--g1', + 'regulatory--no-tricycles--g1', + 'regulatory--no-tricycles-or-hand-carts--g1', + 'regulatory--no-turn-on-red--g1', + 'regulatory--no-turn-on-red--g2', + 'regulatory--no-turn-on-red--g3', + 'regulatory--no-turns--g1', + 'regulatory--no-turns--g2', + 'regulatory--no-two-stage-right-turn-for-mopeds--g1', + 'regulatory--no-u-turn--g1', + 'regulatory--no-u-turn--g2', + 'regulatory--no-u-turn--g3', + 'regulatory--no-vehicles-carrying-dangerous-goods--g1', + 'regulatory--no-vehicles-carrying-dangerous-goods--g2', + 'regulatory--no-vehicles-carrying-dangerous-goods--g3', + 'regulatory--no-vehicles-carrying-dangerous-goods--g4', + 'regulatory--no-vehicles-carrying-dangerous-water-pollutants--g1', + 'regulatory--no-vehicles-carrying-dangerous-water-pollutants--g2', + 'regulatory--no-vehicles-carrying-explosives--g1', + 'regulatory--no-vehicles-carrying-explosives-or-dangerous-water-pollutants--g1', + 'regulatory--one-way-left--g1', + 'regulatory--one-way-left--g2', + 'regulatory--one-way-left--g3', + 'regulatory--one-way-right--g1', + 'regulatory--one-way-right--g2', + 'regulatory--one-way-right--g3', + 'regulatory--one-way-straight--g1', + 'regulatory--one-way-straight--g3', + 'regulatory--parking-fee-station--g1', + 'regulatory--parking-restrictions--g1', + 'regulatory--parking-restrictions--g2', + 'regulatory--parking-restrictions--g3', + 'regulatory--pass-on-either-side--g1', + 'regulatory--pass-on-either-side--g2', + 'regulatory--pass-on-either-side--g3', + 'regulatory--pass-with-care--g1', + 'regulatory--passing-lane-ahead--g1', + 'regulatory--pedestrians-bicycles-permitted--g1', + 'regulatory--pedestrians-keep-left--g1', + 'regulatory--pedestrians-only--g1', + 'regulatory--pedestrians-only--g2', + 'regulatory--pedestrians-only--g3', + 'regulatory--pedestrians-priority-zone--g1', + 'regulatory--pedestrians-push-button--g1', + 'regulatory--pedestrians-push-button--g2', + 'regulatory--priority-over-oncoming-vehicles--g1', + 'regulatory--priority-over-oncoming-vehicles--g2', + 'regulatory--priority-road--g1', + 'regulatory--priority-road--g2', + 'regulatory--radar-enforced--g1', + 'regulatory--reserved-parking--g1', + 'regulatory--reversible-lanes--g1', + 'regulatory--reversible-lanes--g2', + 'regulatory--road-closed--g1', + 'regulatory--road-closed--g2', + 'regulatory--road-closed-to-vehicles--g1', + 'regulatory--road-closed-to-vehicles--g3', + 'regulatory--roundabout--g1', + 'regulatory--roundabout--g2', + 'regulatory--roundabout--g3', + 'regulatory--shared-path-bicycles-and-pedestrians--g1', + 'regulatory--shared-path-pedestrians-and-bicycles--g1', + 'regulatory--sidewalk-closed--g1', + 'regulatory--slanted-parking--g1', + 'regulatory--snow-chains--g1', + 'regulatory--snow-chains--g2', + 'regulatory--snow-chains--g3', + 'regulatory--snowmobiles-only--g1', + 'regulatory--snowmobiles-permitted--g1', + 'regulatory--speed-limit-zone--g1', + 'regulatory--speeding-fines-increased--g1', + 'regulatory--state-route--g1', + 'regulatory--stay-in-lane--g1', + 'regulatory--stop--g1', + 'regulatory--stop--g10', + 'regulatory--stop--g2', + 'regulatory--stop--g3', + 'regulatory--stop--g4', + 'regulatory--stop--g5', + 'regulatory--stop--g6', + 'regulatory--stop--g7', + 'regulatory--stop--g8', + 'regulatory--stop--g9', + 'regulatory--stop-here-on-red-or-flashing-light--g1', + 'regulatory--stop-here-on-red-or-flashing-light--g2', + 'regulatory--stop-signals--g1', + 'regulatory--stop-signals--g2', + 'regulatory--tanks-only--g1', + 'regulatory--taxi-only--g1', + 'regulatory--text--g1', + 'regulatory--text--g2', + 'regulatory--toll-pass-only--g1', + 'regulatory--tractors-only--g1', + 'regulatory--traffic-signal-photo-enforced--g1', + 'regulatory--trams-and-buses-only--g1', + 'regulatory--trams-only--g1', + 'regulatory--triple-lanes--g1', + 'regulatory--triple-lanes-go-straight-center-lane--g1', + 'regulatory--triple-lanes-turn-left-center-lane--g1', + 'regulatory--triple-lanes-turn-right-center-lane--g1', + 'regulatory--truck-route--g1', + 'regulatory--truck-speed-limit-10--g1', + 'regulatory--truck-speed-limit-15--g1', + 'regulatory--truck-speed-limit-20--g1', + 'regulatory--truck-speed-limit-25--g1', + 'regulatory--truck-speed-limit-30--g1', + 'regulatory--truck-speed-limit-35--g1', + 'regulatory--truck-speed-limit-40--g1', + 'regulatory--truck-speed-limit-45--g1', + 'regulatory--truck-speed-limit-5--g1', + 'regulatory--truck-speed-limit-50--g1', + 'regulatory--truck-speed-limit-55--g1', + 'regulatory--truck-speed-limit-60--g1', + 'regulatory--truck-speed-limit-65--g1', + 'regulatory--truck-speed-limit-70--g1', + 'regulatory--truck-speed-limit-75--g1', + 'regulatory--truck-speed-limit-80--g1', + 'regulatory--truck-speed-limit-85--g1', + 'regulatory--trucks-and-buses-only--g1', + 'regulatory--trucks-on-right--g1', + 'regulatory--trucks-only--g1', + 'regulatory--turn-left--g1', + 'regulatory--turn-left--g2', + 'regulatory--turn-left--g3', + 'regulatory--turn-left-ahead--g1', + 'regulatory--turn-left-ahead--g2', + 'regulatory--turn-left-or-right--g1', + 'regulatory--turn-left-or-right--g2', + 'regulatory--turn-left-or-right--g3', + 'regulatory--turn-left-or-u-turn--g1', + 'regulatory--turn-right--g1', + 'regulatory--turn-right--g2', + 'regulatory--turn-right--g3', + 'regulatory--turn-right-ahead--g1', + 'regulatory--turn-right-ahead--g2', + 'regulatory--turning-vehicles-yield-to-pedestrians--g1', + 'regulatory--two-stage-right-turn-for-mopeds--g1', + 'regulatory--two-way--g1', + 'regulatory--u-turn--g1', + 'regulatory--u-turn--g2', + 'regulatory--u-turn--g3', + 'regulatory--use-crosswalk--g1', + 'regulatory--vehicles-carrying-dangerous-goods-only--g1', + 'regulatory--vehicles-carrying-dangerous-goods-permitted--g1', + 'regulatory--vehicles-carrying-explosives-only--g1', + 'regulatory--vehicles-carrying-hazardous-goods-permitted--g1', + 'regulatory--vehicles-only--g1', + 'regulatory--wear-seat-belt--g1', + 'regulatory--wear-seat-belt--g2', + 'regulatory--wear-seat-belt--g3', + 'regulatory--weight-limit--g1', + 'regulatory--weight-limit--g2', + 'regulatory--weight-limit--g3', + 'regulatory--weight-limit--g4', + 'regulatory--weight-limit--g5', + 'regulatory--weight-limit--g6', + 'regulatory--weight-limit--g7', + 'regulatory--weight-limit-per-axle--g1', + 'regulatory--weight-limit-per-tandem-axle--g1', + 'regulatory--weight-limit-with-trucks--g1', + 'regulatory--width-limit--g1', + 'regulatory--wrong-way--g1', + 'regulatory--yield--g1', + 'regulatory--yield-or-stop-for-pedestrians--g1', + 'regulatory--your-speed--g1', + 'warning--accident-area--g1', + 'warning--accident-area--g2', + 'warning--accident-area--g3', + 'warning--accident-area--g4', + 'warning--accident-area--g5', + 'warning--accident-area--g6', + 'warning--accident-area--g7', + 'warning--accident-area--g8', + 'warning--added-lane-from-entering-roadway--g1', + 'warning--added-lane-from-entering-roadway--g2', + 'warning--added-lane-left--g1', + 'warning--added-lane-right--g1', + 'warning--animal-drawn-vehicles--g1', + 'warning--arch-bridge--g1', + 'warning--atv-and-snowmobiles--g1', + 'warning--atv-crossing--g1', + 'warning--atv-crossing--g2', + 'warning--axle-restriction--g1', + 'warning--bear-crossing--g1', + 'warning--bear-crossing--g2', + 'warning--bicycles-and-others--g1', + 'warning--bicycles-caution-on-rail-tracks--g1', + 'warning--bicycles-crossing--g1', + 'warning--bicycles-crossing--g2', + 'warning--bicycles-crossing--g3', + 'warning--bicycles-crossing--g4', + 'warning--bollard--g1', + 'warning--bridge--g1', + 'warning--bridge--g2', + 'warning--bus-stop-ahead--g1', + 'warning--bus-stop-ahead--g2', + 'warning--bus-stop-ahead--g3', + 'warning--camel-crossing--g1', + 'warning--camel-crossing--g2', + 'warning--camera--g1', + 'warning--camera--g2', + 'warning--carts--g1', + 'warning--carts--g2', + 'warning--checkpoint--g1', + 'warning--children--g1', + 'warning--children--g2', + 'warning--children--g3', + 'warning--children--g4', + 'warning--children--g6', + 'warning--cliff--g1', + 'warning--cliff--g2', + 'warning--closed-lane-in-triple-lanes--g1', + 'warning--closed-lane-in-triple-lanes--g2', + 'warning--construction-ahead--g1', + 'warning--crossroads--g1', + 'warning--crossroads--g2', + 'warning--crossroads--g3', + 'warning--crossroads--g4', + 'warning--crossroads--g5', + 'warning--crossroads--g6', + 'warning--crossroads-with-priority-to-the-right--g1', + 'warning--curve-left--g1', + 'warning--curve-left--g2', + 'warning--curve-left--g3', + 'warning--curve-left-with-junction--g1', + 'warning--curve-out-intersection-left--g1', + 'warning--curve-out-intersection-right--g1', + 'warning--curve-right--g1', + 'warning--curve-right--g2', + 'warning--curve-right--g3', + 'warning--curve-right-with-junction--g1', + 'warning--dangerous-crosswinds-left--g1', + 'warning--dangerous-crosswinds-left--g2', + 'warning--dangerous-crosswinds-left--g4', + 'warning--dangerous-crosswinds-right--g1', + 'warning--dangerous-crosswinds-right--g2', + 'warning--dangerous-crosswinds-right--g3', + 'warning--dangerous-crosswinds-right--g4', + 'warning--dead-end--g1', + 'warning--dead-end--g2', + 'warning--dead-end--g3', + 'warning--dead-end-go-left--g1', + 'warning--dead-end-go-right--g1', + 'warning--descent-or-climbing-lanes-in-triple-lanes--g1', + 'warning--detour-ahead--g1', + 'warning--detour-or-construction-ahead--g1', + 'warning--dip--g1', + 'warning--dip--g2', + 'warning--disabled-persons-crossing--g1', + 'warning--disabled-persons-crossing--g2', + 'warning--divided-highway--g1', + 'warning--divided-highway--g2', + 'warning--divided-highway--g3', + 'warning--divided-highway--g4', + 'warning--divided-highway--g5', + 'warning--divided-highway--g6', + 'warning--divided-highway--g7', + 'warning--divided-highway--g8', + 'warning--divided-highway--g9', + 'warning--divided-highway-ends--g1', + 'warning--divided-highway-ends--g2', + 'warning--divided-highway-ends--g3', + 'warning--divided-highway-ends--g4', + 'warning--divided-highway-on-left--g1', + 'warning--divided-highway-on-left--g2', + 'warning--divided-highway-on-right--g1', + 'warning--divided-highway-on-right--g2', + 'warning--divided-highway-to-left--g1', + 'warning--divided-highway-to-right--g1', + 'warning--domestic-animals--g1', + 'warning--domestic-animals--g2', + 'warning--domestic-animals--g3', + 'warning--domestic-animals--g4', + 'warning--domestic-animals--g5', + 'warning--domestic-animals--g6', + 'warning--domestic-animals--g7', + 'warning--domestic-animals--g8', + 'warning--double-curve-first-left--g1', + 'warning--double-curve-first-left--g2', + 'warning--double-curve-first-right--g1', + 'warning--double-curve-first-right--g2', + 'warning--double-descent--g1', + 'warning--double-reverse-curve-left--g1', + 'warning--double-reverse-curve-left--g2', + 'warning--double-reverse-curve-right--g1', + 'warning--double-reverse-curve-right--g2', + 'warning--double-side-roads-left--g1', + 'warning--double-side-roads-left--g3', + 'warning--double-side-roads-right--g1', + 'warning--double-side-roads-right--g3', + 'warning--double-turn-first-left--g1', + 'warning--double-turn-first-right--g1', + 'warning--dual-lanes-all-directions-on-left--g1', + 'warning--dual-lanes-all-directions-on-right--g1', + 'warning--dual-lanes-go-straight-or-turn-left--g1', + 'warning--dual-lanes-go-straight-or-turn-right--g1', + 'warning--dual-lanes-left-turn--g1', + 'warning--dual-lanes-left-turn-or-go-straight--g1', + 'warning--dual-lanes-right-turn--g1', + 'warning--dual-lanes-right-turn-or-go-straight--g1', + 'warning--dual-lanes-turn-left--g1', + 'warning--dual-lanes-turn-left-or-right--g1', + 'warning--dual-lanes-turn-left-or-right--g2', + 'warning--dual-lanes-turn-left-or-right--g3', + 'warning--dual-lanes-turn-left-or-right--g4', + 'warning--dual-lanes-turn-right--g1', + 'warning--dual-path-cyclists-and-pedestrians--g1', + 'warning--electricity--g1', + 'warning--electricity--g2', + 'warning--elephant-crossing--g1', + 'warning--emergency-vehicles--g1', + 'warning--emu-crossing--g1', + 'warning--emu-crossing--g2', + 'warning--entering-roadway-merge--g1', + 'warning--entering-roadway-merge--g2', + 'warning--equestrians-crossing--g1', + 'warning--equestrians-crossing--g2', + 'warning--expressway--g1', + 'warning--falling-rocks-or-debris-left--g1', + 'warning--falling-rocks-or-debris-left--g2', + 'warning--falling-rocks-or-debris-left--g3', + 'warning--falling-rocks-or-debris-left--g4', + 'warning--falling-rocks-or-debris-right--g1', + 'warning--falling-rocks-or-debris-right--g2', + 'warning--falling-rocks-or-debris-right--g3', + 'warning--falling-rocks-or-debris-right--g4', + 'warning--ferry--g1', + 'warning--flaggers-in-road--g1', + 'warning--flaggers-in-road--g2', + 'warning--foggy-road--g1', + 'warning--foggy-road--g2', + 'warning--ford--g1', + 'warning--forest--g1', + 'warning--fresh-oil--g1', + 'warning--frog-crossing--g1', + 'warning--gate--g1', + 'warning--gate--g2', + 'warning--gate-left--g1', + 'warning--gate-right--g1', + 'warning--go-left--g1', + 'warning--go-right--g1', + 'warning--golf-carts-crossing--g1', + 'warning--gravel-road-surface--g1', + 'warning--hairpin-curve-left--g1', + 'warning--hairpin-curve-left--g2', + 'warning--hairpin-curve-left--g3', + 'warning--hairpin-curve-right--g1', + 'warning--hairpin-curve-right--g3', + 'warning--height-restriction--g2', + 'warning--height-restriction--g3', + 'warning--height-restriction--g4', + 'warning--height-restriction--g5', + 'warning--horizontal-alignment-left--g1', + 'warning--horizontal-alignment-left--g3', + 'warning--horizontal-alignment-right--g1', + 'warning--horizontal-alignment-right--g3', + 'warning--horse-crossing--g1', + 'warning--icy-road--g1', + 'warning--junction-with-a-side-road-acute-left--g1', + 'warning--junction-with-a-side-road-acute-left--g2', + 'warning--junction-with-a-side-road-acute-right--g1', + 'warning--junction-with-a-side-road-acute-right--g2', + 'warning--junction-with-a-side-road-perpendicular-left--g1', + 'warning--junction-with-a-side-road-perpendicular-left--g2', + 'warning--junction-with-a-side-road-perpendicular-left--g3', + 'warning--junction-with-a-side-road-perpendicular-left--g4', + 'warning--junction-with-a-side-road-perpendicular-right--g1', + 'warning--junction-with-a-side-road-perpendicular-right--g2', + 'warning--junction-with-a-side-road-perpendicular-right--g3', + 'warning--junction-with-a-side-road-perpendicular-right--g4', + 'warning--junction-with-merge-from-left--g1', + 'warning--junction-with-merge-from-right--g1', + 'warning--junction-with-side-roads--g1', + 'warning--kangaloo-crossing--g1', + 'warning--keep-distance--g1', + 'warning--keep-left--g1', + 'warning--keep-right--g1', + 'warning--kiwi-crossing--g1', + 'warning--kiwi-crossing--g2', + 'warning--koala-crossing--g1', + 'warning--koala-crossing--g2', + 'warning--koala-crossing--g3', + 'warning--koala-crossing--g4', + 'warning--lane-closed-in-dual-lanes-left--g1', + 'warning--lane-closed-in-dual-lanes-left--g2', + 'warning--lane-closed-in-dual-lanes-right--g1', + 'warning--lane-closed-in-dual-lanes-right--g2', + 'warning--length-restriction--g1', + 'warning--length-restriction--g2', + 'warning--light-rail-transit-vehicles--g1', + 'warning--limited-lighting-under-trees--g1', + 'warning--logging-vehicles--g1', + 'warning--loop-270-degree--g1', + 'warning--loop-pretzel--g1', + 'warning--loose-road-surface--g1', + 'warning--loose-road-surface--g2', + 'warning--loose-road-surface--g3', + 'warning--loose-road-surface--g4', + 'warning--low-flying-aircraft--g1', + 'warning--low-flying-aircraft--g2', + 'warning--low-flying-aircraft--g3', + 'warning--low-flying-aircraft--g4', + 'warning--low-flying-aircraft--g5', + 'warning--low-flying-aircraft--g6', + 'warning--low-flying-aircraft--g7', + 'warning--low-flying-aircraft--g8', + 'warning--low-ground-clearance--g1', + 'warning--low-ground-clearance--g2', + 'warning--low-ground-clearance--g3', + 'warning--monkey-crossing--g1', + 'warning--motorcycles-crossing--g1', + 'warning--narrow-bridge--g1', + 'warning--narrow-bridge--g2', + 'warning--narrow-bridge--g3', + 'warning--no-passing-zone--g1', + 'warning--no-passing-zone--g2', + 'warning--occupied-lanes--g1', + 'warning--offset-roads--g1', + 'warning--offset-roads--g2', + 'warning--offset-roads--g3', + 'warning--offset-roads--g4', + 'warning--opening-or-swing-bridge--g1', + 'warning--opening-or-swing-bridge--g2', + 'warning--other-danger--g1', + 'warning--other-danger--g2', + 'warning--other-danger--g3', + 'warning--panda-crossing--g1', + 'warning--pass-left-or-right--g1', + 'warning--pass-left-or-right--g2', + 'warning--pass-left-or-right--g3', + 'warning--pavement-ahead--g1', + 'warning--pavement-ends--g1', + 'warning--pavement-ends--g2', + 'warning--pavement-ends--g3', + 'warning--pavement-ends--g4', + 'warning--pavement-ends--g5', + 'warning--pedestrians-crossing--g1', + 'warning--pedestrians-crossing--g10', + 'warning--pedestrians-crossing--g11', + 'warning--pedestrians-crossing--g12', + 'warning--pedestrians-crossing--g4', + 'warning--pedestrians-crossing--g5', + 'warning--pedestrians-crossing--g6', + 'warning--pedestrians-crossing--g7', + 'warning--pedestrians-crossing--g8', + 'warning--pedestrians-crossing--g9', + 'warning--playground--g1', + 'warning--playground--g3', + 'warning--polar-bear-crossing--g1', + 'warning--quay-or-river-bank--g1', + 'warning--quay-or-river-bank--g2', + 'warning--quay-or-river-bank--g3', + 'warning--quay-or-river-bank--g4', + 'warning--rabbit-crossing--g1', + 'warning--raccoon-crossing--g1', + 'warning--railroad-crossing--g1', + 'warning--railroad-crossing--g2', + 'warning--railroad-crossing--g3', + 'warning--railroad-crossing--g4', + 'warning--railroad-crossing-with-barriers--g1', + 'warning--railroad-crossing-with-barriers--g2', + 'warning--railroad-crossing-with-barriers--g3', + 'warning--railroad-crossing-with-barriers--g4', + 'warning--railroad-crossing-with-barriers--g5', + 'warning--railroad-crossing-with-barriers--g6', + 'warning--railroad-crossing-with-barriers--g7', + 'warning--railroad-crossing-without-barriers--g1', + 'warning--railroad-crossing-without-barriers--g2', + 'warning--railroad-crossing-without-barriers--g3', + 'warning--railroad-crossing-without-barriers--g4', + 'warning--railroad-crossing-without-barriers--g5', + 'warning--railroad-crossing-without-barriers--g6', + 'warning--railroad-intersection--g1', + 'warning--railroad-intersection--g2', + 'warning--railroad-intersection--g3', + 'warning--railroad-intersection--g4', + 'warning--railroad-intersection--g5', + 'warning--railroad-intersection--g6', + 'warning--railroad-intersection--g7', + 'warning--railroad-intersection--g8', + 'warning--railroad-intersection--g9', + 'warning--ramp-closed--g1', + 'warning--reduced-maximum-speed-limit--g1', + 'warning--reserved-lane--g1', + 'warning--restricted-zone--g1', + 'warning--reversible-lanes--g1', + 'warning--reversible-lanes--g2', + 'warning--rickshaws-crossing--g1', + 'warning--road-blocks--g1', + 'warning--road-bump--g1', + 'warning--road-bump--g2', + 'warning--road-bump--g3', + 'warning--road-bump-with-speed-limit--g1', + 'warning--road-closed--g3', + 'warning--road-narrows--g1', + 'warning--road-narrows--g2', + 'warning--road-narrows-left--g1', + 'warning--road-narrows-left--g2', + 'warning--road-narrows-left-ahead--g1', + 'warning--road-narrows-right--g1', + 'warning--road-narrows-right--g2', + 'warning--road-narrows-right-ahead--g1', + 'warning--road-toll-ahead--g1', + 'warning--road-widens--g1', + 'warning--road-widens-left--g1', + 'warning--road-widens-right--g1', + 'warning--roadworks--g1', + 'warning--roadworks--g10', + 'warning--roadworks--g11', + 'warning--roadworks--g2', + 'warning--roadworks--g3', + 'warning--roadworks--g5', + 'warning--roadworks--g6', + 'warning--roadworks--g8', + 'warning--roadworks--g9', + 'warning--roadworks-go-left-or-straight--g1', + 'warning--roadworks-go-right-or-straight--g1', + 'warning--roundabout--g1', + 'warning--roundabout--g2', + 'warning--roundabout--g3', + 'warning--roundabout--g4', + 'warning--roundabout--g5', + 'warning--roundabout--g6', + 'warning--roundabout--g7', + 'warning--ruts--g1', + 'warning--sand--g1', + 'warning--sand-drift--g1', + 'warning--school-zone--g2', + 'warning--severe-weather--g1', + 'warning--shared-lane-motorcycles-bicycles--g1', + 'warning--sharp-turn--g1', + 'warning--single-reverse-curve--g1', + 'warning--skewed-t-roads-left--g1', + 'warning--skewed-t-roads-left--g2', + 'warning--skewed-t-roads-left--g3', + 'warning--skewed-t-roads-right--g1', + 'warning--skewed-t-roads-right--g2', + 'warning--skewed-t-roads-right--g3', + 'warning--skiers--g1', + 'warning--skiers--g2', + 'warning--skiers--g3', + 'warning--slippery-bicycles--g1', + 'warning--slippery-motorcycles--g1', + 'warning--slippery-motorcycles--g2', + 'warning--slippery-road-surface--g1', + 'warning--slippery-road-surface--g2', + 'warning--slow--g1', + 'warning--snow-tractors--g1', + 'warning--snowmobiles--g1', + 'warning--snowmobiles--g2', + 'warning--snowmobiles--g3', + 'warning--snowmobiles-and-others--g1', + 'warning--soft-road-surface--g1', + 'warning--soft-road-surface--g2', + 'warning--soft-shoulder--g1', + 'warning--soft-shoulder--g2', + 'warning--soft-shoulder--g3', + 'warning--soft-shoulder--g4', + 'warning--speed-camera--g1', + 'warning--steep-ascent--g1', + 'warning--steep-ascent--g2', + 'warning--steep-ascent--g3', + 'warning--steep-ascent--g4', + 'warning--steep-ascent-and-descent--g1', + 'warning--steep-descent--g1', + 'warning--steep-descent--g2', + 'warning--steep-descent--g3', + 'warning--steep-descent--g4', + 'warning--steep-descent--g5', + 'warning--steep-descent--g6', + 'warning--stop-ahead--g1', + 'warning--stop-ahead--g3', + 'warning--stop-ahead--g4', + 'warning--stop-ahead--g5', + 'warning--stop-ahead--g6', + 'warning--t-roads--g1', + 'warning--t-roads--g2', + 'warning--tanks-crossing--g1', + 'warning--tanks-crossing--g2', + 'warning--texts--g1', + 'warning--texts--g2', + 'warning--texts--g3', + 'warning--towing--g1', + 'warning--tractors--g1', + 'warning--tractors--g2', + 'warning--tractors--g3', + 'warning--tractors--g4', + 'warning--tractors--g5', + 'warning--tractors--g6', + 'warning--tractors--g7', + 'warning--traffic-merges-at-signalized-intersections--g1', + 'warning--traffic-merges-left--g1', + 'warning--traffic-merges-left--g2', + 'warning--traffic-merges-left--g3', + 'warning--traffic-merges-left--g4', + 'warning--traffic-merges-left-and-right--g1', + 'warning--traffic-merges-left-buses--g1', + 'warning--traffic-merges-right--g1', + 'warning--traffic-merges-right--g2', + 'warning--traffic-merges-right--g3', + 'warning--traffic-merges-right-buses--g1', + 'warning--traffic-queues-likely--g1', + 'warning--traffic-queues-likely--g2', + 'warning--traffic-queues-likely--g3', + 'warning--traffic-queues-likely--g4', + 'warning--traffic-queues-likely--g5', + 'warning--traffic-signals--g1', + 'warning--traffic-signals--g2', + 'warning--traffic-signals--g3', + 'warning--traffic-signals--g4', + 'warning--traffic-signals--g5', + 'warning--traffic-signals--g6', + 'warning--traffic-slow--g1', + 'warning--trail-crossing--g1', + 'warning--trail-crossing--g2', + 'warning--trail-crossing--g3', + 'warning--trail-crossing--g4', + 'warning--trail-crossing--g5', + 'warning--trail-crossing--g6', + 'warning--trams-crossing--g1', + 'warning--trams-crossing--g2', + 'warning--triple-curve-left--g1', + 'warning--triple-curve-right--g1', + 'warning--triple-lanes-left-turn--g1', + 'warning--triple-lanes-left-turn-or-go-straight--g1', + 'warning--triple-lanes-right-turn--g1', + 'warning--triple-lanes-right-turn-or-go-straight--g1', + 'warning--triple-lanes-with-directions--g1', + 'warning--triple-reverse-curve-left--g1', + 'warning--triple-reverse-curve-right--g1', + 'warning--trucks-crossing--g1', + 'warning--trucks-crossing--g2', + 'warning--trucks-rollover--g1', + 'warning--trucks-rollover--g2', + 'warning--trucks-rollover--g3', + 'warning--trucks-rollover--g4', + 'warning--trucks-rollover--g5', + 'warning--tunnel--g1', + 'warning--tunnel--g2', + 'warning--tunnel--g3', + 'warning--tunnel--g4', + 'warning--tunnel--g5', + 'warning--tunnel--g6', + 'warning--tunnel--g7', + 'warning--turn-left--g1', + 'warning--turn-left--g2', + 'warning--turn-left--g3', + 'warning--turn-left-or-right--g1', + 'warning--turn-right--g1', + 'warning--turn-right--g2', + 'warning--turn-right--g3', + 'warning--two-way-traffic--g1', + 'warning--two-way-traffic--g2', + 'warning--two-way-traffic--g3', + 'warning--two-way-traffic--g4', + 'warning--two-way-traffic--g5', + 'warning--two-way-traffic--g6', + 'warning--u-turn--g1', + 'warning--u-turn--g2', + 'warning--uneven-road--g1', + 'warning--uneven-road--g2', + 'warning--uneven-roads-ahead--g1', + 'warning--vehicles-and-others--g1', + 'warning--vehicles-crossing--g1', + 'warning--village--g1', + 'warning--weight-limit--g5', + 'warning--weight-limit-per-tandem-axle--g1', + 'warning--width-restriction--g1', + 'warning--width-restriction--g2', + 'warning--width-restriction--g3', + 'warning--width-restriction--g4', + 'warning--wild-animals--g1', + 'warning--wild-animals--g2', + 'warning--wild-animals--g3', + 'warning--wild-animals--g4', + 'warning--wild-animals--g5', + 'warning--wild-animals--g6', + 'warning--wild-animals--g7', + 'warning--wild-animals--g8', + 'warning--wind--g1', + 'warning--winding-road--g1', + 'warning--winding-road-first-left--g1', + 'warning--winding-road-first-left--g2', + 'warning--winding-road-first-left--g3', + 'warning--winding-road-first-right--g1', + 'warning--winding-road-first-right--g2', + 'warning--winding-road-first-right--g3', + 'warning--winding-road-first-right--g4', + 'warning--winding-road-to-left--g1', + 'warning--winding-road-to-right--g1', + 'warning--wombat-crossing--g1', + 'warning--y-roads--g1', + 'warning--y-roads--g2', + 'warning--yield-ahead--g1', + 'warning--yield-ahead--g3', +]); + +/** Sign values collapsed to their family (regional suffix removed), sorted. */ +export const SIGN_FAMILIES = Object.freeze( + [...new Set(SIGN_VALUES.map((value) => value.replace(/--g\d+$/, '')))].sort(), +); diff --git a/src/layers/mapillary/tileMath.js b/src/layers/mapillary/tileMath.js new file mode 100644 index 000000000..efc495ad3 --- /dev/null +++ b/src/layers/mapillary/tileMath.js @@ -0,0 +1,163 @@ +/** + * Web-Mercator tile arithmetic for Mapillary vector tiles. Pure functions, + * no Cesium dependency, shared by the browser layer and the server executor. + */ + +const MAX_LAT = 85.05112878; + +function clampLat(lat) { + return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat)); +} + +/** Tile column for a longitude at zoom z. */ +export function lonToTileX(lon, z) { + const n = 2 ** z; + const wrapped = ((((lon + 180) % 360) + 360) % 360) - 180; + return Math.min(n - 1, Math.max(0, Math.floor(((wrapped + 180) / 360) * n))); +} + +/** Tile row for a latitude at zoom z. */ +export function latToTileY(lat, z) { + const n = 2 ** z; + const rad = (clampLat(lat) * Math.PI) / 180; + const y = Math.floor( + ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n, + ); + return Math.min(n - 1, Math.max(0, y)); +} + +/** West longitude of tile column x at zoom z. */ +function tileXToLon(x, z) { + return (x / 2 ** z) * 360 - 180; +} + +/** North latitude of tile row y at zoom z. */ +function tileYToLat(y, z) { + const n = Math.PI - (2 * Math.PI * y) / 2 ** z; + return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); +} + +/** + * Geographic bounds of one tile. + * @returns {{west:number,south:number,east:number,north:number}} + */ +export function tileBounds(x, y, z) { + return { + west: tileXToLon(x, z), + east: tileXToLon(x + 1, z), + north: tileYToLat(y, z), + south: tileYToLat(y + 1, z), + }; +} + +/** + * Normalize a bbox given as [west, south, east, north] (or an object) into + * finite, ordered numbers. Returns null when the input is not a usable box. + */ +export function normalizeBbox(input) { + const values = Array.isArray(input) + ? input + : input && typeof input === 'object' + ? [input.west, input.south, input.east, input.north] + : null; + if (!values || values.length !== 4) return null; + const [w, s, e, n] = values.map(Number); + if (![w, s, e, n].every(Number.isFinite)) return null; + const west = Math.max(-180, Math.min(w, e)); + const east = Math.min(180, Math.max(w, e)); + const south = Math.max(-MAX_LAT, Math.min(s, n)); + const north = Math.min(MAX_LAT, Math.max(s, n)); + if (east - west <= 0 || north - south <= 0) return null; + return { west, south, east, north }; +} + +/** Area of a bbox in square degrees (Mapillary's bbox limit unit). */ +export function bboxAreaDeg2(bbox) { + const box = normalizeBbox(bbox); + return box ? (box.east - box.west) * (box.north - box.south) : 0; +} + +/** + * Number of tiles at zoom z covering a bbox, without allocating them. + */ +export function countTilesForBbox(bbox, z) { + const box = normalizeBbox(bbox); + if (!box) return 0; + const x0 = lonToTileX(box.west, z); + const x1 = lonToTileX(box.east, z); + const y0 = latToTileY(box.north, z); + const y1 = latToTileY(box.south, z); + return (x1 - x0 + 1) * (y1 - y0 + 1); +} + +/** + * Enumerate the tiles at zoom z covering a bbox, ordered from the centre of + * the box outwards so a progressive renderer fills in what the user is most + * likely looking at first. `limit` caps the list; the result reports whether + * the cap was hit. + * @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}} + */ +export function tilesForBbox(bbox, z, { limit = Infinity } = {}) { + const box = normalizeBbox(bbox); + if (!box) return { tiles: [], truncated: false, total: 0 }; + const x0 = lonToTileX(box.west, z); + const x1 = lonToTileX(box.east, z); + const y0 = latToTileY(box.north, z); + const y1 = latToTileY(box.south, z); + const total = (x1 - x0 + 1) * (y1 - y0 + 1); + const cx = (x0 + x1) / 2; + const cy = (y0 + y1) / 2; + const tiles = []; + for (let y = y0; y <= y1; y++) + for (let x = x0; x <= x1; x++) tiles.push({ x, y, z }); + tiles.sort( + (a, b) => + (a.x - cx) ** 2 + (a.y - cy) ** 2 - ((b.x - cx) ** 2 + (b.y - cy) ** 2), + ); + const truncated = tiles.length > limit; + return { tiles: truncated ? tiles.slice(0, limit) : tiles, truncated, total }; +} + +/** + * Convert a vector-tile-local coordinate (0..extent) into longitude and + * latitude for tile (x, y, z). + * @returns {[number, number]} [lon, lat] + */ +export function tileLocalToLonLat(px, py, extent, x, y, z) { + const n = 2 ** z; + const lon = ((x + px / extent) / n) * 360 - 180; + const merc = Math.PI - (2 * Math.PI * (y + py / extent)) / n; + const lat = + (180 / Math.PI) * Math.atan(0.5 * (Math.exp(merc) - Math.exp(-merc))); + return [lon, lat]; +} + +/** + * Pick a coverage zoom for a camera height (metres above ground). Higher + * cameras get coarser sequence tiles; below the floor Mapillary has no + * finer tiles than z14. + */ +export function coverageZoomForHeight(heightM) { + if (!Number.isFinite(heightM)) return null; + if (heightM > 60_000) return null; + if (heightM > 12_000) return 11; + if (heightM > 5_000) return 12; + if (heightM > 1_800) return 13; + return 14; +} + +/** + * Pick the low-zoom `overview` tile level for a camera far above the ground. + * Mapillary publishes coverage points at z0–5; sequences start at z6 and are + * far too heavy for a continent-sized view. Returns null below the overview + * ceiling (60 km), where `coverageZoomForHeight` takes over. + */ +export function overviewZoomForHeight(heightM) { + if (!Number.isFinite(heightM) || heightM <= 60_000) return null; + if (heightM > 15_000_000) return 0; + if (heightM > 7_000_000) return 1; + if (heightM > 3_000_000) return 2; + if (heightM > 1_200_000) return 3; + if (heightM > 400_000) return 4; + return 5; +} diff --git a/src/layers/mapillary/tileMath.test.mjs b/src/layers/mapillary/tileMath.test.mjs new file mode 100644 index 000000000..40e450a2d --- /dev/null +++ b/src/layers/mapillary/tileMath.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + bboxAreaDeg2, + countTilesForBbox, + coverageZoomForHeight, + overviewZoomForHeight, + latToTileY, + lonToTileX, + normalizeBbox, + tileBounds, + tileLocalToLonLat, + tilesForBbox, +} from './tileMath.js'; + +test('lon/lat to tile matches the Sacramento reference tile', () => { + assert.equal(lonToTileX(-121.4944, 14), 2662); + assert.equal(latToTileY(38.5816, 14), 6286); + const bounds = tileBounds(2662, 6286, 14); + assert.ok(bounds.west < -121.4944 && -121.4944 < bounds.east); + assert.ok(bounds.south < 38.5816 && 38.5816 < bounds.north); +}); + +test('tile-local coordinates round-trip into the tile bounds', () => { + const bounds = tileBounds(2662, 6286, 14); + const [lon, lat] = tileLocalToLonLat(0, 0, 4096, 2662, 6286, 14); + assert.ok(Math.abs(lon - bounds.west) < 1e-9); + assert.ok(Math.abs(lat - bounds.north) < 1e-9); + const [lon2, lat2] = tileLocalToLonLat(4096, 4096, 4096, 2662, 6286, 14); + assert.ok(Math.abs(lon2 - bounds.east) < 1e-9); + assert.ok(Math.abs(lat2 - bounds.south) < 1e-9); +}); + +test('normalizeBbox orders and clamps coordinates', () => { + assert.deepEqual(normalizeBbox([-121.36, 38.69, -121.56, 38.44]), { + west: -121.56, + south: 38.44, + east: -121.36, + north: 38.69, + }); + assert.equal(normalizeBbox([0, 0, 0, 1]), null); + assert.equal(normalizeBbox('nope'), null); + assert.equal(normalizeBbox([1, 2, Number.NaN, 3]), null); +}); + +test('city-scale boxes exceed the Mapillary bbox limit and need tiles', () => { + const sacramento = [-121.56, 38.44, -121.36, 38.69]; + assert.ok(bboxAreaDeg2(sacramento) > 0.01); + assert.equal(countTilesForBbox(sacramento, 14), 160); + const detroit = [-83.29, 42.25, -82.91, 42.45]; + assert.equal(countTilesForBbox(detroit, 14), 234); +}); + +test('tilesForBbox orders from the centre outwards and honours the cap', () => { + const result = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14, { + limit: 5, + }); + assert.equal(result.total, 160); + assert.equal(result.truncated, true); + assert.equal(result.tiles.length, 5); + const centreX = lonToTileX(-121.46, 14); + const centreY = latToTileY(38.565, 14); + assert.ok(Math.abs(result.tiles[0].x - centreX) <= 1); + assert.ok(Math.abs(result.tiles[0].y - centreY) <= 1); + const full = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14); + assert.equal(full.truncated, false); + assert.equal(full.tiles.length, 160); +}); + +test('coverageZoomForHeight steps from coarse to z14 as the camera descends', () => { + assert.equal(coverageZoomForHeight(100_000), null); + assert.equal(coverageZoomForHeight(30_000), 11); + assert.equal(coverageZoomForHeight(8_000), 12); + assert.equal(coverageZoomForHeight(3_000), 13); + assert.equal(coverageZoomForHeight(500), 14); + assert.equal(coverageZoomForHeight(Number.NaN), null); +}); + +test('overviewZoomForHeight covers the globe above the sequence ceiling', () => { + assert.equal(overviewZoomForHeight(30_000), null); + assert.equal(overviewZoomForHeight(100_000), 5); + assert.equal(overviewZoomForHeight(900_000), 4); + assert.equal(overviewZoomForHeight(2_000_000), 3); + assert.equal(overviewZoomForHeight(5_000_000), 2); + assert.equal(overviewZoomForHeight(10_000_000), 1); + assert.equal(overviewZoomForHeight(25_000_000), 0); +}); diff --git a/src/layers/mapillary/values.js b/src/layers/mapillary/values.js new file mode 100644 index 000000000..4082819b5 --- /dev/null +++ b/src/layers/mapillary/values.js @@ -0,0 +1,162 @@ +/** + * Mapillary map-feature value helpers shared by the browser layer and the + * server query executor. Values look like `object--fire-hydrant` or + * `regulatory--stop--g1`; the segments are separated by `--`. + */ + +const SIGN_PREFIXES = Object.freeze([ + 'regulatory', + 'warning', + 'information', + 'complementary', +]); + +/** Escape a string for use inside a RegExp source. */ +function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Normalize one user-supplied value pattern: trimmed, lower-cased, spaces + * folded to hyphens, and only the characters a Mapillary value can contain. + * Returns '' when nothing usable remains. + * @param {unknown} pattern + * @returns {string} + */ +export function normalizeValuePattern(pattern) { + return String(pattern ?? '') + .trim() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9*-]/g, '') + .slice(0, 120); +} + +/** + * Build a matcher for a list of value patterns. `*` matches any run of + * characters, so `regulatory--stop--*` covers every regional variant and + * `object--traffic-light--*` every traffic-light subtype. An empty pattern + * list matches everything. + * @param {Iterable} patterns + * @returns {(value: string) => boolean} + */ +export function createValueMatcher(patterns) { + const exact = new Set(); + const wild = []; + for (const raw of patterns || []) { + const pattern = normalizeValuePattern(raw); + if (!pattern) continue; + if (pattern.includes('*')) { + wild.push( + new RegExp(`^${pattern.split('*').map(escapeRegExp).join('.*')}$`), + ); + } else exact.add(pattern); + } + if (!exact.size && !wild.length) return () => true; + return (value) => { + if (typeof value !== 'string') return false; + if (exact.has(value)) return true; + for (const regex of wild) if (regex.test(value)) return true; + return false; + }; +} + +/** + * Whether a value belongs to the traffic-sign layer rather than the point + * object layer. Sign values start with one of four regulatory families. + * @param {string} value + * @returns {boolean} + */ +export function isSignValue(value) { + const head = String(value || '').split('--')[0]; + return SIGN_PREFIXES.includes(head); +} + +/** + * Human label for a value: `object--fire-hydrant` → "Fire hydrant", + * `regulatory--stop--g1` → "Stop (regulatory)", + * `object--traffic-light--general-upright` → "Traffic light · general upright". + * @param {string} value + * @returns {string} + */ +export function humanizeValue(value) { + const parts = String(value || '') + .split('--') + .filter(Boolean); + if (!parts.length) return 'Unknown'; + const [head, ...rest] = parts; + const words = (segment) => + segment.replace(/-/g, ' ').replace(/^\w/, (c) => c.toUpperCase()); + if (SIGN_PREFIXES.includes(head)) { + const body = rest.filter((segment) => !/^g\d+$/.test(segment)); + return `${body.map(words).join(' · ') || words(head)} (${head})`; + } + if (head === 'object' || head === 'marking' || head === 'construction') { + const body = rest.length ? rest : [head]; + return body.map(words).join(' · '); + } + return parts.map(words).join(' · '); +} + +/** Coarse category used for colouring and grouping results. */ +export function categoryOf(value) { + const head = String(value || '').split('--')[0]; + if (SIGN_PREFIXES.includes(head)) return 'sign'; + if (head === 'marking') return 'marking'; + if (head === 'construction') return 'construction'; + return 'object'; +} + +/** + * Stable CSS colour per value, so one class keeps one colour across a query. + * Signs lean warm, objects lean cool; the hue within a category is hashed + * from the value so neighbouring classes differ. + * @param {string} value + * @returns {string} CSS colour. + */ +export function colorForValue(value) { + let hash = 0; + const text = String(value || ''); + for (let i = 0; i < text.length; i++) + hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + const category = categoryOf(text); + // Objects sit in the cyan-blue band so they never blend into the + // Mapillary-green coverage lines; signs lean warm, markings violet. + const base = + category === 'sign' + ? 20 + : category === 'marking' + ? 280 + : category === 'construction' + ? 45 + : 205; + const hue = (base + (hash % 60) - 30 + 360) % 360; + return `hsl(${hue} 90% 60%)`; +} + +/** + * Merge per-value counts whose human labels coincide (regional sign variants + * such as `regulatory--stop--g1` and `--g3`), summing counts and keeping the + * first value's colour. Input and output are sorted by count, descending. + * @param {Iterable<[string, number]>} counts + * @returns {Array<{value: string, values: string[], count: number, label: string, color: string}>} + */ +export function groupCountsByLabel(counts) { + const groups = new Map(); + for (const [value, count] of counts) { + const label = humanizeValue(value); + const group = groups.get(label); + if (group) { + group.count += count; + group.values.push(value); + } else + groups.set(label, { + value, + values: [value], + count, + label, + color: colorForValue(value), + }); + } + return [...groups.values()].sort((a, b) => b.count - a.count); +} diff --git a/src/layers/mapillary/values.test.mjs b/src/layers/mapillary/values.test.mjs new file mode 100644 index 000000000..d2e17c860 --- /dev/null +++ b/src/layers/mapillary/values.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + categoryOf, + groupCountsByLabel, + colorForValue, + createValueMatcher, + humanizeValue, + isSignValue, + normalizeValuePattern, +} from './values.js'; + +test('normalizeValuePattern folds case, spaces and stray characters', () => { + assert.equal( + normalizeValuePattern(' Object--Fire Hydrant '), + 'object--fire-hydrant', + ); + assert.equal( + normalizeValuePattern('regulatory--stop--*'), + 'regulatory--stop--*', + ); + assert.equal(normalizeValuePattern('drop table;'), 'drop-table'); + assert.equal(normalizeValuePattern(null), ''); +}); + +test('createValueMatcher matches exact values and wildcards only', () => { + const matches = createValueMatcher([ + 'object--fire-hydrant', + 'regulatory--stop--*', + ]); + assert.equal(matches('object--fire-hydrant'), true); + assert.equal(matches('regulatory--stop--g1'), true); + assert.equal(matches('regulatory--stop--g3'), true); + assert.equal(matches('regulatory--stop-ahead--g1'), false); + assert.equal(matches('object--bench'), false); + assert.equal(matches(undefined), false); +}); + +test('an empty pattern list matches everything', () => { + const matches = createValueMatcher([]); + assert.equal(matches('anything--at-all'), true); +}); + +test('a wildcard only spans the segment it is placed in', () => { + const matches = createValueMatcher(['object--sign--*']); + assert.equal(matches('object--sign--store'), true); + assert.equal(matches('object--signal'), false); + // Regex metacharacters are stripped by normalization, never interpreted. + assert.equal( + createValueMatcher(['object--sign.*'])('object--sign--store'), + true, + ); +}); + +test('isSignValue and categoryOf split the taxonomy by family', () => { + assert.equal(isSignValue('regulatory--stop--g1'), true); + assert.equal(isSignValue('warning--pedestrians-crossing--g4'), true); + assert.equal(isSignValue('object--fire-hydrant'), false); + assert.equal(categoryOf('object--fire-hydrant'), 'object'); + assert.equal(categoryOf('marking--discrete--stop-line'), 'marking'); + assert.equal(categoryOf('information--general-directions--g1'), 'sign'); +}); + +test('humanizeValue produces readable labels', () => { + assert.equal(humanizeValue('object--fire-hydrant'), 'Fire hydrant'); + assert.equal(humanizeValue('regulatory--stop--g1'), 'Stop (regulatory)'); + assert.equal( + humanizeValue('object--traffic-light--general-upright'), + 'Traffic light · General upright', + ); + assert.equal(humanizeValue(''), 'Unknown'); +}); + +test('colorForValue is stable and category-shifted', () => { + assert.equal(colorForValue('object--bench'), colorForValue('object--bench')); + assert.match(colorForValue('regulatory--stop--g1'), /^hsl\(\d+ 90% 60%\)$/); +}); + +test('groupCountsByLabel merges regional sign variants under one label', () => { + const groups = groupCountsByLabel([ + ['regulatory--stop--g1', 100], + ['object--bench', 30], + ['regulatory--stop--g3', 5], + ]); + assert.deepEqual( + groups.map(({ label, count, values }) => ({ label, count, values })), + [ + { + label: 'Stop (regulatory)', + count: 105, + values: ['regulatory--stop--g1', 'regulatory--stop--g3'], + }, + { label: 'Bench', count: 30, values: ['object--bench'] }, + ], + ); +}); diff --git a/src/layers/mapillary/viewerBridge.js b/src/layers/mapillary/viewerBridge.js new file mode 100644 index 000000000..c28e76143 --- /dev/null +++ b/src/layers/mapillary/viewerBridge.js @@ -0,0 +1,361 @@ +import * as Cesium from 'cesium'; +import { decodeDetectionPolygons } from './decode.js'; + +/** Eye height above the sampled ground when the globe camera follows the viewer. */ +const FOLLOW_EYE_HEIGHT_M = 2.4; + +/** + * Own the embedded MapillaryJS viewer: lazy-load the library, open images, + * mirror its pose onto the globe, and optionally drive the Cesium camera + * from it ("street cockpit"). + */ +export function createViewerBridge({ state, source, parts }) { + const { render } = state.services; + let viewer = null; + let Library = null; + let pendingOpen = null; + let prewarming = false; + /** In-flight viewer construction, so pre-warm and open never build two. */ + let creating = null; + + function requestRender() { + render?.governorRequestRender?.('mapillary-viewer'); + } + + function libraryRenderMode(mode) { + const { RenderMode } = Library || {}; + if (!RenderMode) return undefined; + return mode === 'fill' ? RenderMode.Fill : RenderMode.Letterbox; + } + + async function ensureLibrary() { + if (Library) return Library; + // The viewer stylesheet is vendored into src/ui/styles/mapillary-js.css. + Library = await import('mapillary-js'); + return Library; + } + + function groundHeightAt(lon, lat, fallback) { + const scene = state.viewer?.scene; + const carto = Cesium.Cartographic.fromDegrees(lon, lat); + let height = null; + try { + if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto); + } catch { + /* not sampleable yet */ + } + if (!Number.isFinite(height)) height = scene?.globe?.getHeight?.(carto); + if (!Number.isFinite(height)) height = fallback; + return Number.isFinite(height) ? height : 0; + } + + /** Put the Cesium camera where the street-level camera is. */ + function followCamera() { + const { position, bearing, tilt } = state.street; + if (!state.street.follow || !position || !state.viewer) return; + const ground = groundHeightAt( + position.lon, + position.lat, + state.street.altitude, + ); + state.viewer.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees( + position.lon, + position.lat, + ground + FOLLOW_EYE_HEIGHT_M, + ), + orientation: { + heading: Cesium.Math.toRadians(bearing || 0), + pitch: Cesium.Math.toRadians(Number.isFinite(tilt) ? tilt : 0), + roll: 0, + }, + }); + requestRender(); + } + + /** Frame the current image from a short distance behind it. */ + function lookAtPosition() { + const { position, bearing } = state.street; + if (!position || !state.viewer) return; + const ground = groundHeightAt( + position.lon, + position.lat, + state.street.altitude, + ); + state.viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere( + Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2), + 4, + ), + { + offset: new Cesium.HeadingPitchRange( + Cesium.Math.toRadians(bearing || 0), + Cesium.Math.toRadians(-32), + 140, + ), + duration: 1.6, + }, + ); + } + + async function publishPose(image) { + if (!viewer) return; + try { + const [lngLat, pov] = await Promise.all([ + viewer.getPosition(), + viewer.getPointOfView(), + ]); + state.street.position = { lon: lngLat.lng, lat: lngLat.lat }; + state.street.bearing = pov?.bearing ?? state.street.bearing; + state.street.tilt = pov?.tilt ?? 0; + if (image) { + if (state.street.imageId && state.street.imageId !== image.id) + clearHighlight(); + state.street.imageId = image.id; + state.street.isPano = image.merged + ? image.cameraType === 'spherical' + : image.cameraType === 'spherical'; + state.street.capturedAt = image.capturedAt ?? null; + state.street.sequenceId = image.sequenceId ?? null; + state.street.altitude = Number.isFinite(image.computedAltitude) + ? image.computedAltitude + : Number.isFinite(image.originalAltitude) + ? image.originalAltitude + : null; + state.street.creator = image.creatorUsername || null; + // mapillary.com highlights the selected image's sequence on the map; + // mirror that once the image itself is on screen, so the sequence + // lookup never competes with the image download. + if (!state.street.loading) selectCurrentSequence(); + } + parts.sequences.setMarker(state.street.position, state.street.bearing); + followCamera(); + state.notify?.(); + } catch { + /* viewer torn down mid-flight */ + } + } + + function selectCurrentSequence() { + const { sequenceId } = state.street; + if (sequenceId && state.enabled && sequenceId !== state.sequence.selectedId) + parts.sequences.select(sequenceId); + } + + /** + * Load the library and stand the viewer up ahead of the first image, so + * opening one only costs the image download. Safe to call repeatedly. + */ + async function prewarm(container = state.street.host) { + if (prewarming || !state.enabled) return; + prewarming = true; + try { + await ensureLibrary(); + if (container && state.enabled && !viewer) await ensureViewer(container); + } catch { + /* the real open reports errors */ + } finally { + prewarming = false; + } + } + + function ensureViewer(container) { + if (viewer && state.street.container === container) return viewer; + if (creating?.container === container) return creating.promise; + const promise = createViewer(container).finally(() => { + if (creating?.promise === promise) creating = null; + }); + creating = { container, promise }; + return promise; + } + + async function createViewer(container) { + destroyViewer(); + const { Viewer } = await ensureLibrary(); + viewer = new Viewer({ + accessToken: source.token, + container, + component: { + cover: false, + bearing: true, + zoom: true, + attribution: true, + tag: true, + }, + trackResize: true, + renderMode: libraryRenderMode(state.street.renderMode), + }); + state.street.container = container; + viewer.on('image', (event) => publishPose(event.image)); + viewer.on('pov', () => publishPose(null)); + viewer.on('position', () => publishPose(null)); + return viewer; + } + + /** + * Open an image in the viewer inside `container`. Returns once the first + * image has loaded; the pose then streams through `state.street`. + */ + async function open(imageId, container, { frame = true } = {}) { + if (!imageId || !container) return; + const id = String(imageId); + state.street.loading = true; + state.street.error = null; + state.street.open = true; + state.notify?.(); + pendingOpen = id; + try { + const instance = await ensureViewer(container); + if (pendingOpen !== id) return; + const image = await instance.moveTo(id); + if (pendingOpen !== id) return; + await publishPose(image); + if (frame && !state.street.follow) lookAtPosition(); + } catch (error) { + if (pendingOpen === id) + state.street.error = error?.message || 'Image could not be opened'; + } finally { + if (pendingOpen === id) state.street.loading = false; + state.notify?.(); + } + if (pendingOpen === id && !state.street.error) selectCurrentSequence(); + } + + function clearHighlight() { + if (!state.street.highlight) return; + state.street.highlight = null; + try { + viewer?.getComponent('tag')?.removeAll(); + } catch { + /* component not ready */ + } + } + + /** + * Outline every detection of `value` inside the current image using the + * MapillaryJS tag component. Detections are per image on the Graph API; + * their geometry is a small vector tile in image-normalized coordinates. + */ + async function highlightDetections(imageId, value, label) { + if (!viewer || !imageId) return 0; + const { OutlineTag, PolygonGeometry } = Library || {}; + let component; + try { + component = viewer.getComponent('tag'); + component.removeAll(); + } catch { + return 0; + } + state.street.highlight = { value, count: 0, loading: true }; + state.notify?.(); + let detections = []; + try { + detections = await source.getImageDetections(imageId); + } catch { + detections = []; + } + if (pendingOpen !== String(imageId) || !viewer) return 0; + const tags = []; + for (const detection of detections) { + if (value && detection.value !== value) continue; + decodeDetectionPolygons(detection.geometry).forEach((polygon, index) => { + try { + tags.push( + new OutlineTag( + `mly-det-${detection.id}-${index}`, + new PolygonGeometry(polygon), + { + lineColor: 0x00d4ff, + lineWidth: 3, + fillColor: 0x00d4ff, + fillOpacity: 0.22, + text: tags.length === 0 ? label || undefined : undefined, + textColor: 0xffffff, + }, + ), + ); + } catch { + /* degenerate polygon */ + } + }); + } + if (tags.length) component.add(tags); + state.street.highlight = { value, count: tags.length, loading: false }; + state.notify?.(); + return tags.length; + } + + /** Switch between showing the whole image ('letterbox') and cropping ('fill'). */ + function setRenderMode(mode) { + state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox'; + try { + const value = libraryRenderMode(state.street.renderMode); + if (viewer && value !== undefined) viewer.setRenderMode(value); + } catch { + /* viewer not ready */ + } + state.notify?.(); + } + + function setFollow(enabled) { + state.street.follow = enabled === true; + if (state.street.follow) followCamera(); + state.notify?.(); + } + + function resize() { + try { + viewer?.resize(); + } catch { + /* no-op */ + } + } + + function destroyViewer() { + if (viewer) { + try { + viewer.remove(); + } catch { + /* already removed */ + } + } + viewer = null; + state.street.container = null; + } + + /** Close the image. The viewer instance is kept warm for the next open. */ + function close() { + pendingOpen = null; + clearHighlight(); + Object.assign(state.street, { + open: false, + follow: false, + imageId: null, + sequenceId: null, + position: null, + bearing: null, + tilt: null, + loading: false, + error: null, + highlight: null, + feature: null, + }); + parts.sequences.setMarker(null); + state.notify?.(); + } + + return { + open, + close, + setFollow, + setRenderMode, + resize, + prewarm, + lookAtPosition, + highlightDetections, + destroy() { + close(); + destroyViewer(); + }, + }; +} diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index f0bff95cc..2a4cc63f6 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -13,6 +13,7 @@ import { createSatelliteSource } from '../layers/satellites/source.js'; import { createLaunchSource } from '../layers/launches/source.js'; import { createOverpassAlprSource } from '../layers/alpr/source.js'; import { createFirmsSource } from '../layers/firms/source.js'; +import { createMapillarySource } from '../layers/mapillary/source.js'; import { createReferenceSources } from '../sources/reference.js'; export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js'; @@ -35,5 +36,8 @@ export function createStandaloneLayerSources() { launches: createLaunchSource(), alpr: createOverpassAlprSource(), firms: createFirmsSource(), + mapillary: createMapillarySource({ + token: import.meta.env?.MAPILLARY_CLIENT_TOKEN || '', + }), }; } diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index c5d07b098..8408b2137 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -28,7 +28,7 @@ const PANEL_GROUPS = [ }, { label: 'Cameras', - ids: ['cctv', 'alpr-cameras'], + ids: ['cctv', 'mapillary', 'alpr-cameras'], }, { label: 'Infrastructure', @@ -58,6 +58,7 @@ const PANEL_LABELS = { 'ais-live-vessels': 'Live Vessels', bikeshare: 'Bike Share', cctv: 'Cameras', + mapillary: 'Street Level (Mapillary)', 'alpr-cameras': 'Mapped ALPR Cameras', 'local-datacenters': 'Data Centers', 'local-firms': 'Active Fires', diff --git a/src/ui/mapillaryControls.js b/src/ui/mapillaryControls.js new file mode 100644 index 000000000..dabda83ac --- /dev/null +++ b/src/ui/mapillaryControls.js @@ -0,0 +1,586 @@ +import { makeFloating } from './floatingWindow.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 ''; + } +} + +/** + * 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. + */ +export class MapillaryControls { + constructor({ root, mapillary, actions }) { + this.root = root; + this.mapillary = mapillary; + this.actions = actions; + this.destroyed = false; + this.listeners = new AbortController(); + this._unsubscribe = null; + this._state = null; + this._lastStage = 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'), + enableBtn: byId('mly-enable-btn'), + minimizeBtn: byId('mly-minimize-btn'), + minimized: byId('mly-minimized'), + minimizedBadge: byId('mly-minimized-badge'), + form: byId('mly-query-form'), + input: byId('mly-query-input'), + runBtn: byId('mly-query-run'), + answer: byId('mly-answer'), + 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'), + 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'), + 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'), + }; + } + + listen(target, type, handler, options = {}) { + target?.addEventListener(type, handler, { + ...options, + signal: this.listeners.signal, + }); + } + + _bind() { + 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.enableBtn, 'click', () => this._toggleEnabled()); + this.listen(el.form, 'submit', (event) => { + event.preventDefault(); + this._run(el.input?.value); + }); + for (const button of this.root.querySelectorAll('[data-mly-suggestion]')) { + this.listen(button, 'click', () => { + if (el.input) el.input.value = button.dataset.mlySuggestion || ''; + this._run(button.dataset.mlySuggestion); + }); + } + 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); + }); + for (const button of this.root.querySelectorAll('[data-mly-pano]')) { + this.listen(button, 'click', () => + this.mapillary.setCoverageFilter?.({ pano: button.dataset.mlyPano }), + ); + } + this.listen(el.sinceSelect, 'change', () => { + const value = String(el.sinceSelect.value || '0'); + let sinceMs = null; + if (value.startsWith('year:')) + sinceMs = Date.UTC(Number(value.slice(5)), 0, 1); + else if (Number(value) > 0) + 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); + }); + // 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()); + } + + async _ensureEnabled() { + if (this.actions.isEnabled?.()) return true; + try { + await this.actions.setEnabled?.(true); + } catch (error) { + this.actions.showToast?.( + error?.message || 'Street Level could not start', + ); + return false; + } + return this.actions.isEnabled?.() === true; + } + + async _toggleEnabled() { + const enabled = this.actions.isEnabled?.() === true; + try { + await this.actions.setEnabled?.(!enabled); + } 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); + } + + connect() { + this._unsubscribe?.(); + this._unsubscribe = null; + if (this.destroyed || !this.root) return; + this._unsubscribe = this.mapillary.subscribe?.((state) => + this.render(state), + ); + 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 }); + 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'; + } + + /** 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'); + this._wrapHome.parent.insertBefore(wrap, this._wrapHome.next); + this._wrapHome = null; + } + } + const button = this._elements.viewerExpand; + if (button) { + const icon = button.querySelector('.mly-btn-icon'); + const text = button.querySelector('.mly-btn-text'); + if (icon) icon.textContent = on ? '⤡' : '⤢'; + if (text) text.textContent = on ? 'SHRINK' : 'EXPAND'; + button.setAttribute('aria-pressed', String(on)); + button.setAttribute('aria-label', on ? 'Shrink' : 'Expand'); + } + 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', + ); + if (!collapsed) this.mapillary.resizeViewer?.(); + if (persist) { + try { + localStorage.setItem(DOCK_STORAGE_KEY, collapsed ? '1' : '0'); + } catch { + /* storage unavailable */ + } + } + } + + render(state) { + if (this.destroyed || !state || !this.root) return; + this._state = state; + 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'; + } + 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.'; + } + + // Imagery filters. + const filter = state.coverage.filter || { pano: 'all', sinceMs: null }; + for (const button of this.root.querySelectorAll('[data-mly-pano]')) { + const active = button.dataset.mlyPano === 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 (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'; + } + 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?.(); + } + + // 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(' · '); + + if (state.query.stage !== this._lastStage) { + this._lastStage = state.query.stage; + if (state.query.stage === 'done' || state.query.stage === 'error') { + this.setCollapsed(false); + if (this.isMinimized()) this.setMinimized(false); + } + } + } + + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.setViewerExpanded(false); + 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/shellElements.js b/src/ui/shellElements.js index a666dcc88..065380dfb 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -44,6 +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'), _radioPanel: document.getElementById('radio-panel'), _contextRadioDock: document.getElementById('context-radio-dock'), _contextRadioToggleBtn: document.getElementById('context-radio-toggle-btn'), diff --git a/src/ui/styles/mapillary-js.css b/src/ui/styles/mapillary-js.css new file mode 100644 index 000000000..d38be7cc3 --- /dev/null +++ b/src/ui/styles/mapillary-js.css @@ -0,0 +1,1635 @@ +/* Vendored from mapillary-js@4.1.2 dist/mapillary.css (MIT, Copyright Mapillary). + Loaded up front so the embedded street-level viewer has its chrome the + moment it opens. Regenerate by copying the file from node_modules. */ +.mapillary-attribution-container { + background-color: rgba(0, 0, 0, 0.5); + font-size: 9px; + font-weight: 300; + position: absolute; + bottom: 0; + right: 0; + height: 16px; + line-height: initial; + z-index: 10; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", + "Helvetica Neue", sans-serif; +} + +.mapillary-attribution-image-container { + display: inline-block; + color: white; + text-decoration: none; + margin-left: 8px; + vertical-align: middle; + height: 100%; +} + +.mapillary-attribution-image-container:hover, +.mapillary-attribution-image-container:active, +.mapillary-attribution-image-container:visited { + color: white; +} + +.mapillary-attribution-compact .mapillary-attribution-image-container { + margin-left: 4px; +} + +.mapillary-attribution-username { + display: inline-block; + vertical-align: middle; + line-height: 16px; +} + +.mapillary-attribution-date { + display: inline-block; + vertical-align: middle; + color: white; + line-height: 16px; + margin-right: 8px; + margin-left: 4px; +} + +.mapillary-attribution-compact .mapillary-attribution-date { + margin-right: 6px; +} + +.mapillary-attribution-icon-container { + display: inline-block; + margin-left: 8px; + vertical-align: middle; + height: 100%; +} + +.mapillary-attribution-compact .mapillary-attribution-icon-container { + margin-left: 6px; +} + +.mapillary-attribution-image-container .mapillary-attribution-icon-container { + margin-left: 2px; + margin-right: 2px; +} + +.mapillary-attribution-logo { + margin-top: 3px; + width: 44px; + height: 10px; + background-size: contain; + background-repeat: no-repeat; + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2MSIgaGVpZ2h0PSIxNCIgdmlld0JveD0iMCAwIDYxIDE0Ij48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPiAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTIzLjk5NDgzMDMgMi44MTQ1MjI4NUwyMy45OTQ4MzAzIDExLjMxMzg1MyAyMi4zNzA4NjM3IDExLjMxMzg1MyAyMi4zNzA4NjM3IDYuODkyMjM2MTQgMjAuNTQzOTAxMiAxMS4zMTM4NTMgMTkuNjYwMjcyNCAxMS4zMTM4NTMgMTcuODMzMzA5OSA2Ljg5MjIzNjE0IDE3LjgzMzMwOTkgMTEuMzEzODUzIDE2LjIwOTM0MzMgMTEuMzEzODUzIDE2LjIwOTM0MzMgMi44MTQ1MjI4NSAxNy44ODEwNzM3IDIuODE0NTIyODUgMjAuMTAyMDg2OCA4LjE2OTU5MjExIDIyLjMyMzEgMi44MTQ1MjI4NSAyMy45OTQ4MzAzIDIuODE0NTIyODUgMjMuOTk0ODMwMyAyLjgxNDUyMjg1ek0zMS4wODE5NTg0IDQuNjY5MTQ1NDZMMzIuNTAyOTI5MiA0LjY2OTE0NTQ2IDMyLjUwMjkyOTIgNS4yMjE4NDc1NkMzMi41OTg0NTcyIDUuMDI1MzMwMjggMzIuNzg5NTEwMSA0Ljg1NzQ3NDI4IDMzLjA3NjA5MzkgNC43MTgyNzQ1MyAzMy4zNjI2Nzc3IDQuNTc5MDc0NzkgMzMuNjc3MTE5MSA0LjUwOTQ3NTk2IDM0LjAxOTQyNzUgNC41MDk0NzU5NiAzNC4zNDU4MTQ1IDQuNTA5NDc1OTYgMzQuNjUyMjk1NCA0LjU4NTIxNTg2IDM0LjkzODg3OTEgNC43MzY2OTc5NCAzNS4yMjU0NjI5IDQuODg4MTgwMDEgMzUuNDc0MjI5OCA1LjExMTMwNTY3IDM1LjY4NTE4NzMgNS40MDYwODE2IDM1Ljg5NjE0NDggNS43MDA4NTc1MiAzNi4wNjMzMTYyIDYuMDY1MjI3ODYgMzYuMTg2NzA2NCA2LjQ5OTIwMzUzIDM2LjMxMDA5NjYgNi45MzMxNzkyIDM2LjM3MTc5MDggNy40Mjg1NTkxIDM2LjM3MTc5MDggNy45ODUzNTgwNyAzNi4zNzE3OTA4IDguNTU4NTMzNDkgMzYuMzEyMDg2OCA5LjA2MjEwMTQ4IDM2LjE5MjY3NjkgOS40OTYwNzcxNSAzNi4wNzMyNjcgOS45MzAwNTI4MiAzNS45MTAwNzU5IDEwLjI5NDQyMzIgMzUuNzAzMDk4NyAxMC41ODkxOTkxIDM1LjQ5NjEyMTUgMTAuODgzOTc1IDM1LjI1MTMzNDkgMTEuMTA1MDUzNiAzNC45Njg3MzE1IDExLjI1MjQ0MTYgMzQuNjg2MTI4IDExLjM5OTgyOTYgMzQuMzc3NjU3MSAxMS40NzM1MjI1IDM0LjA0MzMwOTMgMTEuNDczNTIyNSAzMy42ODUwNzk2IDExLjQ3MzUyMjUgMzMuMzk2NTEgMTEuNDE2MjA1OCAzMy4xNzc1OTE4IDExLjMwMTU3MDcgMzIuOTU4NjczNyAxMS4xODY5MzU2IDMyLjgwMTQ1MyAxMS4wNDM2NDM5IDMyLjcwNTkyNTEgMTAuODcxNjkxM0wzMi43MDU5MjUxIDEzLjUyMDU0MjMgMzEuMDgxOTU4NCAxMy44Nzg3ODc2IDMxLjA4MTk1ODQgNC42NjkxNDU0NiAzMS4wODE5NTg0IDQuNjY5MTQ1NDZ6TTMzLjYzNzMxNzcgOS44ODkxMDk3NkMzNC40MjU0MjMgOS44ODkxMDk3NiAzNC44MTk0Njk4IDkuMjU4NjI2MjYgMzQuODE5NDY5OCA3Ljk5NzY0MDM0IDM0LjgxOTQ2OTggNy4zODM1MjM4MiAzNC43MjE5NTMyIDYuOTE2ODAyMjcgMzQuNTI2OTE3IDYuNTk3NDYxNjggMzQuMzMxODgwOCA2LjI3ODEyMTA5IDM0LjA1MTI3MTggNi4xMTg0NTMxOSAzMy42ODUwODE0IDYuMTE4NDUzMTkgMzMuNDE0NDE4OSA2LjExODQ1MzE5IDMzLjE4OTUzMzYgNi4yMTQ2NjMzNCAzMy4wMTA0MTg4IDYuNDA3MDg2NTEgMzIuODMxMzAzOSA2LjU5OTUwOTY5IDMyLjY5Mzk4NDYgNi44MjY3MjkzOSAzMi41OTg0NTY3IDcuMDg4NzUyNDRMMzIuNTk4NDU2NyA4Ljk4MDIyMTg2QzMyLjcwOTkwNTkgOS4yODMxODYgMzIuODU5MTY2MSA5LjUxMDQwNTcxIDMzLjA0NjI0MTYgOS42NjE4ODc3OCAzMy4yMzMzMTcxIDkuODEzMzY5ODYgMzMuNDMwMzQwNSA5Ljg4OTEwOTc2IDMzLjYzNzMxNzcgOS44ODkxMDk3NkwzMy42MzczMTc3IDkuODg5MTA5NzYgMzMuNjM3MzE3NyA5Ljg4OTEwOTc2ek0zOC43MTIyMTMzIDQuNjY5MTQ1NDZMMzguNzEyMjEzMyAxMS4zMTM4NTMgMzcuMDc2MzA1OCAxMS4zMTM4NTMgMzcuMDc2MzA1OCA0LjY2OTE0NTQ2IDM4LjcxMjIxMzMgNC42NjkxNDU0NiAzOC43MTIyMTMzIDQuNjY5MTQ1NDZ6TTM3LjA3NjMwNTggMy4xOTUyNzMxOUMzNy4wNzYzMDU4IDIuOTU3ODE0OCAzNy4xNTE5MzA5IDIuNzUzMTEyMzYgMzcuMzAzMTgzNSAyLjU4MTE1OTc0IDM3LjQ1NDQzNiAyLjQwOTIwNzExIDM3LjY0OTQ2OTMgMi4zMjMyMzIwOSAzNy44ODgyODkxIDIuMzIzMjMyMDkgMzguMTExMTg3NiAyLjMyMzIzMjA5IDM4LjMwNDIzMDcgMi40MDkyMDcxMSAzOC40Njc0MjQyIDIuNTgxMTU5NzQgMzguNjMwNjE3OCAyLjc1MzExMjM2IDM4LjcxMjIxMzMgMi45NTc4MTQ4IDM4LjcxMjIxMzMgMy4xOTUyNzMxOSAzOC43MTIyMTMzIDMuNDI0NTQzMzUgMzguNjMwNjE3OCAzLjYxOTAxMDY3IDM4LjQ2NzQyNDIgMy43Nzg2ODA5NiAzOC4zMDQyMzA3IDMuOTM4MzUxMjYgMzguMTExMTg3NiA0LjAxODE4NTIxIDM3Ljg4ODI4OTEgNC4wMTgxODUyMSAzNy42NDk0NjkzIDQuMDE4MTg1MjEgMzcuNDU0NDM2IDMuOTM4MzUxMjYgMzcuMzAzMTgzNSAzLjc3ODY4MDk2IDM3LjE1MTkzMDkgMy42MTkwMTA2NyAzNy4wNzYzMDU4IDMuNDI0NTQzMzUgMzcuMDc2MzA1OCAzLjE5NTI3MzE5TDM3LjA3NjMwNTggMy4xOTUyNzMxOSAzNy4wNzYzMDU4IDMuMTk1MjczMTl6TTM5LjU2MDAxOTQgMi45MjUwNjMyN0w0MS4xODM5ODYgMi4zNjAwNzg5IDQxLjE4Mzk4NiAxMS4zMTM4NTMgMzkuNTYwMDE5NCAxMS4zMTM4NTMgMzkuNTYwMDE5NCAyLjkyNTA2MzI3IDM5LjU2MDAxOTQgMi45MjUwNjMyN3pNNDIuMDMxNzkyMSAyLjkyNTA2MzI3TDQzLjY1NTc1ODcgMi4zNjAwNzg5IDQzLjY1NTc1ODcgMTEuMzEzODUzIDQyLjAzMTc5MjEgMTEuMzEzODUzIDQyLjAzMTc5MjEgMi45MjUwNjMyNyA0Mi4wMzE3OTIxIDIuOTI1MDYzMjd6TTUyLjQ2Nzg0OTEgNC42NjkxNDU0Nkw1Mi40Njc4NDkxIDUuMTIzNTg5NDFDNTIuNTc5Mjk4MyA0LjkzNTI2MDM0IDUyLjc2MjM5MDggNC43ODU4Mjc1NyA1My4wMTcxMzE5IDQuNjc1Mjg2NTkgNTMuMjcxODczMSA0LjU2NDc0NTYyIDUzLjUzODU1MTIgNC41MDk0NzU5NiA1My44MTcxNzQzIDQuNTA5NDc1OTYgNTQuMDAwMjY5NSA0LjUwOTQ3NTk2IDU0LjE3NzM5MTUgNC41MzgxMzQzIDU0LjM0ODU0NTcgNC41OTU0NTE4NSA1NC41MTk2OTk5IDQuNjUyNzY5MzkgNTQuNjUzMDM5IDQuNzQ2OTMyNTEgNTQuNzQ4NTY2OSA0Ljg3Nzk0NDAzTDU0LjE2MzQ2MTMgNi4zNjQwOTg1N0M1My45NjQ0NDQ4IDYuMTgzOTU3NzMgNTMuNzQ1NTI5OSA2LjA5Mzg4ODY2IDUzLjUwNjcxMDEgNi4wOTM4ODg2NiA1My4zMDc2OTM2IDYuMDkzODg4NjYgNTMuMTI2NTkxMyA2LjE2OTYyODU2IDUyLjk2MzM5NzcgNi4zMjExMTA2MyA1Mi44MDAyMDQyIDYuNDcyNTkyNzEgNTIuNjY2ODY1MSA2LjcwODAwMDUxIDUyLjU2MzM3NjUgNy4wMjczNDExTDUyLjU2MzM3NjUgMTEuMzEzODUzIDUwLjkzOTQwOTkgMTEuMzEzODUzIDUwLjkzOTQwOTkgNC42NjkxNDU0NiA1Mi40Njc4NDkxIDQuNjY5MTQ1NDYgNTIuNDY3ODQ5MSA0LjY2OTE0NTQ2ek00OC40NjE2MDg4IDEwLjgyOTMxOTJDNDguMDI4ODg4MiAxMS4yMTU4NzI0IDQ3LjUyNDc0MDUgMTEuNDg0MTExIDQ2Ljk4NjY2NzQgMTEuNDg0MTExIDQ1LjEyMzA5NDEgMTEuNDg0MTExIDQ0LjM0MTk0NzMgOS45MjgwNTIxIDQ0LjM0MTk0NzMgOC4wMDg1NTYxNiA0NC4zNDE5NDczIDYuMDg5MDYwMjMgNDUuMTIzMDk0MSA0LjUzMzAwMTMzIDQ2Ljk4NjY2NzQgNC41MzMwMDEzMyA0Ny41NjU5MDA3IDQuNTMzMDAxMzMgNDguMDU4MTgzNSA0Ljc4MzA1OTI5IDQ4LjQ2MTYwODggNS4xNTg1MTc4Mkw0OC40NjE2MDg4IDQuNjIzMDg3MTcgNTAuMTE2NTEzOSA0LjYyMzA4NzE3IDUwLjExNjUxMzkgMTEuMzk0MDI1MiA0OC40NjE2MDg4IDExLjM5NDAyNTIgNDguNDYxNjA4OCAxMC44MjkzMTkyIDQ4LjQ2MTYwODggMTAuODI5MzE5MnpNNDguNDYxNjA4OCA5LjE1MTIyMDIxQzQ4LjI1NDM5MjQgOS42MTI3NDY0MyA0Ny44ODgwNjAxIDkuOTAwOTc4NTIgNDcuMzU4NTk4MyA5LjkxMDM5ODE0IDQ2LjM1NzM2OTUgOS45MjgyMTA5MyA0NS45NzMyNTE2IDkuMDgwNTg1NDcgNDUuOTU1MjA2MSA4LjAyNjg3Nzc4IDQ1LjkzNzE2MDcgNi45NzMxNzAwOSA0Ni4yOTIwMjEyIDYuMTEyMzk3NzMgNDcuMjkzMjUgNi4wOTQ1ODUwNiA0Ny44MDk0MTU2IDYuMDg1NDAyMDUgNDguMjI3OTIyMiA2LjQyMTYyMDk0IDQ4LjQ2MTYwODggNi45NjI1MjM2TDQ4LjQ2MTYwODggOS4xNTEyMjAyMSA0OC40NjE2MDg4IDkuMTUxMjIwMjF6TTI4Ljc4NjM2ODkgMTAuODI5MzE5MkMyOC4zNTM2NDg0IDExLjIxNTg3MjQgMjcuODQ5NTAwNyAxMS40ODQxMTEgMjcuMzExNDI3NiAxMS40ODQxMTEgMjUuNDQ3ODU0MiAxMS40ODQxMTEgMjQuNjY2NzA3NSA5LjkyODA1MjEgMjQuNjY2NzA3NSA4LjAwODU1NjE2IDI0LjY2NjcwNzUgNi4wODkwNjAyMyAyNS40NDc4NTQyIDQuNTMzMDAxMzMgMjcuMzExNDI3NiA0LjUzMzAwMTMzIDI3Ljg5MDY2MDkgNC41MzMwMDEzMyAyOC4zODI5NDM2IDQuNzgzMDU5MjkgMjguNzg2MzY4OSA1LjE1ODUxNzgyTDI4Ljc4NjM2ODkgNC42MjMwODcxNyAzMC40NDEyNzQgNC42MjMwODcxNyAzMC40NDEyNzQgMTEuMzk0MDI1MiAyOC43ODYzNjg5IDExLjM5NDAyNTIgMjguNzg2MzY4OSAxMC44MjkzMTkyIDI4Ljc4NjM2ODkgMTAuODI5MzE5MiAyOC43ODYzNjg5IDEwLjgyOTMxOTJ6TTI4Ljc4NjM2ODkgOS4xNTEyMjAyMUMyOC41NzkxNTI2IDkuNjEyNzQ2NDMgMjguMjEyODIwMiA5LjkwMDk3ODUyIDI3LjY4MzM1ODQgOS45MTAzOTgxNCAyNi42ODIxMjk2IDkuOTI4MjEwOTMgMjYuMjk4MDExNyA5LjA4MDU4NTQ3IDI2LjI3OTk2NjMgOC4wMjY4Nzc3OCAyNi4yNjE5MjA4IDYuOTczMTcwMDkgMjYuNjE2NzgxMyA2LjExMjM5NzczIDI3LjYxODAxMDEgNi4wOTQ1ODUwNiAyOC4xMzQxNzU3IDYuMDg1NDAyMDUgMjguNTUyNjgyNCA2LjQyMTYyMDk0IDI4Ljc4NjM2ODkgNi45NjI1MjM2TDI4Ljc4NjM2ODkgOS4xNTEyMjAyMSAyOC43ODYzNjg5IDkuMTUxMjIwMjEgMjguNzg2MzY4OSA5LjE1MTIyMDIxek01Ny43NDU1ODIzIDEzLjY3MjA0ODZMNTYuMDQ5OTcwMSAxMy42NzIwNDg2IDU3LjA2NDk0OTIgMTAuNzM2NTg2MyA1NC45Mzk0NjM1IDQuNjY5MTQ1NDYgNTYuNzMwNjAzMSA0LjY2OTE0NTQ2IDU3LjkzNjYzNzIgOC41MjU3Nzc5MSA1OS4xNDI2NzEyIDQuNjY5MTQ1NDYgNjAuODk3OTg4IDQuNjY5MTQ1NDYgNTcuNzQ1NTgyMyAxMy42NzIwNDg2IDU3Ljc0NTU4MjMgMTMuNjcyMDQ4NnpNNi4zODE1NDI3NCA5LjY1MDUwNjdDNi4xNDAwNzQxMiA5LjUyNzg2NDA0IDYuMDgxMjQwMDYgOS4zMTcwOTQ1MyA2LjIxMDQ0MDM3IDkuMDg4NjU1NjUgNi4yNzYxNDk0MSA4Ljk3MjQ3NTYxIDYuNDE4NTkyMzQgOC43NDA3NDQyMiA2LjUzNDY4MTY4IDguNTUzOTgyMiA2LjY5NDMyNzI0IDguMjk3MTQ3ODYgNi45MjcwMTQzNCA4LjI5NjM3NTg4IDcuMDU4MzU4NjYgOC4zNzI1MzM2MiA3LjE4OTcwMjk4IDguNDQ4NjkxMzUgOS4wNDg1MzYwNCA5LjQyMDg3MDYzIDkuMTY4NzQ4NzEgOS40ODY3MDY1NiA5LjQ1MjU5MjYgOS42NDIxNTcxMSA5Ljg3MTE4NjI5IDkuMjg2MjM3NDEgOS42ODM0MjI1MSA4Ljk3NDQxMzI2IDkuNTkzNzUzMzIgOC44MjU0OTczMSA4LjgxMDM5MzggNy40MDQ0NzgzIDguNTYxNDk3OCA2LjkzNjEwNDM4IDguNDQ4MjUxNTYgNi43MjI5OTY5NiA4LjQ3OTIxMDA1IDYuNTMxMzIyNTEgOC43MzE2NDcgNi40MDM3NzQ2MyA4Ljg1NjU2NDM2IDYuMzQwNjU4MSA5LjA4MDA0MjM5IDYuMjA4OTA3OTggOS4yNzM5NDc2MiA2LjA5MTkwNTg2IDkuNDcxODkyMTkgNS45NzI0NjY0IDkuNzM1MzQ4OCA2LjA2MjIzODg1IDkuODQyNDU1NTUgNi4yNDAyMjYyMSA5Ljk0OTU2MjI5IDYuNDE4MjEzNTcgMTMuMTIwOTE4MiAxMi4yMjk0MDE5IDEzLjIyMjE1MTcgMTIuNDA5ODE2OSAxMy40MjMxMjk0IDEyLjc2Nzk5MjggMTMuMDMyMjg1MyAxMy4xNTExMTM3IDEyLjY2Mjk0OSAxMi45NTMwOTQgMTIuNDc4MTQzNiAxMi44NTQwMTA1IDYuNjIzMDExMzYgOS43NzMxNDkzNiA2LjM4MTU0Mjc0IDkuNjUwNTA2N3pNLjIwMDE0NjA2MiA2LjQwMDgxMjA1Qy0uMTExNTU0MjgyIDYuMjMyNzQxMDYtLjA1NDU0NDIzMjggNS43OTYwNDA1Mi4zMzg3ODI2NDggNS42ODQ2NjAzNi42NzIwMzAwODMgNS41OTAyOTMxNyAzLjM3ODIwNjQ3IDQuNjg4MjYzNCA0LjE5NzE1MTE1IDQuNDE0OTAwNyA0LjM0NDc5NDQzIDQuMzY1NjE3NTcgNC40NjAwNTgwOSA0LjI1MjI4MjMyIDQuNTA5NjU3NTcgNC4xMDc1ODY3IDQuNzg4NTg4NTMgMy4yOTM4NjY3MSA1LjcyMDI0NTU2LjU3NTk2MTMzNCA1LjgyNDY4OTExLjI3MTI2OTc5IDUuOTQ3NzA0OC0uMDg3NjAxOTc5IDYuMzkzMDQwNzctLjA4ODkxODQ4NjcgNi41NzQ1NDY3MS4yNTg1MDIyNTEgNi42MzkwMTU2Ni4zODE5MDIzNDYgOS4wODk0MDQ4NCA0Ljc5ODEzNTQzIDkuMTc0NjU5NDYgNC45ODU0NzA1MSA5LjI1OTkxNDA4IDUuMTcyODA1NTkgOS4yMDQ5ODczMSA1LjM4MDMxNDM0IDkuMDExNzY0MDYgNS41MDAwMDUxNSA4LjgxODU0MDggNS42MTk2OTU5NiA4LjUwMzQzNTIyIDUuNzk4NzIwNjUgOC4zOTM2NTA3IDUuODY2NTQzMjMgOC4xOTQ4OTQ2MyA1Ljk4OTMzMDU3IDguMDA0MzkxMzUgNS45MDM4NDMyNCA3LjkxMzY1NTAxIDUuNzEzNjQ4MTYgNy44MjI5MTg2NiA1LjUyMzQ1MzA3IDcuMTIzODc4MjggNC4yOTYyNjg4NiA2LjgzODgyOTUyIDMuNzc1Mjc3OTUgNi42OTYzNDk0MSAzLjUxNDg2MzQxIDYuMjY3MTEzMiAzLjQxNDkyNzk2IDYuMTIyODM3MjEgMy44MzU4MjE1OSA2LjAxODQ4NDg4IDQuMTQwMjQ2NjkgNS43NzUyNjI2NCA0Ljg0OTc5NDM5IDUuNjQ4NjQ1NTcgNS4yMTkxNzIgNS42MDAyMDM3NSA1LjM2MDQ5MDQgNS40Nzc0MTk0NCA1LjQ4MjEyNTU0IDUuMzMzOTgxMjQgNS41MjkzNzAwMSA0Ljk3ODAxNjMgNS42NDY2MTQ3OCA0LjMwMTYyOTQ0IDUuODY5Mzk3NDQgMy45MDY3MjM3NSA1Ljk5OTQ2ODE4IDMuNjI0OTE0NjkgNi4wOTIyODgxIDMuNDg3OTM1MjMgNi41MjE1MDI1NyAzLjg2ODQxNzc2IDYuNzAyMjQ2NzQgMy45NDc4OTUyNSA2Ljc0MDAwMTY3IDUuNjYwOTcxNTMgNy42NTQwNDM5MSA1LjgzNzcyMDU0IDcuNzM3NjQ1NTggNi4wMTQ0Njk1NCA3LjgyMTI0NzI0IDYuMTEyNzUyODEgOC4wNjI1Nzk0NSA2LjAwOTI1MzE2IDguMjMzNTUwMDQgNS44NjgwODA0IDguNDY2NzUyNjggNS42ODczNDc5NiA4Ljc2MjcyMDUyIDUuNjIxNzg2MiA4Ljg1ODgwODE1IDUuNTA4MTU4MzggOS4wMjUzNDE2MyA1LjI4NDk1MDc3IDkuMDgwODAyODYgNS4wOTc0ODk5NiA4Ljk4MjUxMDc2IDQuOTEwMDI5MTUgOC44ODQyMTg2Ni4zMTE1MjIyODkgNi40NjA4NjY4OS4yMDAxNDYwNjIgNi40MDA4MTIwNXoiLz48L2c+ICA8L3N2Zz4gIA=="); +} + +.mapillary-attribution-compact .mapillary-attribution-logo { + width: 11px; + background-size: cover; +} +.mapillary-bearing-indicator-container { + bottom: 34px; + height: 40px; + pointer-events: none; + position: absolute; + right: 10px; + width: 40px; + z-index: 10; +} + +.mapillary-bearing-indicator-container.mapillary-bearing-compact { + -webkit-transform: scale(0.6); + transform: scale(0.6); + bottom: 16px; + right: 2px; +} + +.mapillary-bearing-indicator-background { + position: absolute; + top: 0; + left: 0; + width: 40px; + height: 40px; + opacity: 0.5; + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; +} + +.mapillary-bearing-indicator-background-circle { + pointer-events: auto; + position: absolute; + top: 0; + left: 0; + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #000; +} + +.mapillary-bearing-indicator-background-arrow-container { + position: absolute; + left: 14px; + top: -3.5px; + height: 8px; + width: 12px; + overflow: hidden; +} + +.mapillary-bearing-indicator-background-arrow { + position: absolute; + width: 12px; + height: 12px; + left: 0; + top: 2px; + background-color: #000; + -webkit-transform: rotate(45deg); + transform: rotate(45deg); + border-radius: 2px; +} + +.mapillary-bearing-north-container { + position: absolute; + left: 16px; + top: -1px; + width: 8px; + height: 21px; + -webkit-transform-origin: 50% 100%; + transform-origin: 50% 100%; +} + +.mapillary-bearing-north { + position: absolute; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 4px solid white; +} +.mapillary-cover-container { + background-color: rgba(0, 0, 0, 0.5); + bottom: 0; + left: 0; + overflow: hidden; + position: absolute; + right: 0; + top: 0; + z-index: 100; +} + +.mapillary-cover-background { + background-position: center; + background-size: cover; + bottom: 0; + left: 0; + margin: -3px; + position: absolute; + right: 0; + top: 0; + z-index: 0; +} + +.mapillary-cover-indicator { + bottom: 0; + left: 0; + margin: -3px; + position: absolute; + right: 0; + top: 0; + z-index: 0; + background-color: rgba(0, 0, 0, 0.2); + -webkit-transition: opacity 0.1s ease-in-out; + transition: opacity 0.1s ease-in-out; + cursor: pointer; +} + +.mapillary-cover-indicator:hover { + opacity: 0; +} + +.mapillary-cover-button { + background-color: rgba(0, 0, 0, 0.5); + border-radius: 64px; + width: 128px; + height: 128px; + left: 50%; + top: 50%; + letter-spacing: 1px; + margin-left: -64px; + margin-top: -64px; + position: absolute; + -webkit-transition: opacity 0.2s ease-in-out; + transition: opacity 0.2s ease-in-out; + z-index: 1; + pointer-events: none; +} + +.mapillary-cover-compact .mapillary-cover-button { + width: 72px; + height: 72px; + margin-left: -36px; + margin-top: -36px; +} + +.mapillary-cover-button-icon { + background-size: contain; + background-repeat: no-repeat; + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiB2aWV3Qm94PSIwIDAgMTI4IDEyOCI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4gIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik01Ny43NDc4MjExLDU2LjkzMTc4NDUgTDQwLjkxNDUzODEsNjQuNjM0MjkyIEMzOC40NjQ1MzU5LDY1Ljc1NDY0MzIgMzQuNzg1NzMyMiw2NS43NTMzNDM1IDMyLjc2MzkxMDMsNjQuNjMyOTkyMyBDMzAuNzczNzU4Niw2My41Mjk1MzczIDMxLjE4ODAwNCw2MS43Nzg4MjYxIDMzLjYyNTMzODIsNjAuNzE5NTYxMyBMNjAuMjgzOTk3MSw0OS4xNTIxMzAyIEM2Mi4yODU1NTAxLDQ4LjI4MjYyMzMgNjUuNTAwNzAzLDQ4LjI4MjYyMzMgNjcuNTE2MTkwOCw0OS4xNTIxMzAyIEw5NC4zNDIwNjgyLDYwLjcyNjA1OTkgQzk2Ljc5NDYwNCw2MS43ODQwMjUgOTcuMjM0MTg1Niw2My41MzYwMzU5IDk1LjI2MDUwMjMsNjQuNjM5NDkwOCBDOTMuMjU1MTQ4OSw2NS43NTk4NDIgODkuNTczODExNSw2NS43NTk4NDIgODcuMTA4NjA3Nyw2NC42MzgxOTExIEw3MC42MjEzMjk1LDU3LjE0MDMxMjIgTDcxLjc5NzkzNTgsNzcuMzkyODQ3MiBDNzEuODk1MjAzMSw3OS4wODYzOTEyIDY4LjU0MjcyMzIsODAuNSA2NC4zNDU5NjMsODAuNSBDNjAuMTQ5MjAyOSw4MC41IDU2Ljc3NzI2OTUsNzkuMDg2MzkxMiA1Ni44NTExOTI2LDc3LjM5Mjg0NzIgTDU3Ljc0NzgyMTEsNTYuOTMxNzg0NSBaIi8+PC9nPiAgPC9zdmc+ICA="); + height: 100%; + width: 100%; + position: absolute; + pointer-events: none; +} + +.mapillary-cover-compact .mapillary-cover-button-icon { + width: 72px; + height: 72px; +} + +.mapillary-cover-logo { + background-size: contain; + background-repeat: no-repeat; + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNTciIGhlaWdodD0iNTIiIHZpZXdCb3g9IjAgMCAxNTcgNTIiPjxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+ICA8cGF0aCBmaWxsPSIjMDAwIiBmaWxsLW9wYWNpdHk9Ii41IiBkPSJNODMuMjMwNCAxOS4wNjg2TDgzLjIzMDQgMjAuMjk1NkM4Mi44NDQ0IDE5LjkxNDYgODIuNDIzNCAxOS41ODI2IDgxLjk2MTQgMTkuMzQwNiA4MS4xMTI0IDE4Ljg5NTYgODAuMTkyNCAxOC42NzE2IDc5LjIyODQgMTguNjcxNiA3OC41MzQ0IDE4LjY3MTYgNzcuODY4NCAxOC43NzM2IDc3LjIzNjQgMTguOTgwNkw3NC4yMDI0IDE4Ljk4MDYgNjcuNDg3NCAxOC45Nzg2QzY3LjAyNzQgMTguODYzNiA2Ni41NDk0IDE4LjgwMzYgNjYuMDU2NCAxOC44MDM2IDY0LjIwMzQgMTguODAzNiA2Mi42OTU0IDE5LjM4MjYgNjEuNTQ0NCAyMC4zOTA2TDYxLjU0NDQgMTUuNDU3NiA1NC45MzA0IDE1LjQ1NzYgNTEuOTAwNCAyMi43MDE2IDQ4Ljg3MDQgMTUuNDU3NiA0Mi4yNTY0IDE1LjQ1NzYgNDIuMjU2NCAzNi4wMDY2IDYxLjU0NDQgMzYuMDA2NiA2MS41NDQ0IDM0Ljc1MTZDNjIuNjk1NCAzNS43NTg2IDY0LjIwMzQgMzYuMzM4NiA2Ni4wNTY0IDM2LjMzODYgNjYuNTIwNCAzNi4zMzg2IDY2Ljk3NDQgMzYuMjc3NiA2Ny40MTc0IDM2LjE2MjZMNzEuNDYwNCAzNi4xNjI2IDcxLjQ2MDQgNDEuNDg1NiA3OC42NDk0IDM5LjkxMzYgNzguNjQ5NCAzNi4yOTM2Qzc4Ljg1MjQgMzYuMzA5NiA3OS4wNjA0IDM2LjMxNzYgNzkuMjc1NCAzNi4zMTc2IDgwLjI0NzQgMzYuMzE3NiA4MS4xNjg0IDM2LjA5OTYgODIuMDEwNCAzNS42NjM2IDgyLjQ1MzQgMzUuNDM0NiA4Mi44NTk0IDM1LjE0NzYgODMuMjMwNCAzNC44MDk2TDgzLjIzMDQgMzYuMDA2NiA4OC4xMDg0IDM2LjAwNjYgOTAuNDQzNCAzNi4wMDY2IDkyLjk2MTQgMzYuMDA2NiA5NS4yOTY0IDM2LjAwNjYgMTAwLjE1MDQgMzYuMDA2NiAxMDAuMTUwNCAzNC43MjU2QzEwMS4zMDU0IDM1Ljc0OTYgMTAyLjgyMTQgMzYuMzM4NiAxMDQuNjkwNCAzNi4zMzg2IDEwNS4xNTQ0IDM2LjMzODYgMTA1LjYwODQgMzYuMjc3NiAxMDYuMDUxNCAzNi4xNjI2TDExMi44MzY0IDM2LjE2MjYgMTE3LjY0MTQgMzYuMTYxNiAxMTcuNjQxNCAyNi4wMzE2IDExOS41ODM0IDI3Ljc3NDYgMTIwLjA5NjQgMjYuNDc4NiAxMjIuMzYwNCAzMi44ODY2IDExOS42NzE0IDQwLjU5NzYgMTI3LjIzMTQgNDAuNTk3NiAxMzQuODM0NCAxOS4wNjg2IDEyNy4wOTM0IDE5LjA2ODYgMTI2LjE5MjQgMjEuOTI3NiAxMjUuMjkwNCAxOS4wNjg2IDExOS45NTk0IDE5LjA2ODZDMTE5Ljg5ODQgMTkuMDQ0NiAxMTkuODQwNCAxOS4wMTE2IDExOS43NzY0IDE4Ljk5MDYgMTE5LjIzNjQgMTguODExNiAxMTguNjc1NCAxOC42NzE2IDExOC4xMDI0IDE4LjY3MTYgMTE3LjQ0ODQgMTguNjcxNiAxMTYuODEwNCAxOC43NzM2IDExNi4xOTc0IDE4Ljk4MDZMMTEyLjgzNjQgMTguOTgwNiAxMDYuMTIxNCAxOC45Nzg2QzEwNS42NjE0IDE4Ljg2MzYgMTA1LjE4MzQgMTguODAzNiAxMDQuNjkwNCAxOC44MDM2IDEwMi44MjE0IDE4LjgwMzYgMTAxLjMwNTQgMTkuMzkyNiAxMDAuMTUwNCAyMC40MTY2TDEwMC4xNTA0IDEzLjc2NjYgOTUuMjk2NCAxNS40NDE2IDk1LjI5NjQgMTMuNzY2NiA4OS41MTg0IDE1Ljc1OTZDODkuNDc4NCAxNS43MTQ2IDg5LjQ0OTQgMTUuNjY0NiA4OS40MDc0IDE1LjYyMDYgODguNzE1NCAxNC44OTc2IDg3LjgxNzQgMTQuNTAxNiA4Ni44MjU0IDE0LjUwMTYgODUuNzkyNCAxNC41MDE2IDg0Ljg2MjQgMTQuOTA3NiA4NC4xODA0IDE1LjY3NTYgODMuNTU1NCAxNi4zODA2IDgzLjIzMDQgMTcuMjUyNiA4My4yMzA0IDE4LjE5ODZMODMuMjMwNCAxOS4wNjg2ek0zOS44ODkxIDM1LjM0NUMzOS44NTAxIDM1LjI3NiAzOS41MDExIDM0LjY0NCAzOC44MDIxIDMzLjM3NSAzOC44NTMxIDMzLjQ2OCAzNy4xMTExIDMwLjMwNyAzNi41NTgxIDI5LjMwNiAzNC4zNDkxIDI1LjMwMSAzMy4yOTIxIDIzLjM4OCAzMy4xNzYxIDIzLjE5NyAzMi45MzUxIDIyLjc5OSAzMi41OTUxIDIyLjQ4OSAzMi4yMDkxIDIyLjI1OCAzMi4yMzMxIDIxLjgyIDMyLjE2MTEgMjEuMzcgMzEuOTY0MSAyMC45NDEgMzEuODU1MSAyMC43MDIgMzEuMDg0MSAxOS4zMSAyOS4zMjQxIDE2LjE1MSAyOS4yNDIxIDE2LjAwMyAyNi44MDUxIDExLjYzMiAyNi43NzUxIDExLjU3NSAyNS42MzMxIDkuNDA4IDIyLjQ3NDEgOS40NDYgMjEuNjMzMSAxMS44NzlMMjEuMjE3MSAxMy4wODIgMjAuNDYyMSAxNS4yNjYgMTkuMjAxMSAxOC45MTVDMTUuNjQ5MSAyMC4wODkgMTIuNTg3MSAyMS4wOTMgMTIuMTM2MSAyMS4yMiA5LjcxMzEgMjEuOTAxIDkuMTY3MSAyNS4wODcgMTEuNDU5MSAyNi4zMTMgMTEuNjU1MSAyNi40MTggMjAuNjMzMSAzMS4xMSAyMS4xNjIxIDMxLjM4NSAyMS42MTgxIDMxLjYyMiAyMi4xMTIxIDMxLjcyMSAyMi41OTUxIDMxLjY5OSAyMi44NjAxIDMyLjEwNiAyMy4yMzIxIDMyLjQ1OSAyMy43MjQxIDMyLjcwNiAyNC4xMTgxIDMyLjkwNSAzNS42MzAxIDM4LjkwOCAzNi4xMDQxIDM5LjE2IDM4LjU2MjEgNDAuNDY4IDQxLjI5NDEgMzcuODI3IDM5Ljg4OTEgMzUuMzQ1Ii8+ICA8cGF0aCBmaWxsPSIjRkZGIiBkPSJNNTkuNTQ0NCAxNy40NTczTDU5LjU0NDQgMzQuMDA3MyA1Ni4zNTU0IDM0LjAwNzMgNTYuMzU1NCAyNS4zOTczIDUyLjc2ODQgMzQuMDA3MyA1MS4wMzM0IDM0LjAwNzMgNDcuNDQ1NCAyNS4zOTczIDQ3LjQ0NTQgMzQuMDA3MyA0NC4yNTY0IDM0LjAwNzMgNDQuMjU2NCAxNy40NTczIDQ3LjUzOTQgMTcuNDU3MyA1MS45MDA0IDI3Ljg4NDMgNTYuMjYxNCAxNy40NTczIDU5LjU0NDQgMTcuNDU3M3pNNzMuNDYxNCAyMS4wNjgzTDc2LjI1MTQgMjEuMDY4MyA3Ni4yNTE0IDIyLjE0NDNDNzYuNDM4NCAyMS43NjIzIDc2LjgxMzQgMjEuNDM2MyA3Ny4zNzY0IDIxLjE2NDMgNzcuOTM4NCAyMC44OTQzIDc4LjU1NjQgMjAuNzU4MyA3OS4yMjg0IDIwLjc1ODMgNzkuODY5NCAyMC43NTgzIDgwLjQ3MDQgMjAuOTA1MyA4MS4wMzQ0IDIxLjIwMDMgODEuNTk2NCAyMS40OTUzIDgyLjA4NTQgMjEuOTMwMyA4Mi40OTk0IDIyLjUwMzMgODIuOTEzNCAyMy4wNzczIDgzLjI0MjQgMjMuNzg3MyA4My40ODQ0IDI0LjYzMjMgODMuNzI2NCAyNS40NzczIDgzLjg0NzQgMjYuNDQxMyA4My44NDc0IDI3LjUyNTMgODMuODQ3NCAyOC42NDIzIDgzLjczMDQgMjkuNjIyMyA4My40OTY0IDMwLjQ2NzMgODMuMjYxNCAzMS4zMTIzIDgyLjk0MDQgMzIuMDIxMyA4Mi41MzQ0IDMyLjU5NjMgODIuMTI4NCAzMy4xNzAzIDgxLjY0NzQgMzMuNjAwMyA4MS4wOTI0IDMzLjg4NzMgODAuNTM4NCAzNC4xNzQzIDc5LjkzMTQgMzQuMzE3MyA3OS4yNzU0IDM0LjMxNzMgNzguNTcyNCAzNC4zMTczIDc4LjAwNDQgMzQuMjA2MyA3Ny41NzU0IDMzLjk4MjMgNzcuMTQ1NCAzMy43NjAzIDc2LjgzNjQgMzMuNDgwMyA3Ni42NDk0IDMzLjE0NTNMNzYuNjQ5NCAzOC4zMDMzIDczLjQ2MTQgMzkuMDAxMyA3My40NjE0IDIxLjA2ODN6TTc4LjQ3ODQgMzEuMjMyM0M4MC4wMjU0IDMxLjIzMjMgODAuNzk5NCAzMC4wMDUzIDgwLjc5OTQgMjcuNTUwMyA4MC43OTk0IDI2LjM1MzMgODAuNjA4NCAyNS40NDUzIDgwLjIyNDQgMjQuODIzMyA3OS44NDE0IDI0LjIwMTMgNzkuMjkxNCAyMy44OTEzIDc4LjU3MjQgMjMuODkxMyA3OC4wNDA0IDIzLjg5MTMgNzcuNTk4NCAyNC4wNzgzIDc3LjI0NzQgMjQuNDUyMyA3Ni44OTU0IDI0LjgyNzMgNzYuNjI2NCAyNS4yNjkzIDc2LjQzODQgMjUuNzc5M0w3Ni40Mzg0IDI5LjQ2MzNDNzYuNjU3NCAzMC4wNTMzIDc2Ljk1MDQgMzAuNDk1MyA3Ny4zMTc0IDMwLjc5MDMgNzcuNjg0NCAzMS4wODUzIDc4LjA3MTQgMzEuMjMyMyA3OC40Nzg0IDMxLjIzMjN6TTg1LjIzMDQgMzQuMDA3M0w4OC40NDM0IDM0LjAwNzMgODguNDQzNCAyMS4wNjkzIDg1LjIzMDQgMjEuMDY5MyA4NS4yMzA0IDM0LjAwNzN6TTg1LjIzMDQgMTguMTk4M0M4NS4yMzA0IDE3LjczNjMgODUuMzc5NCAxNy4zMzgzIDg1LjY3NjQgMTcuMDAzMyA4NS45NzM0IDE2LjY2ODMgODYuMzU2NCAxNi41MDEzIDg2LjgyNTQgMTYuNTAxMyA4Ny4yNjI0IDE2LjUwMTMgODcuNjQxNCAxNi42NjgzIDg3Ljk2MjQgMTcuMDAzMyA4OC4yODM0IDE3LjMzODMgODguNDQzNCAxNy43MzYzIDg4LjQ0MzQgMTguMTk4MyA4OC40NDM0IDE4LjY0NTMgODguMjgzNCAxOS4wMjMzIDg3Ljk2MjQgMTkuMzM1MyA4Ny42NDE0IDE5LjY0NTMgODcuMjYyNCAxOS44MDEzIDg2LjgyNTQgMTkuODAxMyA4Ni4zNTY0IDE5LjgwMTMgODUuOTczNCAxOS42NDUzIDg1LjY3NjQgMTkuMzM1MyA4NS4zNzk0IDE5LjAyMzMgODUuMjMwNCAxOC42NDUzIDg1LjIzMDQgMTguMTk4M3pNOTAuMTA3NCAxNy42NzMzTDkzLjI5NjQgMTYuNTcyMyA5My4yOTY0IDM0LjAwNzMgOTAuMTA3NCAzNC4wMDczIDkwLjEwNzQgMTcuNjczM3pNOTQuOTYxNCAxNy42NzMzTDk4LjE1MDQgMTYuNTcyMyA5OC4xNTA0IDM0LjAwNzMgOTQuOTYxNCAzNC4wMDczIDk0Ljk2MTQgMTcuNjczM3pNMTE1LjQ1MzQgMjEuMDY4M0wxMTUuNDUzNCAyMS45NTMzQzExNS42NzE0IDIxLjU4NzMgMTE2LjAzMjQgMjEuMjk2MyAxMTYuNTMyNCAyMS4wODAzIDExNy4wMzI0IDIwLjg2NTMgMTE3LjU1NTQgMjAuNzU4MyAxMTguMTAzNCAyMC43NTgzIDExOC40NjI0IDIwLjc1ODMgMTE4LjgxMDQgMjAuODEzMyAxMTkuMTQ2NCAyMC45MjUzIDExOS40ODI0IDIxLjAzNjMgMTE5Ljc0NDQgMjEuMjIwMyAxMTkuOTMxNCAyMS40NzUzTDExOC43ODM0IDI0LjM2OTNDMTE4LjM5MjQgMjQuMDE4MyAxMTcuOTYxNCAyMy44NDMzIDExNy40OTM0IDIzLjg0MzMgMTE3LjEwMjQgMjMuODQzMyAxMTYuNzQ3NCAyMy45OTAzIDExNi40MjY0IDI0LjI4NTMgMTE2LjEwNjQgMjQuNTgwMyAxMTUuODQ0NCAyNS4wMzgzIDExNS42NDE0IDI1LjY2MDNMMTE1LjY0MTQgMzQuMDA3MyAxMTIuNDUyNCAzNC4wMDczIDExMi40NTI0IDIxLjA2ODMgMTE1LjQ1MzQgMjEuMDY4M3pNMTA3LjU4NjQgMzMuMDYzM0MxMDYuNzM3NCAzMy44MTUzIDEwNS43NDc0IDM0LjMzODMgMTA0LjY5MDQgMzQuMzM4MyAxMDEuMDMxNCAzNC4zMzgzIDk5LjQ5ODQgMzEuMzA5MyA5OS40OTg0IDI3LjU3MDMgOTkuNDk4NCAyMy44MzMzIDEwMS4wMzE0IDIwLjgwNDMgMTA0LjY5MDQgMjAuODA0MyAxMDUuODI4NCAyMC44MDQzIDEwNi43OTU0IDIxLjI5MDMgMTA3LjU4NjQgMjIuMDIxM0wxMDcuNTg2NCAyMC45NzgzIDExMC44MzY0IDIwLjk3ODMgMTEwLjgzNjQgMzQuMTYyMyAxMDcuNTg2NCAzNC4xNjIzIDEwNy41ODY0IDMzLjA2MzN6TTEwNy41ODY0IDI5Ljc5NjNDMTA3LjE3OTQgMzAuNjk0MyAxMDYuNDYxNCAzMS4yNTYzIDEwNS40MjA0IDMxLjI3MzMgMTAzLjQ1NTQgMzEuMzA5MyAxMDIuNzAxNCAyOS42NTgzIDEwMi42NjU0IDI3LjYwNjMgMTAyLjYyOTQgMjUuNTU1MyAxMDMuMzI3NCAyMy44NzkzIDEwNS4yOTM0IDIzLjg0NDMgMTA2LjMwNjQgMjMuODI2MyAxMDcuMTI4NCAyNC40ODAzIDEwNy41ODY0IDI1LjUzNDNMMTA3LjU4NjQgMjkuNzk2M3pNNjguOTUzNCAzMy4wNjMzQzY4LjEwMzQgMzMuODE1MyA2Ny4xMTM0IDM0LjMzODMgNjYuMDU2NCAzNC4zMzgzIDYyLjM5NzQgMzQuMzM4MyA2MC44NjM0IDMxLjMwOTMgNjAuODYzNCAyNy41NzAzIDYwLjg2MzQgMjMuODMzMyA2Mi4zOTc0IDIwLjgwNDMgNjYuMDU2NCAyMC44MDQzIDY3LjE5NDQgMjAuODA0MyA2OC4xNjE0IDIxLjI5MDMgNjguOTUzNCAyMi4wMjEzTDY4Ljk1MzQgMjAuOTc4MyA3Mi4yMDI0IDIwLjk3ODMgNzIuMjAyNCAzNC4xNjIzIDY4Ljk1MzQgMzQuMTYyMyA2OC45NTM0IDMzLjA2MzN6TTY4Ljk1MzQgMjkuNzk2M0M2OC41NDU0IDMwLjY5NDMgNjcuODI2NCAzMS4yNTYzIDY2Ljc4NzQgMzEuMjczMyA2NC44MjE0IDMxLjMwOTMgNjQuMDY2NCAyOS42NTgzIDY0LjAzMTQgMjcuNjA2MyA2My45OTY0IDI1LjU1NTMgNjQuNjkyNCAyMy44NzkzIDY2LjY1ODQgMjMuODQ0MyA2Ny42NzI0IDIzLjgyNjMgNjguNDk0NCAyNC40ODAzIDY4Ljk1MzQgMjUuNTM0M0w2OC45NTM0IDI5Ljc5NjN6TTEyNS44MTY0IDM4LjU5OTNMMTIyLjQ4NzQgMzguNTk5MyAxMjQuNDgwNCAzMi44ODMzIDEyMC4zMDY0IDIxLjA2ODMgMTIzLjgyNDQgMjEuMDY4MyAxMjYuMTkyNCAyOC41NzgzIDEyOC41NjA0IDIxLjA2ODMgMTMyLjAwNjQgMjEuMDY4MyAxMjUuODE2NCAzOC41OTkzek0yNC42MjQgMzAuOTIwMUMyNC4xNDYgMzAuNjgwMSAyNC4wMyAzMC4yNjcxIDI0LjI4NiAyOS44MTgxIDI0LjQxNiAyOS41OTExIDI0LjY5NyAyOS4xMzcxIDI0LjkyNyAyOC43NzExIDI1LjI0MiAyOC4yNjcxIDI1LjcwMyAyOC4yNjYxIDI1Ljk2MiAyOC40MTUxIDI2LjIyMiAyOC41NjUxIDI5Ljg5NyAzMC40NzAxIDMwLjEzNSAzMC41OTkxIDMwLjY5NiAzMC45MDMxIDMxLjUyMyAzMC4yMDYxIDMxLjE1MiAyOS41OTUxIDMwLjk3NSAyOS4zMDMxIDI5LjQyNiAyNi41MTcxIDI4LjkzNCAyNS41OTkxIDI4LjcxIDI1LjE4MTEgMjguNzcxIDI0LjgwNTEgMjkuMjcgMjQuNTU1MSAyOS41MTggMjQuNDMyMSAyOS45NTkgMjQuMTczMSAzMC4zNDMgMjMuOTQzMSAzMC43MzQgMjMuNzA5MSAzMS4yNTUgMjMuODg2MSAzMS40NjcgMjQuMjM0MSAzMS42NzggMjQuNTgzMSAzNy45NDkgMzUuOTc2MSAzOC4xNDkgMzYuMzI5MSAzOC41NDYgMzcuMDMyMSAzNy43NzMgMzcuNzgzMSAzNy4wNDMgMzcuMzk1MSAzNi42NzggMzcuMjAwMSAyNS4xMDIgMzEuMTYwMSAyNC42MjQgMzAuOTIwMU0xMi40MDIgMjQuNTQ5MUMxMS43ODYgMjQuMjIwMSAxMS44OTkgMjMuMzYzMSAxMi42NzcgMjMuMTQ2MSAxMy4zMzUgMjIuOTYwMSAxOC42ODYgMjEuMTkxMSAyMC4zMDUgMjAuNjU2MSAyMC41OTcgMjAuNTYwMSAyMC44MjUgMjAuMzM3MSAyMC45MjMgMjAuMDU0MSAyMS40NzUgMTguNDU4MSAyMy4zMTYgMTMuMTMwMSAyMy41MjMgMTIuNTMyMSAyMy43NjYgMTEuODI5MSAyNC42NDYgMTEuODI2MSAyNS4wMDUgMTIuNTA4MSAyNS4xMzMgMTIuNzQ5MSAyOS45NzggMjEuNDA3MSAzMC4xNDYgMjEuNzc0MSAzMC4zMTUgMjIuMTQyMSAzMC4yMDYgMjIuNTQ5MSAyOS44MjQgMjIuNzgzMSAyOS40NDIgMjMuMDE4MSAyOC44MTkgMjMuMzY5MSAyOC42MDIgMjMuNTAyMSAyOC4yMDkgMjMuNzQyMSAyNy44MzIgMjMuNTc1MSAyNy42NTMgMjMuMjAyMSAyNy40NzQgMjIuODI5MSAyNi4wOTIgMjAuNDI0MSAyNS41MjggMTkuNDAyMSAyNS4yNDcgMTguODkyMSAyNC4zOTggMTguNjk1MSAyNC4xMTIgMTkuNTIxMSAyMy45MDYgMjAuMTE3MSAyMy40MjUgMjEuNTA5MSAyMy4xNzUgMjIuMjMyMSAyMy4wNzkgMjIuNTEwMSAyMi44MzYgMjIuNzQ4MSAyMi41NTMgMjIuODQxMSAyMS44NDkgMjMuMDcwMSAyMC41MTIgMjMuNTA4MSAxOS43MzEgMjMuNzYzMSAxOS4xNzQgMjMuOTQ0MSAxOC45MDMgMjQuNzg2MSAxOS42NTUgMjUuMTQxMSAxOS44MTIgMjUuMjE0MSAyMy4xOTkgMjcuMDA2MSAyMy41NDkgMjcuMTcwMSAyMy44OTggMjcuMzM0MSAyNC4wOTMgMjcuODA3MSAyMy44ODggMjguMTQzMSAyMy42MDkgMjguNjAwMSAyMy4yNTEgMjkuMTgwMSAyMy4xMjIgMjkuMzY4MSAyMi44OTcgMjkuNjk0MSAyMi40NTYgMjkuODAzMSAyMi4wODUgMjkuNjEwMSAyMS43MTQgMjkuNDE4MSAxMi42MjMgMjQuNjY3MSAxMi40MDIgMjQuNTQ5MSIvPjwvZz4gIDwvc3ZnPiAg"); + bottom: 0px; + height: 52px; + left: 50%; + margin-left: -73px; + position: absolute; + -webkit-transition: opacity 0.2s ease-in-out; + transition: opacity 0.2s ease-in-out; + width: 157px; + z-index: 1; +} + +.mapillary-cover-compact .mapillary-cover-logo { + width: 104px; + height: 35px; + margin-left: -49px; +} + +.mapillary-cover-done { + visibility: hidden; + opacity: 0; +} + +.mapillary-cover.mapillary-cover-loading .mapillary-cover-button, +.mapillary-cover.mapillary-cover-loading .mapillary-cover-logo { + opacity: 0; +} + +.mapillary-cover-spinner { + -webkit-animation: rotate 1s linear infinite; + animation: rotate 1s linear infinite; + background-repeat: no-repeat; + background-size: 100%; + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4NCIgaGVpZ2h0PSI4NCIgdmlld0JveD0iMCAwIDg0IDg0Ij48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPiAgPHBhdGggZD0iTTQyLDg0IEM2NS4xOTYsODQgODQsNjUuMTk2IDg0LDQyIEM4NCwxOC44MDQgNjUuMTk2LDAgNDIsMCBDMTguODA0LDAgMCwxOC44MDQgMCw0MiBDMCw2NS4xOTYgMTguODA0LDg0IDQyLDg0Ii8+ICA8cGF0aCBmaWxsPSIjRkZGIiBmaWxsLXJ1bGU9Im5vbnplcm8iIGQ9Ik00Miw4My45OTg1MTA0IEM0MC4zNDMxNDU4LDgzLjk5ODUxMDQgMzksODIuNjU1MzY0NiAzOSw4MC45OTg1MTA0IEMzOSw3OS4zNDE2NTYxIDQwLjM0MzE0NTgsNzcuOTk4NTEwNCA0Miw3Ny45OTg1MTA0IEM2MS44ODIyODg2LDc3Ljk5ODUxMDQgNzgsNjEuODgwNzk5IDc4LDQxLjk5ODUxMDQgQzc4LDIyLjIzNDkyOTQgNjIuMDY2MzMxNiw2LjE3MDg3ODA2IDQyLjMxODc2ODIsNS45OTk4ODk5NCBDNDAuNjYxOTc2LDUuOTg1NTQ0MjggMzkuMzMwNTEwMSw0LjYzMDgxOTQyIDM5LjM0NDg1NTgsMi45NzQwMjcyOCBDMzkuMzU5MjAxNCwxLjMxNzIzNTE0IDQwLjcxMzkyNjMsLTAuMDE0MjMwODE0NiA0Mi4zNzA3MTg0LDAuMDAwMTE0ODQyOTUyIEM2NS40MTA5MTU3LDAuMTk5NjEyODggODQsMTguOTQwODA5MSA4NCw0MS45OTg1MTA0IEM4NCw2NS4xOTQ1MDc1IDY1LjE5NTk5NzEsODMuOTk4NTEwNCA0Miw4My45OTg1MTA0IFoiLz48L2c+ICA8L3N2Zz4gIA=="); + height: 32px; + left: 50%; + margin-left: -16px; + margin-top: -16px; + position: absolute; + top: 50%; + width: 32px; + z-index: 1; +} + +@-webkit-keyframes rotate { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +@keyframes rotate { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +.mapillary-direction-perspective { + display: block; + position: absolute; + pointer-events: none; + -webkit-transform-style: preserve-3d; + transform-style: preserve-3d; + z-index: 10; +} + +.mapillary-direction-circle, +.mapillary-direction-circle-sequence, +.mapillary-direction-circle-inactive, +.mapillary-direction-circle-highlight, +.mapillary-direction-circle-sequence-highlight { + position: absolute; + border-radius: 50%; +} + +.mapillary-direction-circle, +.mapillary-direction-circle-sequence, +.mapillary-direction-circle-highlight, +.mapillary-direction-circle-sequence-highlight { + pointer-events: auto; +} + +.mapillary-direction-circle:hover, +.mapillary-direction-circle-sequence:hover, +.mapillary-direction-circle-highlight:hover, +.mapillary-direction-circle-sequence-highlight:hover { + cursor: pointer; +} + +.mapillary-direction-circle, +.mapillary-direction-circle-sequence { + background: rgba(0, 0, 0, 0); +} + +.mapillary-direction-circle-inactive { + background: rgba(0, 0, 0, 0); + z-index: -1; +} + +.mapillary-direction-circle-highlight, +.mapillary-direction-circle-sequence-highlight { + background: rgba(0, 0, 0, 0.4); +} + +.mapillary-direction-arrow-step, +.mapillary-direction-arrow-spherical, +.mapillary-direction-arrow-inactive { + position: absolute; + top: 44%; + left: 50%; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); + width: 75%; + height: 44.0775%; + pointer-events: none; +} + +.mapillary-direction-arrow-step, +.mapillary-direction-arrow-inactive { + background: url("data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgNTguNzciPgogICAgPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTk3LjQ0LDQzLjgyTDU2LjE4LDIuNTZhOC43NSw4Ljc1LDAsMCwwLTEyLjM3LDBoMEwyLjU2LDQzLjhBOC43NSw4Ljc1LDAsMCwwLDE0Ljk0LDU2LjE4bDM1LTM1TDg1LjA1LDU2LjJBOC43NSw4Ljc1LDAsMSwwLDk3LjQ0LDQzLjgyaDBaIi8+Cjwvc3ZnPg=="); + background-repeat: no-repeat; +} + +.mapillary-direction-arrow-inactive { + opacity: 0.2; + z-index: -1; +} + +.mapillary-direction-arrow-spherical { + background: url("data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgNTguNzciPgogICAgPHBhdGggZmlsbD0iI0Y1REVCMyIgZD0iTTk3LjQ0LDQzLjgyTDU2LjE4LDIuNTZhOC43NSw4Ljc1LDAsMCwwLTEyLjM3LDBoMEwyLjU2LDQzLjhBOC43NSw4Ljc1LDAsMCwwLDE0Ljk0LDU2LjE4bDM1LTM1TDg1LjA1LDU2LjJBOC43NSw4Ljc1LDAsMSwwLDk3LjQ0LDQzLjgyaDBaIi8+Cjwvc3ZnPg=="); + background-repeat: no-repeat; +} + +.mapillary-direction-turn-circle, +.mapillary-direction-turn-circle-sequence, +.mapillary-direction-turn-circle-highlight, +.mapillary-direction-turn-circle-sequence-highlight { + position: absolute; + border-radius: 50%; + pointer-events: auto; +} + +.mapillary-direction-turn-circle, +.mapillary-direction-turn-circle-sequence { + background: rgba(0, 0, 0, 0); +} + +.mapillary-direction-turn-circle-highlight, +.mapillary-direction-turn-circle-sequence-highlight { + background: rgba(0, 0, 0, 0.4); +} + +.mapillary-direction-turn-circle:hover, +.mapillary-direction-turn-circle-sequence:hover, +.mapillary-direction-turn-circle-highlight:hover, +.mapillary-direction-turn-circle-sequence-highlight:hover { + cursor: pointer; +} + +.mapillary-direction-turn-left, +.mapillary-direction-turn-right, +.mapillary-direction-turn-around { + position: absolute; + top: 50%; + pointer-events: none; + -webkit-filter: drop-shadow(0px 2px 1px rgba(0, 0, 0, 0.8)); + filter: drop-shadow(0px 2px 1px rgba(0, 0, 0, 0.8)); +} + +.mapillary-direction-turn-left, +.mapillary-direction-turn-right { + width: 58.794%; + height: 70%; +} + +.mapillary-direction-turn-around { + left: 50%; + width: 75%; + height: 63.2475%; +} + +.mapillary-direction-turn-left { + left: 45%; + background: url("data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTE5LjA2Ij4KICAgIDxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik05OS4yLDc2Qzk4LDY0LjkxLDk1LjA2LDU2LjYsODkuNTcsNTAsNzksMzcuMzgsNjAuMywzMi43MiwyNC40MiwzMi4zOUw0NC44OSwxMS45M0E3LDcsMCwwLDAsMzUsMmgwbC0zMywzM2E3LDcsMCwwLDAsMCw5Ljg4TDM1LDc3Ljg1YTcsNywwLDEsMCw5LjktOS45TDI0Ljc5LDQ3Ljg5QzU2LDQ4LjE4LDcxLDUxLjkzLDc3LjcyLDYwYzMuMjEsMy44OCw1LjE4LDkuNDIsNi4xMSwxNy43NywwLjg1LDcuNTkuODcsMTYuMjIsMC4zNCwzMy4zNmE3Ljc0LDcuNzQsMCwxLDAsMTUuNDYuNDhDMTAwLjE5LDkzLjcyLDEwMC4xNiw4NC42LDk5LjIsNzZaIi8+Cjwvc3ZnPg=="); + background-repeat: no-repeat; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +.mapillary-direction-turn-right { + left: 55%; + background: url("data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTE5LjA2Ij4KICAgIDxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik05OS4yLDc2Qzk4LDY0LjkxLDk1LjA2LDU2LjYsODkuNTcsNTAsNzksMzcuMzgsNjAuMywzMi43MiwyNC40MiwzMi4zOUw0NC44OSwxMS45M0E3LDcsMCwwLDAsMzUsMmgwbC0zMywzM2E3LDcsMCwwLDAsMCw5Ljg4TDM1LDc3Ljg1YTcsNywwLDEsMCw5LjktOS45TDI0Ljc5LDQ3Ljg5QzU2LDQ4LjE4LDcxLDUxLjkzLDc3LjcyLDYwYzMuMjEsMy44OCw1LjE4LDkuNDIsNi4xMSwxNy43NywwLjg1LDcuNTkuODcsMTYuMjIsMC4zNCwzMy4zNmE3Ljc0LDcuNzQsMCwxLDAsMTUuNDYuNDhDMTAwLjE5LDkzLjcyLDEwMC4xNiw4NC42LDk5LjIsNzZaIi8+Cjwvc3ZnPg=="); + background-repeat: no-repeat; + -webkit-transform: translate(-50%, -50%) scaleX(-1); + transform: translate(-50%, -50%) scaleX(-1); +} + +.mapillary-direction-turn-around { + background: url("data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgODQuMzMiPgogICAgPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTY1LjQ3LDBDNTYsMCw0Ni44Niw1LjM5LDQwLjIxLDE2LjA3LDMzLjQ3LDI3LDI5LjM2LDQzLjE4LDI4LjQzLDY1TDEwLDQ2LjU5YTUuODcsNS44NywwLDAsMC04LjMsOC4zTDI5LjQ0LDgyLjYxYTUuODgsNS44OCwwLDAsMCw4LjMsMEw2NS40Nyw1NWE1Ljg4LDUuODgsMCwxLDAtOC4zMi04LjMyTDM5LjMyLDY0LjQ3YzAuOTItMTkuNTYsNC41LTMzLjcsMTAuMTEtNDIuNzIsNC43Mi03LjYyLDEwLjQ1LTExLDE2LjEtMTFhMTYuNjQsMTYuNjQsMCwwLDEsMTQuNzgsOEM4NiwyNyw4OS4yMSw0MS44NSw4OS4yMSw2NC40N2E1LjM5LDUuMzksMCwxLDAsMTAuNzksMEMxMDAuMDUsMTguNzgsODcuMjIsMCw2NS40NywwWiIvPgo8L3N2Zz4="); + background-repeat: no-repeat; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +@media (hover: hover) { + .mapillary-direction-circle:hover, + .mapillary-direction-circle-sequence:hover { + background: rgba(0, 0, 0, 0.4); + } + + .mapillary-direction-turn-circle:hover, + .mapillary-direction-turn-circle-sequence:hover { + background: rgba(0, 0, 0, 0.4); + } +} +.mapillary-viewer { + background-color: #0F0F0F; + position: relative; + overflow: hidden; + -webkit-tap-highlight-color: rgba(0,0,0,0); +} + +.mapillary-interactive { + cursor: -webkit-grab; + cursor: grab; +} + +.mapillary-interactive:active { + cursor: -webkit-grabbing; + cursor: grabbing; +} + +.mapillary-viewer .mapillary-dom { + bottom: 0; + left: 0; + overflow: hidden; + pointer-events: none; + position: absolute; + right: 0; + top: 0; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.mapillary-dom .mapillary-dom-renderer { + pointer-events: auto; +} + +.mapillary-viewer div { + -webkit-box-sizing: content-box; + box-sizing: content-box; +} + +.mapillary-viewer input:focus { + background: none; +} +.mapillary-navigation-sequence, +.mapillary-navigation-spatial { + position: absolute; + pointer-events: none; + z-index: 10; +} + +.mapillary-navigation-sequence { + top: 12px; + left: 50%; + margin-left: -40px; +} + +.mapillary-navigation-spatial { + bottom: 18px; + left: 50%; + margin-left: -100px; +} + +.mapillary-navigation-spatial-bottom { + margin-left: 40px; +} + +.mapillary-navigation-button { + width: 36px; + height: 36px; + background-size: contain; + background-repeat: no-repeat; + pointer-events: auto; + margin: 0px 2px; + cursor: pointer; + display: inline-block; + -webkit-filter: drop-shadow(0 2px 0 rgba(0, 0, 0, 1)); + filter: drop-shadow(0 2px 0 rgba(0, 0, 0, 1)); +} + +.mapillary-navigation-prev, +.mapillary-navigation-left { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgwcHgiIGhlaWdodD0iMTgwcHgiIHZpZXdCb3g9IjAgMCAxODAgMTgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IlBhZ2UtMSIgZmlsbD0ibm9uZSIgdHJhbnNmb3JtPSJyb3RhdGUoLTkwIDkwLDkwKSI+PGcgaWQ9ImFycm93LXVwIiBmaWxsPSIjRkZGRkZGIj48cGF0aCBkPSJNODguODg0MzgxNyw0NS43Mzc4ODA2IEM4OC4yOTk4ODUzLDQ1Ljk0OTc5MTYgODcuNzUxNTY5Miw0Ni4yODk5Mjk5IDg3LjI4MzA4NTMsNDYuNzU4NDEzOCBMMzEuMzYzMzk0NywxMDIuNjc4MTA0IEMyOS42NDYwNjE0LDEwNC4zOTU0MzggMjkuNjUxODM2MywxMDcuMTcwNDk4IDMxLjM2NzAwMzEsMTA4Ljg4NTY2NCBMNDAuNjg2NzQ1MywxMTguMjA1NDA3IEM0Mi40MDAzOTY3LDExOS45MTkwNTggNDUuMTgxMTMxMywxMTkuOTIyMTg5IDQ2Ljg5NDMwNTIsMTE4LjIwOTAxNSBMOTAuODAwOTI3Niw3NC4zMDIzOTI2IEwxMzQuNDUwNTUsMTE3Ljk1MjAxNSBDMTM2LjE2Nzg4MywxMTkuNjY5MzQ5IDEzOC45NDI5NDMsMTE5LjY2MzU3NCAxNDAuNjU4MTEsMTE3Ljk0ODQwNyBMMTQ5Ljk3Nzg1MiwxMDguNjI4NjY1IEMxNTEuNjkxNTA0LDEwNi45MTUwMTMgMTUxLjY5NDYzNSwxMDQuMTM0Mjc5IDE0OS45ODE0NjEsMTAyLjQyMTEwNSBMOTQuMDYxNzcwMSw0Ni41MDE0MTQxIEM5Mi42NTczMTA5LDQ1LjA5Njk1NDkgOTAuNTQ1NDIzMSw0NC44NDQ5NDQ1IDg4Ljg4NDM4MTcsNDUuNzM3ODgwNiBMODguODg0MzgxNyw0NS43Mzc4ODA2IFoiIGlkPSJBcnJvdyIgPjwvcGF0aD48cGF0aCBkPSJNOTAsMTcwLjEzODgxMiBDMTM0LjI1OTQ0NCwxNzAuMTM4ODEyIDE3MC4xMzg4MTIsMTM0LjI1OTQ0NCAxNzAuMTM4ODEyLDkwIEMxNzAuMTM4ODEyLDQ1Ljc0MDU1NjIgMTM0LjI1OTQ0NCw5Ljg2MTE4Nzg1IDkwLDkuODYxMTg3ODUgQzQ1Ljc0MDU1NjIsOS44NjExODc4NSA5Ljg2MTE4Nzg1LDQ1Ljc0MDU1NjIgOS44NjExODc4NSw5MCBDOS44NjExODc4NSwxMzQuMjU5NDQ0IDQ1Ljc0MDU1NjIsMTcwLjEzODgxMiA5MCwxNzAuMTM4ODEyIEw5MCwxNzAuMTM4ODEyIEw5MCwxNzAuMTM4ODEyIFogTTAsOTAgQzAsNDAuMjk0MzcyNSA0MC4yOTQzNzI1LDAgOTAsMCBDMTM5LjcwNTYyNywwIDE4MCw0MC4yOTQzNzI1IDE4MCw5MCBDMTgwLDEzOS43MDU2MjcgMTM5LjcwNTYyNywxODAgOTAsMTgwIEM0MC4yOTQzNzI1LDE4MCAwLDEzOS43MDU2MjcgMCw5MCBaIiBpZD0iQ2lyY2xlIj48L3BhdGg+PC9nPjwvZz48L3N2Zz4="); +} + +.DirectionNext, +.DirectionRight { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgwcHgiIGhlaWdodD0iMTgwcHgiIHZpZXdCb3g9IjAgMCAxODAgMTgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IlBhZ2UtMSIgZmlsbD0ibm9uZSIgdHJhbnNmb3JtPSJyb3RhdGUoOTAgOTAsOTApIj48ZyBpZD0iYXJyb3ctdXAiIGZpbGw9IiNGRkZGRkYiPjxwYXRoIGQ9Ik04OC44ODQzODE3LDQ1LjczNzg4MDYgQzg4LjI5OTg4NTMsNDUuOTQ5NzkxNiA4Ny43NTE1NjkyLDQ2LjI4OTkyOTkgODcuMjgzMDg1Myw0Ni43NTg0MTM4IEwzMS4zNjMzOTQ3LDEwMi42NzgxMDQgQzI5LjY0NjA2MTQsMTA0LjM5NTQzOCAyOS42NTE4MzYzLDEwNy4xNzA0OTggMzEuMzY3MDAzMSwxMDguODg1NjY0IEw0MC42ODY3NDUzLDExOC4yMDU0MDcgQzQyLjQwMDM5NjcsMTE5LjkxOTA1OCA0NS4xODExMzEzLDExOS45MjIxODkgNDYuODk0MzA1MiwxMTguMjA5MDE1IEw5MC44MDA5Mjc2LDc0LjMwMjM5MjYgTDEzNC40NTA1NSwxMTcuOTUyMDE1IEMxMzYuMTY3ODgzLDExOS42NjkzNDkgMTM4Ljk0Mjk0MywxMTkuNjYzNTc0IDE0MC42NTgxMSwxMTcuOTQ4NDA3IEwxNDkuOTc3ODUyLDEwOC42Mjg2NjUgQzE1MS42OTE1MDQsMTA2LjkxNTAxMyAxNTEuNjk0NjM1LDEwNC4xMzQyNzkgMTQ5Ljk4MTQ2MSwxMDIuNDIxMTA1IEw5NC4wNjE3NzAxLDQ2LjUwMTQxNDEgQzkyLjY1NzMxMDksNDUuMDk2OTU0OSA5MC41NDU0MjMxLDQ0Ljg0NDk0NDUgODguODg0MzgxNyw0NS43Mzc4ODA2IEw4OC44ODQzODE3LDQ1LjczNzg4MDYgWiIgaWQ9IkFycm93IiA+PC9wYXRoPjxwYXRoIGQ9Ik05MCwxNzAuMTM4ODEyIEMxMzQuMjU5NDQ0LDE3MC4xMzg4MTIgMTcwLjEzODgxMiwxMzQuMjU5NDQ0IDE3MC4xMzg4MTIsOTAgQzE3MC4xMzg4MTIsNDUuNzQwNTU2MiAxMzQuMjU5NDQ0LDkuODYxMTg3ODUgOTAsOS44NjExODc4NSBDNDUuNzQwNTU2Miw5Ljg2MTE4Nzg1IDkuODYxMTg3ODUsNDUuNzQwNTU2MiA5Ljg2MTE4Nzg1LDkwIEM5Ljg2MTE4Nzg1LDEzNC4yNTk0NDQgNDUuNzQwNTU2MiwxNzAuMTM4ODEyIDkwLDE3MC4xMzg4MTIgTDkwLDE3MC4xMzg4MTIgTDkwLDE3MC4xMzg4MTIgWiBNMCw5MCBDMCw0MC4yOTQzNzI1IDQwLjI5NDM3MjUsMCA5MCwwIEMxMzkuNzA1NjI3LDAgMTgwLDQwLjI5NDM3MjUgMTgwLDkwIEMxODAsMTM5LjcwNTYyNyAxMzkuNzA1NjI3LDE4MCA5MCwxODAgQzQwLjI5NDM3MjUsMTgwIDAsMTM5LjcwNTYyNyAwLDkwIFoiIGlkPSJDaXJjbGUiPjwvcGF0aD48L2c+PC9nPjwvc3ZnPg=="); +} + +.mapillary-navigation-forward { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgwcHgiIGhlaWdodD0iMTgwcHgiIHZpZXdCb3g9IjAgMCAxODAgMTgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IlBhZ2UtMSIgZmlsbD0ibm9uZSIgdHJhbnNmb3JtPSJyb3RhdGUoMCA5MCw5MCkiPjxnIGlkPSJhcnJvdy11cCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTg4Ljg4NDM4MTcsNDUuNzM3ODgwNiBDODguMjk5ODg1Myw0NS45NDk3OTE2IDg3Ljc1MTU2OTIsNDYuMjg5OTI5OSA4Ny4yODMwODUzLDQ2Ljc1ODQxMzggTDMxLjM2MzM5NDcsMTAyLjY3ODEwNCBDMjkuNjQ2MDYxNCwxMDQuMzk1NDM4IDI5LjY1MTgzNjMsMTA3LjE3MDQ5OCAzMS4zNjcwMDMxLDEwOC44ODU2NjQgTDQwLjY4Njc0NTMsMTE4LjIwNTQwNyBDNDIuNDAwMzk2NywxMTkuOTE5MDU4IDQ1LjE4MTEzMTMsMTE5LjkyMjE4OSA0Ni44OTQzMDUyLDExOC4yMDkwMTUgTDkwLjgwMDkyNzYsNzQuMzAyMzkyNiBMMTM0LjQ1MDU1LDExNy45NTIwMTUgQzEzNi4xNjc4ODMsMTE5LjY2OTM0OSAxMzguOTQyOTQzLDExOS42NjM1NzQgMTQwLjY1ODExLDExNy45NDg0MDcgTDE0OS45Nzc4NTIsMTA4LjYyODY2NSBDMTUxLjY5MTUwNCwxMDYuOTE1MDEzIDE1MS42OTQ2MzUsMTA0LjEzNDI3OSAxNDkuOTgxNDYxLDEwMi40MjExMDUgTDk0LjA2MTc3MDEsNDYuNTAxNDE0MSBDOTIuNjU3MzEwOSw0NS4wOTY5NTQ5IDkwLjU0NTQyMzEsNDQuODQ0OTQ0NSA4OC44ODQzODE3LDQ1LjczNzg4MDYgTDg4Ljg4NDM4MTcsNDUuNzM3ODgwNiBaIiBpZD0iQXJyb3ciID48L3BhdGg+PHBhdGggZD0iTTkwLDE3MC4xMzg4MTIgQzEzNC4yNTk0NDQsMTcwLjEzODgxMiAxNzAuMTM4ODEyLDEzNC4yNTk0NDQgMTcwLjEzODgxMiw5MCBDMTcwLjEzODgxMiw0NS43NDA1NTYyIDEzNC4yNTk0NDQsOS44NjExODc4NSA5MCw5Ljg2MTE4Nzg1IEM0NS43NDA1NTYyLDkuODYxMTg3ODUgOS44NjExODc4NSw0NS43NDA1NTYyIDkuODYxMTg3ODUsOTAgQzkuODYxMTg3ODUsMTM0LjI1OTQ0NCA0NS43NDA1NTYyLDE3MC4xMzg4MTIgOTAsMTcwLjEzODgxMiBMOTAsMTcwLjEzODgxMiBMOTAsMTcwLjEzODgxMiBaIE0wLDkwIEMwLDQwLjI5NDM3MjUgNDAuMjk0MzcyNSwwIDkwLDAgQzEzOS43MDU2MjcsMCAxODAsNDAuMjk0MzcyNSAxODAsOTAgQzE4MCwxMzkuNzA1NjI3IDEzOS43MDU2MjcsMTgwIDkwLDE4MCBDNDAuMjk0MzcyNSwxODAgMCwxMzkuNzA1NjI3IDAsOTAgWiIgaWQ9IkNpcmNsZSI+PC9wYXRoPjwvZz48L2c+PC9zdmc+"); +} + +.mapillary-navigation-backward { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgwcHgiIGhlaWdodD0iMTgwcHgiIHZpZXdCb3g9IjAgMCAxODAgMTgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IlBhZ2UtMSIgZmlsbD0ibm9uZSIgdHJhbnNmb3JtPSJyb3RhdGUoMTgwIDkwLDkwKSI+PGcgaWQ9ImFycm93LXVwIiBmaWxsPSIjRkZGRkZGIj48cGF0aCBkPSJNODguODg0MzgxNyw0NS43Mzc4ODA2IEM4OC4yOTk4ODUzLDQ1Ljk0OTc5MTYgODcuNzUxNTY5Miw0Ni4yODk5Mjk5IDg3LjI4MzA4NTMsNDYuNzU4NDEzOCBMMzEuMzYzMzk0NywxMDIuNjc4MTA0IEMyOS42NDYwNjE0LDEwNC4zOTU0MzggMjkuNjUxODM2MywxMDcuMTcwNDk4IDMxLjM2NzAwMzEsMTA4Ljg4NTY2NCBMNDAuNjg2NzQ1MywxMTguMjA1NDA3IEM0Mi40MDAzOTY3LDExOS45MTkwNTggNDUuMTgxMTMxMywxMTkuOTIyMTg5IDQ2Ljg5NDMwNTIsMTE4LjIwOTAxNSBMOTAuODAwOTI3Niw3NC4zMDIzOTI2IEwxMzQuNDUwNTUsMTE3Ljk1MjAxNSBDMTM2LjE2Nzg4MywxMTkuNjY5MzQ5IDEzOC45NDI5NDMsMTE5LjY2MzU3NCAxNDAuNjU4MTEsMTE3Ljk0ODQwNyBMMTQ5Ljk3Nzg1MiwxMDguNjI4NjY1IEMxNTEuNjkxNTA0LDEwNi45MTUwMTMgMTUxLjY5NDYzNSwxMDQuMTM0Mjc5IDE0OS45ODE0NjEsMTAyLjQyMTEwNSBMOTQuMDYxNzcwMSw0Ni41MDE0MTQxIEM5Mi42NTczMTA5LDQ1LjA5Njk1NDkgOTAuNTQ1NDIzMSw0NC44NDQ5NDQ1IDg4Ljg4NDM4MTcsNDUuNzM3ODgwNiBMODguODg0MzgxNyw0NS43Mzc4ODA2IFoiIGlkPSJBcnJvdyIgPjwvcGF0aD48cGF0aCBkPSJNOTAsMTcwLjEzODgxMiBDMTM0LjI1OTQ0NCwxNzAuMTM4ODEyIDE3MC4xMzg4MTIsMTM0LjI1OTQ0NCAxNzAuMTM4ODEyLDkwIEMxNzAuMTM4ODEyLDQ1Ljc0MDU1NjIgMTM0LjI1OTQ0NCw5Ljg2MTE4Nzg1IDkwLDkuODYxMTg3ODUgQzQ1Ljc0MDU1NjIsOS44NjExODc4NSA5Ljg2MTE4Nzg1LDQ1Ljc0MDU1NjIgOS44NjExODc4NSw5MCBDOS44NjExODc4NSwxMzQuMjU5NDQ0IDQ1Ljc0MDU1NjIsMTcwLjEzODgxMiA5MCwxNzAuMTM4ODEyIEw5MCwxNzAuMTM4ODEyIEw5MCwxNzAuMTM4ODEyIFogTTAsOTAgQzAsNDAuMjk0MzcyNSA0MC4yOTQzNzI1LDAgOTAsMCBDMTM5LjcwNTYyNywwIDE4MCw0MC4yOTQzNzI1IDE4MCw5MCBDMTgwLDEzOS43MDU2MjcgMTM5LjcwNTYyNywxODAgOTAsMTgwIEM0MC4yOTQzNzI1LDE4MCAwLDEzOS43MDU2MjcgMCw5MCBaIiBpZD0iQ2lyY2xlIj48L3BhdGg+PC9nPjwvZz48L3N2Zz4="); +} + +.mapillary-navigation-turn-left { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgwcHgiIGhlaWdodD0iMTgwcHgiIHZpZXdCb3g9IjAgMCAxODAgMTgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IlBhZ2UtMSIgZmlsbD0ibm9uZSIgdHJhbnNmb3JtPSJyb3RhdGUoLTQ1IDkwLDkwKSI+PGcgaWQ9ImFycm93LXVwIiBmaWxsPSIjRkZGRkZGIj48cGF0aCBkPSJNODguODg0MzgxNyw0NS43Mzc4ODA2IEM4OC4yOTk4ODUzLDQ1Ljk0OTc5MTYgODcuNzUxNTY5Miw0Ni4yODk5Mjk5IDg3LjI4MzA4NTMsNDYuNzU4NDEzOCBMMzEuMzYzMzk0NywxMDIuNjc4MTA0IEMyOS42NDYwNjE0LDEwNC4zOTU0MzggMjkuNjUxODM2MywxMDcuMTcwNDk4IDMxLjM2NzAwMzEsMTA4Ljg4NTY2NCBMNDAuNjg2NzQ1MywxMTguMjA1NDA3IEM0Mi40MDAzOTY3LDExOS45MTkwNTggNDUuMTgxMTMxMywxMTkuOTIyMTg5IDQ2Ljg5NDMwNTIsMTE4LjIwOTAxNSBMOTAuODAwOTI3Niw3NC4zMDIzOTI2IEwxMzQuNDUwNTUsMTE3Ljk1MjAxNSBDMTM2LjE2Nzg4MywxMTkuNjY5MzQ5IDEzOC45NDI5NDMsMTE5LjY2MzU3NCAxNDAuNjU4MTEsMTE3Ljk0ODQwNyBMMTQ5Ljk3Nzg1MiwxMDguNjI4NjY1IEMxNTEuNjkxNTA0LDEwNi45MTUwMTMgMTUxLjY5NDYzNSwxMDQuMTM0Mjc5IDE0OS45ODE0NjEsMTAyLjQyMTEwNSBMOTQuMDYxNzcwMSw0Ni41MDE0MTQxIEM5Mi42NTczMTA5LDQ1LjA5Njk1NDkgOTAuNTQ1NDIzMSw0NC44NDQ5NDQ1IDg4Ljg4NDM4MTcsNDUuNzM3ODgwNiBMODguODg0MzgxNyw0NS43Mzc4ODA2IFoiIGlkPSJBcnJvdyIgPjwvcGF0aD48cGF0aCBkPSJNOTAsMTcwLjEzODgxMiBDMTM0LjI1OTQ0NCwxNzAuMTM4ODEyIDE3MC4xMzg4MTIsMTM0LjI1OTQ0NCAxNzAuMTM4ODEyLDkwIEMxNzAuMTM4ODEyLDQ1Ljc0MDU1NjIgMTM0LjI1OTQ0NCw5Ljg2MTE4Nzg1IDkwLDkuODYxMTg3ODUgQzQ1Ljc0MDU1NjIsOS44NjExODc4NSA5Ljg2MTE4Nzg1LDQ1Ljc0MDU1NjIgOS44NjExODc4NSw5MCBDOS44NjExODc4NSwxMzQuMjU5NDQ0IDQ1Ljc0MDU1NjIsMTcwLjEzODgxMiA5MCwxNzAuMTM4ODEyIEw5MCwxNzAuMTM4ODEyIEw5MCwxNzAuMTM4ODEyIFogTTAsOTAgQzAsNDAuMjk0MzcyNSA0MC4yOTQzNzI1LDAgOTAsMCBDMTM5LjcwNTYyNywwIDE4MCw0MC4yOTQzNzI1IDE4MCw5MCBDMTgwLDEzOS43MDU2MjcgMTM5LjcwNTYyNywxODAgOTAsMTgwIEM0MC4yOTQzNzI1LDE4MCAwLDEzOS43MDU2MjcgMCw5MCBaIiBpZD0iQ2lyY2xlIj48L3BhdGg+PC9nPjwvZz48L3N2Zz4="); +} + +.mapillary-navigation-turn-right { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgwcHgiIGhlaWdodD0iMTgwcHgiIHZpZXdCb3g9IjAgMCAxODAgMTgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IlBhZ2UtMSIgZmlsbD0ibm9uZSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgOTAsOTApIj48ZyBpZD0iYXJyb3ctdXAiIGZpbGw9IiNGRkZGRkYiPjxwYXRoIGQ9Ik04OC44ODQzODE3LDQ1LjczNzg4MDYgQzg4LjI5OTg4NTMsNDUuOTQ5NzkxNiA4Ny43NTE1NjkyLDQ2LjI4OTkyOTkgODcuMjgzMDg1Myw0Ni43NTg0MTM4IEwzMS4zNjMzOTQ3LDEwMi42NzgxMDQgQzI5LjY0NjA2MTQsMTA0LjM5NTQzOCAyOS42NTE4MzYzLDEwNy4xNzA0OTggMzEuMzY3MDAzMSwxMDguODg1NjY0IEw0MC42ODY3NDUzLDExOC4yMDU0MDcgQzQyLjQwMDM5NjcsMTE5LjkxOTA1OCA0NS4xODExMzEzLDExOS45MjIxODkgNDYuODk0MzA1MiwxMTguMjA5MDE1IEw5MC44MDA5Mjc2LDc0LjMwMjM5MjYgTDEzNC40NTA1NSwxMTcuOTUyMDE1IEMxMzYuMTY3ODgzLDExOS42NjkzNDkgMTM4Ljk0Mjk0MywxMTkuNjYzNTc0IDE0MC42NTgxMSwxMTcuOTQ4NDA3IEwxNDkuOTc3ODUyLDEwOC42Mjg2NjUgQzE1MS42OTE1MDQsMTA2LjkxNTAxMyAxNTEuNjk0NjM1LDEwNC4xMzQyNzkgMTQ5Ljk4MTQ2MSwxMDIuNDIxMTA1IEw5NC4wNjE3NzAxLDQ2LjUwMTQxNDEgQzkyLjY1NzMxMDksNDUuMDk2OTU0OSA5MC41NDU0MjMxLDQ0Ljg0NDk0NDUgODguODg0MzgxNyw0NS43Mzc4ODA2IEw4OC44ODQzODE3LDQ1LjczNzg4MDYgWiIgaWQ9IkFycm93IiA+PC9wYXRoPjxwYXRoIGQ9Ik05MCwxNzAuMTM4ODEyIEMxMzQuMjU5NDQ0LDE3MC4xMzg4MTIgMTcwLjEzODgxMiwxMzQuMjU5NDQ0IDE3MC4xMzg4MTIsOTAgQzE3MC4xMzg4MTIsNDUuNzQwNTU2MiAxMzQuMjU5NDQ0LDkuODYxMTg3ODUgOTAsOS44NjExODc4NSBDNDUuNzQwNTU2Miw5Ljg2MTE4Nzg1IDkuODYxMTg3ODUsNDUuNzQwNTU2MiA5Ljg2MTE4Nzg1LDkwIEM5Ljg2MTE4Nzg1LDEzNC4yNTk0NDQgNDUuNzQwNTU2MiwxNzAuMTM4ODEyIDkwLDE3MC4xMzg4MTIgTDkwLDE3MC4xMzg4MTIgTDkwLDE3MC4xMzg4MTIgWiBNMCw5MCBDMCw0MC4yOTQzNzI1IDQwLjI5NDM3MjUsMCA5MCwwIEMxMzkuNzA1NjI3LDAgMTgwLDQwLjI5NDM3MjUgMTgwLDkwIEMxODAsMTM5LjcwNTYyNyAxMzkuNzA1NjI3LDE4MCA5MCwxODAgQzQwLjI5NDM3MjUsMTgwIDAsMTM5LjcwNTYyNyAwLDkwIFoiIGlkPSJDaXJjbGUiPjwvcGF0aD48L2c+PC9nPjwvc3ZnPg=="); +} + +.mapillary-navigation-turn-around { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgwcHgiIGhlaWdodD0iMTgwcHgiIHZpZXdCb3g9IjAgMCAxODAgMTgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IlBhZ2UtMSIgZmlsbD0ibm9uZSIgdHJhbnNmb3JtPSJyb3RhdGUoLTEzNSA5MCw5MCkiPjxnIGlkPSJhcnJvdy11cCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTg4Ljg4NDM4MTcsNDUuNzM3ODgwNiBDODguMjk5ODg1Myw0NS45NDk3OTE2IDg3Ljc1MTU2OTIsNDYuMjg5OTI5OSA4Ny4yODMwODUzLDQ2Ljc1ODQxMzggTDMxLjM2MzM5NDcsMTAyLjY3ODEwNCBDMjkuNjQ2MDYxNCwxMDQuMzk1NDM4IDI5LjY1MTgzNjMsMTA3LjE3MDQ5OCAzMS4zNjcwMDMxLDEwOC44ODU2NjQgTDQwLjY4Njc0NTMsMTE4LjIwNTQwNyBDNDIuNDAwMzk2NywxMTkuOTE5MDU4IDQ1LjE4MTEzMTMsMTE5LjkyMjE4OSA0Ni44OTQzMDUyLDExOC4yMDkwMTUgTDkwLjgwMDkyNzYsNzQuMzAyMzkyNiBMMTM0LjQ1MDU1LDExNy45NTIwMTUgQzEzNi4xNjc4ODMsMTE5LjY2OTM0OSAxMzguOTQyOTQzLDExOS42NjM1NzQgMTQwLjY1ODExLDExNy45NDg0MDcgTDE0OS45Nzc4NTIsMTA4LjYyODY2NSBDMTUxLjY5MTUwNCwxMDYuOTE1MDEzIDE1MS42OTQ2MzUsMTA0LjEzNDI3OSAxNDkuOTgxNDYxLDEwMi40MjExMDUgTDk0LjA2MTc3MDEsNDYuNTAxNDE0MSBDOTIuNjU3MzEwOSw0NS4wOTY5NTQ5IDkwLjU0NTQyMzEsNDQuODQ0OTQ0NSA4OC44ODQzODE3LDQ1LjczNzg4MDYgTDg4Ljg4NDM4MTcsNDUuNzM3ODgwNiBaIiBpZD0iQXJyb3ciID48L3BhdGg+PHBhdGggZD0iTTkwLDE3MC4xMzg4MTIgQzEzNC4yNTk0NDQsMTcwLjEzODgxMiAxNzAuMTM4ODEyLDEzNC4yNTk0NDQgMTcwLjEzODgxMiw5MCBDMTcwLjEzODgxMiw0NS43NDA1NTYyIDEzNC4yNTk0NDQsOS44NjExODc4NSA5MCw5Ljg2MTE4Nzg1IEM0NS43NDA1NTYyLDkuODYxMTg3ODUgOS44NjExODc4NSw0NS43NDA1NTYyIDkuODYxMTg3ODUsOTAgQzkuODYxMTg3ODUsMTM0LjI1OTQ0NCA0NS43NDA1NTYyLDE3MC4xMzg4MTIgOTAsMTcwLjEzODgxMiBMOTAsMTcwLjEzODgxMiBMOTAsMTcwLjEzODgxMiBaIE0wLDkwIEMwLDQwLjI5NDM3MjUgNDAuMjk0MzcyNSwwIDkwLDAgQzEzOS43MDU2MjcsMCAxODAsNDAuMjk0MzcyNSAxODAsOTAgQzE4MCwxMzkuNzA1NjI3IDEzOS43MDU2MjcsMTgwIDkwLDE4MCBDNDAuMjk0MzcyNSwxODAgMCwxMzkuNzA1NjI3IDAsOTAgWiIgaWQ9IkNpcmNsZSI+PC9wYXRoPjwvZz48L2c+PC9zdmc+"); +} +.mapillary-popup { + position: absolute; + left: 0; + top: 0; + pointer-events: none; + display: -webkit-box; + display: -ms-flexbox; + display: flex; +} + +.mapillary-popup-content, +.mapillary-popup-content-clean { + position: relative; +} + +.mapillary-popup-capture-pointer { + pointer-events: auto; +} + +.mapillary-popup-content { + padding: 5px 10px; + background: white; + border: 1px solid black; + border-radius: 2px; +} + +.mapillary-popup-float-bottom, +.mapillary-popup-float-bottom-left, +.mapillary-popup-float-bottom-right { + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; +} + +.mapillary-popup-float-top, +.mapillary-popup-float-top-left, +.mapillary-popup-float-top-right { + -webkit-box-orient: vertical; + -webkit-box-direction: reverse; + -ms-flex-direction: column-reverse; + flex-direction: column-reverse; +} + +.mapillary-popup-float-left { + -webkit-box-orient: horizontal; + -webkit-box-direction: reverse; + -ms-flex-direction: row-reverse; + flex-direction: row-reverse; +} + +.mapillary-popup-float-right { + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -ms-flex-direction: row; + flex-direction: row; +} + +.mapillary-popup-tip, +.mapillary-popup-tip-inner { + position: relative; + z-index: 1; + width: 0; + height: 0; + border: 7px solid transparent; +} + +.mapillary-popup-float-top .mapillary-popup-tip { + -ms-flex-item-align: center; + align-self: center; + border-top-color:black; + border-bottom: none; +} + +.mapillary-popup-float-top .mapillary-popup-tip-inner { + position: relative; + top: -8px; + left: -7px; + border-top-color: white; + border-bottom: none; +} + +.mapillary-popup-float-top-right .mapillary-popup-tip { + -ms-flex-item-align: start; + align-self: flex-start; + border-top-color:black; + border-bottom: none; + border-left: none; +} + +.mapillary-popup-float-top-right .mapillary-popup-tip-inner { + position: relative; + top: -9px; + left: 1px; + border-top-color: white; + border-bottom: none; + border-left: none; +} + +.mapillary-popup-float-top-left .mapillary-popup-tip { + -ms-flex-item-align: end; + align-self: flex-end; + border-top-color:black; + border-bottom: none; + border-right: none; +} + +.mapillary-popup-float-top-left .mapillary-popup-tip-inner { + position: relative; + top: -9px; + left: -8px; + border-top-color: white; + border-bottom: none; + border-right: none; +} + +.mapillary-popup-float-bottom .mapillary-popup-tip { + -ms-flex-item-align: center; + align-self: center; + border-top: none; + border-bottom-color: black; +} + +.mapillary-popup-float-bottom .mapillary-popup-tip-inner { + position: relative; + bottom: -1px; + left: -7px; + border-top: none; + border-bottom-color: white; +} + +.mapillary-popup-float-bottom-right .mapillary-popup-tip { + -ms-flex-item-align: start; + align-self: flex-start; + border-bottom-color:black; + border-top: none; + border-left: none; +} + +.mapillary-popup-float-bottom-right .mapillary-popup-tip-inner { + position: relative; + bottom: -2px; + left: 1px; + border-bottom-color: white; + border-top: none; + border-left: none; +} + +.mapillary-popup-float-bottom-left .mapillary-popup-tip { + -ms-flex-item-align: end; + align-self: flex-end; + border-bottom-color:black; + border-top: none; + border-right: none; +} + +.mapillary-popup-float-bottom-left .mapillary-popup-tip-inner { + position: relative; + bottom: -2px; + left: -8px; + border-bottom-color: white; + border-top: none; + border-right: none; +} + +.mapillary-popup-float-left .mapillary-popup-tip { + -ms-flex-item-align: center; + align-self: center; + border-left-color: black; + border-right: none; +} + +.mapillary-popup-float-left .mapillary-popup-tip-inner { + position: relative; + top: -7px; + left: -8px; + border-left-color: white; + border-right: none; +} + +.mapillary-popup-float-right .mapillary-popup-tip { + -ms-flex-item-align: center; + align-self: center; + border-right-color: black; + border-left: none; +} + +.mapillary-popup-float-right .mapillary-popup-tip-inner { + position: relative; + top: -7px; + left: 1px; + border-right-color: white; + border-left: none; +} + +.mapillary-popup-float-top-left .mapillary-popup-content { + border-bottom-right-radius: 0; +} + +.mapillary-popup-float-top-right .mapillary-popup-content { + border-bottom-left-radius: 0; +} + +.mapillary-popup-float-bottom-left .mapillary-popup-content { + border-top-right-radius: 0; +} + +.mapillary-popup-float-bottom-right .mapillary-popup-content { + border-top-left-radius: 0; +} +.mapillary-sequence-container { + position: absolute; + top: 14px; + pointer-events: none; + font-size: 0; + z-index: 10; + width: 100%; +} + +.mapillary-sequence-stepper, +.mapillary-sequence-controls { + position: absolute; + left: 50%; + cursor: pointer; +} + +.mapillary-sequence-stepper { + -webkit-transform: translate(-50%, 0); + transform: translate(-50%, 0); +} + +.mapillary-sequence-expander-button, +.mapillary-sequence-playback-button, +.mapillary-sequence-timeline-button { + display: inline-block; + height: 100%; + pointer-events: auto; +} + +.mapillary-sequence-expander-button { + width: calc(100% * 16 / 88); + background: rgba(0, 0, 0, 0.5); +} + +.mapillary-sequence-expander-bar { + background-color: white; + width: 12.5%; + border-radius: 1px; + height: calc(100% * 16 / 30); + position: relative; + left: 50%; + top: 50%; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +.mapillary-sequence-playback-button, +.mapillary-sequence-timeline-button { + width: 0; + background: rgba(0, 0, 0, 0.5); + -webkit-transition: width 0.15s ease-out; + transition: width 0.15s ease-out; +} + +.mapillary-sequence-speed-container, +.mapillary-sequence-position-container, +.mapillary-sequence-position-container-inactive { + display: inline-block; + height: 100%; + vertical-align: top; + background: rgba(0, 0, 0, 0.5); + pointer-events: auto; + padding-right: 3px; +} + +.mapillary-sequence-speed-container { + padding-left: 3px; +} + +.mapillary-sequence-position-container, +.mapillary-sequence-position-container-inactive { + padding-left: 16px; +} + +.mapillary-sequence-speed, +.mapillary-sequence-position { + margin: 0; + padding: 0; + margin-top: 7px; + height: 16px; + cursor: pointer; + outline: none; + background: none; + -webkit-appearance: none; +} + +.mapillary-sequence-position-container-inactive .mapillary-sequence-position { + cursor: default; +} + +.mapillary-sequence-fast-icon, +.mapillary-sequence-fast-icon-gray, +.mapillary-sequence-timeline-icon, +.mapillary-sequence-timeline-icon-gray, +.mapillary-sequence-slow-icon, +.mapillary-sequence-switch-icon, +.mapillary-sequence-close-icon { + position: relative; + left: 50%; + top: 50%; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); + background-size: contain; + background-repeat: no-repeat; + opacity: 0; +} + +.mapillary-sequence-fast-icon, +.mapillary-sequence-timeline-icon { + -webkit-transition-delay: 0s; + transition-delay: 0s; + -webkit-transition-property: opacity; + transition-property: opacity; +} + +.mapillary-sequence-fast-icon, +.mapillary-sequence-fast-icon-gray, +.mapillary-sequence-timeline-icon, +.mapillary-sequence-timeline-icon-gray, +.mapillary-sequence-slow-icon { + width: calc(100% * 24 / 36); + height: calc(100% * 24 / 30); +} + +.mapillary-sequence-switch-icon, +.mapillary-sequence-close-icon { + width: calc(100% * 16 / 36); + height: calc(100% * 16 / 30); +} + +.mapillary-sequence-fast-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8dGl0bGU+CiAgICAgIGZhc3QKICAgIDwvdGl0bGU+CiAgICA8ZGVmcz4KICAgICAgPHBhdGggZD0iTTIwLjE0ODQgMTMuNWMtMi4xNTE3IDAtMi41NDc4Ljg2Mi0zLjU2NzYgMS4zNjA1LTEuODUzMy45MDYtMy4wOTkzIDIuMDcyNi0zLjA5OTMgNC4yNjQ1IDAgMS4xNjE0LS4wMTAzIDEuODc1LS4zNzAzIDEuODc1LS42NTc1IDAtLjk5MzgtLjY5MS0xLjI5ODgtMS4yMi0uMjA4Mi4wNjktLjQyNjcuMTAxMi0uNjQ1Ny4wOTUtLjg0MzcgMC01LjQ2My0xLjY4NjctNS40NjMtMi42MjUgMC0uNTUxLjM3MDMtLjg0Ny4zNzAzLTEuNXYtLjAxYy0uMDE2NS0uNTUzNC0uMjg2Ny0xLjA2NzMtLjczMDgtMS4zOS0uNTk0My0uNDMwNS0xLjA0NC0xLjAzNS0xLjI4ODItMS43MzIzLS4xNzkyLjA4NjMtLjM3NS4xMzE1LS41NzM1LjEzMjNDMi43NzA1IDEyLjc1IDIgMTEuODczIDIgMTAuNWMwLTEuMzY5NC4yNjYtMS44NzUuNTU1Ni0xLjg3NS4zNzU0IDAgLjQ2My4xODc1LjQ2My4xODc1cy4yNTY4LS45NDEuNzc5OC0uOTQxYy4yNzEyLjAwMzcuNTE3Ny4xNjAyLjYzOS40MDU3Ljg4MDItMS4wMDk3IDIuMTU5LTEuNTY5NiAzLjQ4ODQtMS41MjcyIDUuMzcyIDAgNS43Mjk3IDMuMzc1IDcuNDA3NSAzLjM3NS42MjE0IDAgMS4xMTEtLjg5MjcgMS4xMTEtMS4xMjUgMC0uNzIwNi0zLjMzMzMtMi4wNzU3LTMuMzMzMy01LjI1IDAtLjQ5Ni4zOTM1LS43NS45MjYtLjc1LjQ2MyAwIDEuNDUxMy42MTA0IDIuMDM3IDEuNS44OTUgMS4zNTk0Ljc1OSAzIDEuODUyIDMuNzUuNDA5LjI4MDggMS40NTE4LjI2MDMgMi4yMjIuNzUuNzcwNS40ODk3IDEuMjQ2NCAxLjMwMjcgMS40ODE2IDEuNS4yMTcuMTkwMy4zNTAyLjQ2LjM3MDQuNzUgMCAuMzYxMy0uODA1MyAyLjI1LTEuODUxNiAyLjI1em0tOC44ODkgM2MtLjk5MDctLjAxODgtMS45NjY1LS4yNDg0LTIuODY0LS42NzM4LS4wNTkyLjIxOTgtLjA5MjUuNDQ2LS4wOTkuNjczOCAwIC42MjczLjQ3OTcuODY4IDIuMjIyIDEuNS4zOTE2LjEzNC43Njk3LjMwNTIgMS4xMjkzLjUxMTMuMDQwNC0uNjgzLjExNTQtMS4zNjMyLjIyNS0yLjAzODMtLjIwMDQuMDE0My0uNDAyMi4wMjctLjYxMzMuMDI3eiIgaWQ9ImEiLz4KICAgIDwvZGVmcz4KICAgIDx1c2UgZmlsbD0iI0ZGRiIgZmlsbC1ydWxlPSJub256ZXJvIiB4bGluazpocmVmPSIjYSIvPgogIDwvc3ZnPg=="); +} + +.mapillary-sequence-fast-icon-gray { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgPHRpdGxlPgogICAgZmFzdAogIDwvdGl0bGU+CiAgPGRlZnM+CiAgICA8cGF0aCBkPSJNMjAuMTQ4NCAxMy41Yy0yLjE1MTcgMC0yLjU0NzguODYyLTMuNTY3NiAxLjM2MDUtMS44NTMzLjkwNi0zLjA5OTMgMi4wNzI2LTMuMDk5MyA0LjI2NDUgMCAxLjE2MTQtLjAxMDMgMS44NzUtLjM3MDMgMS44NzUtLjY1NzUgMC0uOTkzOC0uNjkxLTEuMjk4OC0xLjIyLS4yMDgyLjA2OS0uNDI2Ny4xMDEyLS42NDU3LjA5NS0uODQzNyAwLTUuNDYzLTEuNjg2Ny01LjQ2My0yLjYyNSAwLS41NTEuMzcwMy0uODQ3LjM3MDMtMS41di0uMDFjLS4wMTY1LS41NTM0LS4yODY3LTEuMDY3My0uNzMwOC0xLjM5LS41OTQzLS40MzA1LTEuMDQ0LTEuMDM1LTEuMjg4Mi0xLjczMjMtLjE3OTIuMDg2My0uMzc1LjEzMTUtLjU3MzUuMTMyM0MyLjc3MDUgMTIuNzUgMiAxMS44NzMgMiAxMC41YzAtMS4zNjk0LjI2Ni0xLjg3NS41NTU2LTEuODc1LjM3NTQgMCAuNDYzLjE4NzUuNDYzLjE4NzVzLjI1NjgtLjk0MS43Nzk4LS45NDFjLjI3MTIuMDAzNy41MTc3LjE2MDIuNjM5LjQwNTcuODgwMi0xLjAwOTcgMi4xNTktMS41Njk2IDMuNDg4NC0xLjUyNzIgNS4zNzIgMCA1LjcyOTcgMy4zNzUgNy40MDc1IDMuMzc1LjYyMTQgMCAxLjExMS0uODkyNyAxLjExMS0xLjEyNSAwLS43MjA2LTMuMzMzMy0yLjA3NTctMy4zMzMzLTUuMjUgMC0uNDk2LjM5MzUtLjc1LjkyNi0uNzUuNDYzIDAgMS40NTEzLjYxMDQgMi4wMzcgMS41Ljg5NSAxLjM1OTQuNzU5IDMgMS44NTIgMy43NS40MDkuMjgwOCAxLjQ1MTguMjYwMyAyLjIyMi43NS43NzA1LjQ4OTcgMS4yNDY0IDEuMzAyNyAxLjQ4MTYgMS41LjIxNy4xOTAzLjM1MDIuNDYuMzcwNC43NSAwIC4zNjEzLS44MDUzIDIuMjUtMS44NTE2IDIuMjV6bS04Ljg4OSAzYy0uOTkwNy0uMDE4OC0xLjk2NjUtLjI0ODQtMi44NjQtLjY3MzgtLjA1OTIuMjE5OC0uMDkyNS40NDYtLjA5OS42NzM4IDAgLjYyNzMuNDc5Ny44NjggMi4yMjIgMS41LjM5MTYuMTM0Ljc2OTcuMzA1MiAxLjEyOTMuNTExMy4wNDA0LS42ODMuMTE1NC0xLjM2MzIuMjI1LTIuMDM4My0uMjAwNC4wMTQzLS40MDIyLjAyNy0uNjEzMy4wMjd6IiBpZD0iYSIvPgogIDwvZGVmcz4KICA8dXNlIGZpbGw9IiNEN0RBREQiIGZpbGwtcnVsZT0ibm9uemVybyIgeGxpbms6aHJlZj0iI2EiLz4KPC9zdmc+"); +} + +.mapillary-sequence-timeline-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGRlZnM+ICA8cGF0aCBpZD0ic2NydWJiZXItYSIgZD0iTTksNiBMMTUsNiBDMTYuMTA0NTY5NSw2IDE3LDYuODk1NDMwNSAxNyw4IEwxNywxNiBDMTcsMTcuMTA0NTY5NSAxNi4xMDQ1Njk1LDE4IDE1LDE4IEw5LDE4IEM3Ljg5NTQzMDUsMTggNywxNy4xMDQ1Njk1IDcsMTYgTDcsOCBDNyw2Ljg5NTQzMDUgNy44OTU0MzA1LDYgOSw2IFogTTIwLjYzMzY4ODEsMTMgTDE5LDEzIEMxOC40NDc3MTUzLDEzIDE4LDEyLjU1MjI4NDcgMTgsMTIgQzE4LDExLjQ0NzcxNTMgMTguNDQ3NzE1MywxMSAxOSwxMSBMMjAuNjMzNjg4MSwxMSBDMjAuNDEyNjQzLDEwLjYxNzU2OTMgMjAuNDY1NzExMywxMC4xMjAwNzUxIDIwLjc5Mjg5MzIsOS43OTI4OTMyMiBDMjEuMTgzNDE3NSw5LjQwMjM2ODkzIDIxLjgxNjU4MjUsOS40MDIzNjg5MyAyMi4yMDcxMDY4LDkuNzkyODkzMjIgTDIzLjcwNzEwNjgsMTEuMjkyODkzMiBDMjMuODg4MDcxMiwxMS40NzM4NTc2IDI0LDExLjcyMzg1NzYgMjQsMTIgQzI0LDEyLjI3NjE0MjQgMjMuODg4MDcxMiwxMi41MjYxNDI0IDIzLjcwNzEwNjgsMTIuNzA3MTA2OCBMMjIuMjA3MTA2OCwxNC4yMDcxMDY4IEMyMS44MTY1ODI1LDE0LjU5NzYzMTEgMjEuMTgzNDE3NSwxNC41OTc2MzExIDIwLjc5Mjg5MzIsMTQuMjA3MTA2OCBDMjAuNDY1NzExMywxMy44Nzk5MjQ5IDIwLjQxMjY0MywxMy4zODI0MzA3IDIwLjYzMzY4ODEsMTMgWiBNMy4zNjYzMTE4NiwxMyBDMy41ODczNTcwNCwxMy4zODI0MzA3IDMuNTM0Mjg4NjgsMTMuODc5OTI0OSAzLjIwNzEwNjc4LDE0LjIwNzEwNjggQzIuODE2NTgyNDksMTQuNTk3NjMxMSAyLjE4MzQxNzUxLDE0LjU5NzYzMTEgMS43OTI4OTMyMiwxNC4yMDcxMDY4IEwwLjI5Mjg5MzIxOSwxMi43MDcxMDY4IEMwLjExMTkyODgxMywxMi41MjYxNDI0IDAsMTIuMjc2MTQyNCAwLDEyIEMwLDExLjcyMzg1NzYgMC4xMTE5Mjg4MTMsMTEuNDczODU3NiAwLjI5Mjg5MzIxOSwxMS4yOTI4OTMyIEwxLjc5Mjg5MzIyLDkuNzkyODkzMjIgQzIuMTgzNDE3NTEsOS40MDIzNjg5MyAyLjgxNjU4MjQ5LDkuNDAyMzY4OTMgMy4yMDcxMDY3OCw5Ljc5Mjg5MzIyIEMzLjUzNDI4ODY4LDEwLjEyMDA3NTEgMy41ODczNTcwNCwxMC42MTc1NjkzIDMuMzY2MzExODYsMTEgTDUsMTEgQzUuNTUyMjg0NzUsMTEgNiwxMS40NDc3MTUzIDYsMTIgQzYsMTIuNTUyMjg0NyA1LjU1MjI4NDc1LDEzIDUsMTMgTDMuMzY2MzExODYsMTMgWiIvPjwvZGVmcz48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPiAgPHVzZSBmaWxsPSIjRkZGIiB4bGluazpocmVmPSIjc2NydWJiZXItYSIvPjwvZz4gIDwvc3ZnPiAg"); +} + +.mapillary-sequence-timeline-icon-gray { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGRlZnM+ICA8cGF0aCBpZD0ic2NydWJiZXItYSIgZD0iTTksNiBMMTUsNiBDMTYuMTA0NTY5NSw2IDE3LDYuODk1NDMwNSAxNyw4IEwxNywxNiBDMTcsMTcuMTA0NTY5NSAxNi4xMDQ1Njk1LDE4IDE1LDE4IEw5LDE4IEM3Ljg5NTQzMDUsMTggNywxNy4xMDQ1Njk1IDcsMTYgTDcsOCBDNyw2Ljg5NTQzMDUgNy44OTU0MzA1LDYgOSw2IFogTTIwLjYzMzY4ODEsMTMgTDE5LDEzIEMxOC40NDc3MTUzLDEzIDE4LDEyLjU1MjI4NDcgMTgsMTIgQzE4LDExLjQ0NzcxNTMgMTguNDQ3NzE1MywxMSAxOSwxMSBMMjAuNjMzNjg4MSwxMSBDMjAuNDEyNjQzLDEwLjYxNzU2OTMgMjAuNDY1NzExMywxMC4xMjAwNzUxIDIwLjc5Mjg5MzIsOS43OTI4OTMyMiBDMjEuMTgzNDE3NSw5LjQwMjM2ODkzIDIxLjgxNjU4MjUsOS40MDIzNjg5MyAyMi4yMDcxMDY4LDkuNzkyODkzMjIgTDIzLjcwNzEwNjgsMTEuMjkyODkzMiBDMjMuODg4MDcxMiwxMS40NzM4NTc2IDI0LDExLjcyMzg1NzYgMjQsMTIgQzI0LDEyLjI3NjE0MjQgMjMuODg4MDcxMiwxMi41MjYxNDI0IDIzLjcwNzEwNjgsMTIuNzA3MTA2OCBMMjIuMjA3MTA2OCwxNC4yMDcxMDY4IEMyMS44MTY1ODI1LDE0LjU5NzYzMTEgMjEuMTgzNDE3NSwxNC41OTc2MzExIDIwLjc5Mjg5MzIsMTQuMjA3MTA2OCBDMjAuNDY1NzExMywxMy44Nzk5MjQ5IDIwLjQxMjY0MywxMy4zODI0MzA3IDIwLjYzMzY4ODEsMTMgWiBNMy4zNjYzMTE4NiwxMyBDMy41ODczNTcwNCwxMy4zODI0MzA3IDMuNTM0Mjg4NjgsMTMuODc5OTI0OSAzLjIwNzEwNjc4LDE0LjIwNzEwNjggQzIuODE2NTgyNDksMTQuNTk3NjMxMSAyLjE4MzQxNzUxLDE0LjU5NzYzMTEgMS43OTI4OTMyMiwxNC4yMDcxMDY4IEwwLjI5Mjg5MzIxOSwxMi43MDcxMDY4IEMwLjExMTkyODgxMywxMi41MjYxNDI0IDAsMTIuMjc2MTQyNCAwLDEyIEMwLDExLjcyMzg1NzYgMC4xMTE5Mjg4MTMsMTEuNDczODU3NiAwLjI5Mjg5MzIxOSwxMS4yOTI4OTMyIEwxLjc5Mjg5MzIyLDkuNzkyODkzMjIgQzIuMTgzNDE3NTEsOS40MDIzNjg5MyAyLjgxNjU4MjQ5LDkuNDAyMzY4OTMgMy4yMDcxMDY3OCw5Ljc5Mjg5MzIyIEMzLjUzNDI4ODY4LDEwLjEyMDA3NTEgMy41ODczNTcwNCwxMC42MTc1NjkzIDMuMzY2MzExODYsMTEgTDUsMTEgQzUuNTUyMjg0NzUsMTEgNiwxMS40NDc3MTUzIDYsMTIgQzYsMTIuNTUyMjg0NyA1LjU1MjI4NDc1LDEzIDUsMTMgTDMuMzY2MzExODYsMTMgWiIvPjwvZGVmcz48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPiAgPHVzZSBmaWxsPSIjRDdEQUREIiB4bGluazpocmVmPSIjc2NydWJiZXItYSIvPjwvZz4gIDwvc3ZnPiAg"); +} + +.mapillary-sequence-slow-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PHRpdGxlPiAgc2xvdzwvdGl0bGU+PGRlZnM+ICA8cGF0aCBkPSJNMTguOTMzNyAxMy4yMjIybC0uNzgxNiAxLjg5NjNjLjMzMTcuMjUwMyAxLjMzNSAxLjEwMDIgMS4zMzUgMi4yNTE2LS4wMjIzLjMxMy0uMTU4LjYwNy0uMzgxNi44MjctLjA3NjguMDgwMy0uMTgzNC4xMjU0LS4yOTQ2LjEyNDNoLTIuNjU4Yy0uMTQ1MiAwLS4yNzk2LS4wNzY3LS4zNTM0LS4yMDE3bC0uNzEzNi0xLjIwNzRjLS4xMjAyLS4yMDM3LS4xNDY4LS40NDkyLS4wNzMtLjY3NGwuMTI0OC0uMzgwNUg3LjRjLjM2MS40MjM0LjU2ODguOTU2My41ODk4IDEuNTEyNS0uMDIyLjMxMy0uMTU3Ny42MDctLjM4MTMuODI2Ny0uMDc2Ny4wODA0LS4xODM0LjEyNTUtLjI5NDYuMTI0NEg0LjY1NmMtLjE0NTIgMC0uMjc5Ni0uMDc2Ny0uMzUzNS0uMjAxN2wtLjcxMzYtMS4yMDc1Yy0uMTIwNC0uMjAzNi0uMTQ3LS40NDkyLS4wNzMyLS42NzRsLjAwODItLjAyNDZjLS42MjkuNDgzMi0xLjM4NC43NzQ3LTIuMTc0My44Mzk2LS4xMTk3LjAxNzYtLjIzODUtLjAzNzItLjMwMy0uMTM5Ny0uMDY0My0uMTAyNS0uMDYyLS4yMzM0LjAwNTctLjMzMzdDMy40MjQ2IDEzLjQwMTUgMi43MDggNi4wMDIyIDExLjMxMyA2LjAwMjJjMi42OTQ1LS4wNTkgNS4yNzU1IDEuMDg1IDcuMDQxNyAzLjEyMTcuNzg2LS4yNDIyIDEuNDY1LS43NDY3IDEuOTI0Mi0xLjQyOTIuMzUzLS40NTM0Ljk3MjQtLjYwMyAxLjQ5MzUtLjM2MDdsMS45ODg3LjkyNWMuMTY0NS4wNzYzLjI1OTguMjUxLjIzNS40MzA4LS4yMDI1IDEuNDU1NC0xLjc4ODYgMy43NjYyLTUuMDYyNSA0LjUzMjR6IiBpZD0iYSIvPjwvZGVmcz48dXNlIGZpbGw9IiNGRkYiIGZpbGwtcnVsZT0ibm9uemVybyIgeGxpbms6aHJlZj0iI2EiLz4gIDwvc3ZnPiAg"); +} + +.mapillary-sequence-switch-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDx0aXRsZT4KICAgICAgcGxheV9kaXJlY3Rpb24KICAgIDwvdGl0bGU+CiAgICA8cGF0aCBkPSJNNiA0aDhjLjU1MjMgMCAxIC40NDc3IDEgMXMtLjQ0NzcgMS0xIDFINnYyLjA1ODRjLS4wMDM0LjE2NjgtLjExMTguMzE4Mi0uMjgxNS4zOTM0LS4xNjk4LjA3NS0uMzczLjA2MTctLjUyOC0uMDM1TC4yMTQzIDUuMzU4NEMuMDggNS4yNzcgMCA1LjE0MyAwIDVjMC0uMTQzLjA4LS4yNzcuMjE0My0uMzU4NWw0Ljk3NjItMy4wNTgzYy4xNTUtLjA5NjcuMzU4My0uMTEuNTI4LS4wMzUuMTY5OC4wNzUyLjI3OC4yMjY2LjI4MTUuMzkzNFY0em00IDZWNy45NDE2Yy4wMDM0LS4xNjY4LjExMTctLjMxODIuMjgxNS0uMzkzNC4xNjk3LS4wNzUuMzczLS4wNjE3LjUyOC4wMzVsNC45NzYyIDMuMDU4M0MxNS45MiAxMC43MjMgMTYgMTAuODU3IDE2IDExYzAgLjE0My0uMDguMjc3LS4yMTQzLjM1ODRsLTQuOTc2MiAzLjA1ODRjLS4xNTUuMDk2Ny0uMzU4Mi4xMS0uNTI4LjAzNS0uMTY5Ny0uMDc1Mi0uMjc4LS4yMjY2LS4yODE1LS4zOTM0VjEySDJjLS41NTIzIDAtMS0uNDQ3Ny0xLTFzLjQ0NzctMSAxLTFoOHoiIGZpbGw9IiNGRkYiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgogIDwvc3ZnPg=="); +} + +.mapillary-sequence-close-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDx0aXRsZT4KICAgICAgY2xvc2UKICAgIDwvdGl0bGU+CiAgICA8cGF0aCBkPSJNOS4yNzM0IDguMDA0N2wzLjQ0MTMgMy40NDE0Yy4yMzE1LjIyOS4zMjI3LjU2NDYuMjM5Ljg3OTMtLjA4MzguMzE0Ny0uMzI5Ni41NjA0LS42NDQzLjY0NC0uMzE0Ny4wODQtLjY1LS4wMDczLS44NzktLjIzODhMNy45ODg4IDkuMjg5bC0zLjQzNjUgMy40MzY1Yy0uMzU2LjM0Ni0uOTI0LjM0MTctMS4yNzUtLjAwOTMtLjM1MS0uMzUxLS4zNTUtLjkxOS0uMDA5LTEuMjc1bDMuNDM2Mi0zLjQzNjQtMy40NDk1LTMuNDQ5NmMtLjM0NDQtLjM1NjMtLjMzOTYtLjkyMy4wMTA4LTEuMjczNC4zNTA0LS4zNTA0LjkxNy0uMzU1MiAxLjI3MzMtLjAxMDhMNy45ODkgNi43MjA1bDMuNDU0NC0zLjQ1NDVjLjIyOTQtLjIyOTQuNTYzOC0uMzE5Ljg3NzItLjIzNS4zMTM1LjA4NC41NTgzLjMyODcuNjQyMy42NDIyLjA4MzguMzEzNC0uMDA1OC42NDc4LS4yMzUyLjg3NzJMOS4yNzM0IDguMDA0N3oiIGZpbGw9IiNGRkYiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgogIDwvc3ZnPg=="); +} + +.mapillary-sequence-icon-visible { + opacity: 1; +} + +.mapillary-sequence-controls-expanded .mapillary-sequence-fast-icon, +.mapillary-sequence-controls-expanded .mapillary-sequence-timeline-icon { + opacity: 1; + -webkit-transition-delay: 0.12s; + transition-delay: 0.12s; + -webkit-transition: 0.2s ease-in; + transition: 0.2s ease-in; + -webkit-transition-property: opacity; + transition-property: opacity; +} + +.mapillary-sequence-controls-expanded .mapillary-sequence-playback-button, +.mapillary-sequence-controls-expanded .mapillary-sequence-timeline-button { + width: calc(100% * 36 / 88); +} + +.mapillary-sequence-playback, +.mapillary-sequence-timeline { + position: absolute; + height: 30px; + width: 405px; + max-width: 80%; + min-width: 271px; + left: 50%; + padding-left: 5px; + -webkit-transform: translate(-50%, 0); + transform: translate(-50%, 0); +} + +.mapillary-sequence-switch-button, +.mapillary-sequence-switch-button-inactive, +.mapillary-sequence-slow-container, +.mapillary-sequence-fast-container, +.mapillary-sequence-close-button { + width: 36px; + height: 100%; + display: inline-block; + pointer-events: auto; + background: rgba(0, 0, 0, 0.5); +} + +.mapillary-sequence-switch-button, +.mapillary-sequence-switch-button-inactive, +.mapillary-sequence-close-button { + cursor: pointer; +} + +.mapillary-sequence-play, +.mapillary-sequence-play-inactive, +.mapillary-sequence-stop, +.mapillary-sequence-step-next, +.mapillary-sequence-step-next-inactive, +.mapillary-sequence-step-next-highlight, +.mapillary-sequence-step-prev, +.mapillary-sequence-step-prev-inactive, +.mapillary-sequence-step-prev-highlight { + display: inline-block; + width: calc(100% / 3); + height: 100%; + pointer-events: auto; +} + +.mapillary-sequence-play-inactive, +.mapillary-sequence-step-next-inactive, +.mapillary-sequence-step-prev-inactive { + cursor: default; +} + +.mapillary-sequence-play, +.mapillary-sequence-play-inactive, +.mapillary-sequence-stop, +.mapillary-sequence-step-next, +.mapillary-sequence-step-next-inactive, +.mapillary-sequence-step-prev, +.mapillary-sequence-step-prev-inactive { + background: rgba(0, 0, 0, 0.5); +} + +.mapillary-sequence-step-next-highlight, +.mapillary-sequence-step-prev-highlight { + background: rgba(0, 0, 0, 0.5); +} + +.mapillary-sequence-switch-button, +.mapillary-sequence-switch-button-inactive, +.mapillary-sequence-position-container, +.mapillary-sequence-position-container-inactive { + border-bottom-left-radius: 8px; + border-top-left-radius: 8px; +} + +.mapillary-sequence-close-button { + border-bottom-right-radius: 8px; + border-top-right-radius: 8px; +} + +.mapillary-sequence-play .mapillary-sequence-icon, +.mapillary-sequence-play-inactive .mapillary-sequence-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8dGl0bGU+CiAgICBwbGF5CiAgPC90aXRsZT4KICA8cGF0aCBkPSJNMTQuODk1MiA4LjY4NjVMMy41MDgyIDE1Ljc1N0MyLjY3ODMgMTYuMzAyIDIgMTUuODk4IDIgMTQuODY1VjEuMTM2QzIgLjEgMi42NzgzLS4zMDEgMy41MDk2LjI0M2wxMS4zODcgNy4wNzNjLS4wMDE0IDAgLjQwNDMuMjg2Ny40MDQzLjY4NTMgMCAuMzk4OC0uNDA1OC42ODU1LS40MDU4LjY4NTV6IiBmaWxsPSIjRkZGIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz4KPC9zdmc+Cg=="); +} + +.mapillary-sequence-stop .mapillary-sequence-icon, +.mapillary-sequence-stop-inactive .mapillary-sequence-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8dGl0bGU+CiAgICBzdG9wCiAgPC90aXRsZT4KICA8cGF0aCBkPSJNMCAxLjk5NEMwIC44OTMuODk0NSAwIDEuOTk0IDBoMTIuMDEyQzE1LjEwNyAwIDE2IC44OTQ1IDE2IDEuOTk0djEyLjAxMkMxNiAxNS4xMDcgMTUuMTA1NSAxNiAxNC4wMDYgMTZIMS45OTRDLjg5MyAxNiAwIDE1LjEwNTUgMCAxNC4wMDZWMS45OTR6IiBmaWxsPSIjRkZGIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz4KPC9zdmc+Cg=="); +} + +.mapillary-sequence-step-prev .mapillary-sequence-icon, +.mapillary-sequence-step-prev-inactive .mapillary-sequence-icon, +.mapillary-sequence-step-prev-highlight .mapillary-sequence-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8dGl0bGU+CiAgICBjaGV2cm9ubGVmdAogIDwvdGl0bGU+CiAgPHBhdGggZD0iTTYuMjY4IDguMDAwMmw1LjUyMTUtNS45NDczYy40NzEtLjQ2OTcuNDcxLTEuMjI5NiAwLTEuNzAxLS40NzEtLjQ2OTQtMS4yMzIzLS40Njk0LTEuNzAzNCAwTDMuMzUzNCA3LjE0OGMtLjQ3MS40NzE0LS40NzEgMS4yMzMgMCAxLjcwMjZsNi43MzI4IDYuNzk2Yy40Njk1LjQ3MyAxLjIzMjQuNDY5NSAxLjcwMzUgMCAuNDcxLS40Njc4LjQ3MS0xLjIyOTUgMC0xLjcwMDhMNi4yNjggOC4wMDAyeiIgZmlsbD0iI0ZGRiIgZmlsbC1ydWxlPSJldmVub2RkIi8+Cjwvc3ZnPgo="); +} + +.mapillary-sequence-step-next .mapillary-sequence-icon, +.mapillary-sequence-step-next-inactive .mapillary-sequence-icon, +.mapillary-sequence-step-next-highlight .mapillary-sequence-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8dGl0bGU+CiAgICBjaGV2cm9ucmlnaHQKICA8L3RpdGxlPgogIDxwYXRoIGQ9Ik05Ljg3NSA4LjAwMDJMNC4zNTMyIDIuMDUzYy0uNDcxLS40Njk3LS40NzEtMS4yMjk2IDAtMS43MDEuNDcxMi0uNDY5NCAxLjIzMjMtLjQ2OTQgMS43MDM0IDBsNi43MzI4IDYuNzk2Yy40NzEuNDcxNC40NzEgMS4yMzMgMCAxLjcwMjZsLTYuNzMyOCA2Ljc5NmMtLjQ2OTMuNDczLTEuMjMyMi40Njk1LTEuNzAzNCAwLS40NzEtLjQ2NzgtLjQ3MS0xLjIyOTUgMC0xLjcwMDhMOS44NzUgOC4wMDAyeiIgZmlsbD0iI0ZGRiIgZmlsbC1ydWxlPSJldmVub2RkIi8+Cjwvc3ZnPgo="); +} + +.mapillary-sequence-play-inactive .mapillary-sequence-icon, +.mapillary-sequence-stop-inactive .mapillary-sequence-icon, +.mapillary-sequence-step-next-inactive .mapillary-sequence-icon, +.mapillary-sequence-step-prev-inactive .mapillary-sequence-icon, +.mapillary-sequence-switch-button-inactive .mapillary-sequence-switch-icon { + opacity: 0.35; +} + +.mapillary-sequence-icon { + position: relative; + left: 50%; + top: 50%; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); + width: calc(100% * 16 / 36); + height: calc(100% * 16 / 30); + background-size: contain; + background-repeat: no-repeat; +} + +.mapillary-sequence-speed::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: -6px; +} + +.mapillary-sequence-position::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: -6px; +} + +.mapillary-sequence-position-container-inactive + .mapillary-sequence-position::-webkit-slider-thumb { + border: none; + background: none; + -webkit-box-shadow: none; + box-shadow: none; +} + +.mapillary-sequence-speed::-webkit-slider-runnable-track, +.mapillary-sequence-position::-webkit-slider-runnable-track { + height: 4px; + border-radius: 2px; + background-color: white; +} + +.mapillary-sequence-position-container-inactive + .mapillary-sequence-position::-webkit-slider-runnable-track { + background-color: #d7dadd; +} + +.mapillary-sequence-speed::-moz-focus-outer, +.mapillary-sequence-position::-moz-focus-outer { + border: 0; +} + +.mapillary-sequence-speed::-moz-range-track, +.mapillary-sequence-position::-moz-range-track { + height: 4px; + border-radius: 2px; + background-color: white; +} + +.mapillary-sequence-position-container-inactive + .mapillary-sequence-position::-moz-range-track { + background-color: #d7dadd; +} + +.mapillary-sequence-speed::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + background: #dee5ed; + cursor: pointer; + margin-top: -6px; + box-shadow: 1px 2px 4px 0 rgba(0, 0, 0, 0.5); +} + +.mapillary-sequence-position::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + background: #ff861b; + cursor: pointer; + margin-top: -6px; + box-shadow: 1px 2px 4px 0 rgba(0, 0, 0, 0.5); +} + +.mapillary-sequence-position-container-inactive + .mapillary-sequence-position::-moz-range-thumb { + border: none; + background: none; + box-shadow: none; +} + +.mapillary-sequence-speed::-ms-track, +.mapillary-sequence-position::-ms-track { + height: 2px; + border-radius: 2px; + background-color: white; +} + +.mapillary-sequence-position-container-inactive + .mapillary-sequence-position::-ms-track { + background-color: #d7dadd; +} + +.mapillary-sequence-speed::-ms-fill-lower, +.mapillary-sequence-position::-ms-fill-lower { + height: 0; + background: none; +} + +.mapillary-sequence-speed::-ms-fill-upper, +.mapillary-sequence-position::-ms-fill-upper { + height: 0; + background: none; +} + +.mapillary-sequence-speed::-ms-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: 0; +} + +.mapillary-sequence-position::-ms-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: 0; +} + +.mapillary-sequence-position-container-inactive + .mapillary-sequence-position::-ms-thumb { + border: none; + background: none; + box-shadow: none; +} + +.mapillary-sequence-speed::-ms-tooltip, +.mapillary-sequence-position::-ms-tooltip { + display: none; +} + +@media (hover: hover) { + .mapillary-sequence-play:hover, + .mapillary-sequence-stop:hover, + .mapillary-sequence-step-prev:hover, + .mapillary-sequence-step-next:hover { + background: rgba(0, 0, 0, 0.5); + } + + .mapillary-sequence-play-inactive:hover, + .mapillary-sequence-step-prev-inactive:hover, + .mapillary-sequence-step-next-inactive:hover { + background: rgba(0, 0, 0, 0.5); + } + + .mapillary-sequence-switch-button-inactive:hover { + background: rgba(0, 0, 0, 0.5); + } + + .mapillary-sequence-playback-button:hover, + .mapillary-sequence-timeline-button:hover, + .mapillary-sequence-switch-button:hover, + .mapillary-sequence-close-button:hover { + background: rgba(0, 0, 0, 0.5); + } +} +.mapillary-slider-container { + position: absolute; + bottom: 40px; + height: 30px; + left: 50%; + padding-left: 5px; + pointer-events: none; + -webkit-transform: translate(-50%, 0); + transform: translate(-50%, 0); + z-index: 10; +} + +.mapillary-slider-border { + display: inline-block; + width: 8px; + height: 100%; + border-bottom-left-radius: 8px; + border-top-left-radius: 8px; + pointer-events: auto; + cursor: default; + background: rgba(0, 0, 0, 0.5); +} + +.mapillary-slider-mode-button, +.mapillary-slider-mode-button-2d, +.mapillary-slider-mode-button-inactive, +.mapillary-slider-mode-button-2d-inactive { + width: 26px; + height: 100%; + display: inline-block; + pointer-events: auto; + background: rgba(0, 0, 0, 0.5); + +} + +.mapillary-slider-mode-button, +.mapillary-slider-mode-button-2d { + cursor: default; +} + +.mapillary-slider-mode-button-inactive, +.mapillary-slider-mode-button-2d-inactive { + cursor: pointer; +} + +.mapillary-slider-mode-icon, +.mapillary-slider-mode-icon-2d { + position: relative; + top: 50%; + -webkit-transform: translate(0, -50%); + transform: translate(0, -50%); + background-size: contain; + background-repeat: no-repeat; + opacity: 1; + width: 18px; + height: 18px; + margin-left: 4px; +} + +.mapillary-slider-mode-icon { + background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE4IDE3di44NjRjMCAuNTA1IDAgLjk5Ni0uMDM0IDEuNDEyLS4wMzcuNDU4LS4xMjUuOTk3LS40MDIgMS41NGE0IDQgMCAwIDEtMS43NDggMS43NDhjLS41NDMuMjc3LTEuMDgyLjM2NS0xLjU0LjQwMi0uNDE2LjAzNC0uOTA3LjAzNC0xLjQxMi4wMzRINi4xMzZjLS41MDUgMC0uOTk2IDAtMS40MTItLjAzNC0uNDU4LS4wMzctLjk5Ny0uMTI1LTEuNTQtLjQwMmE0IDQgMCAwIDEtMS43NDgtMS43NDhjLS4yNzctLjU0My0uMzY1LTEuMDgyLS40MDItMS41NEExOC4zNiAxOC4zNiAwIDAgMSAxIDE3Ljg2NHYtNi43MjhjMC0uNTA1IDAtLjk5Ni4wMzQtMS40MTIuMDM3LS40NTguMTI1LS45OTcuNDAyLTEuNTRhNCA0IDAgMCAxIDEuNzQ4LTEuNzQ4Yy41NDMtLjI3NyAxLjA4Mi0uMzY1IDEuNTQtLjQwMkM1LjE0IDYgNS42MyA2IDYuMTM2IDZIN3YyaC0uOGMtMS4xMiAwLTEuNjggMC0yLjEwOC4yMThhMiAyIDAgMCAwLS44NzQuODc0QzMgOS41MiAzIDEwLjA4IDMgMTEuMnY2LjZjMCAxLjEyIDAgMS42OC4yMTggMi4xMDhhMiAyIDAgMCAwIC44NzQuODc0QzQuNTIgMjEgNS4wOCAyMSA2LjIgMjFoNi42YzEuMTIgMCAxLjY4IDAgMi4xMDgtLjIxOGEyIDIgMCAwIDAgLjg3NC0uODc0QzE2IDE5LjQ4IDE2IDE4LjEyIDE2IDE3aDJ6TTE2LjUxIDcuMjM2djMuNzFoLjc4YzEuMDk0IDAgMS42Ny0uNTYzIDEuNjctMS44NiAwLTEuMjkzLS41NzYtMS44NS0xLjY2Ny0xLjg1aC0uNzg0eiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4zMjcgMi42MzhDNyAzLjI4IDcgNC4xMiA3IDUuOHY2LjRjMCAxLjY4IDAgMi41Mi4zMjcgMy4xNjJhMyAzIDAgMCAwIDEuMzExIDEuMzExQzkuMjggMTcgMTAuMTIgMTcgMTEuOCAxN2g2LjRjMS42OCAwIDIuNTIgMCAzLjE2Mi0uMzI3YTMgMyAwIDAgMCAxLjMxMS0xLjMxMUMyMyAxNC43MiAyMyAxMy44OCAyMyAxMi4yVjUuOGMwLTEuNjggMC0yLjUyLS4zMjctMy4xNjJhMyAzIDAgMCAwLTEuMzExLTEuMzExQzIwLjcyIDEgMTkuODggMSAxOC4yIDFoLTYuNGMtMS42OCAwLTIuNTIgMC0zLjE2Mi4zMjdhMyAzIDAgMCAwLTEuMzExIDEuMzExem03LjAzNCA3LjcyOWMuMDAzLjk5NC0uOTMyIDEuNzEyLTIuMjM5IDEuNzEyLTEuMjc4IDAtMi4xODctLjcwNy0yLjIwNy0xLjcyNGgxLjIzOGMuMDIzLjQyNi40My43MS45NzIuNzEuNTYzIDAgLjk2LS4zMTguOTU3LS43OC4wMDMtLjQ3LS40MDktLjgtMS4wNjgtLjhoLS41NDJ2LS45MDNoLjU0MmMuNTYgMCAuOTUtLjMxLjk0Ni0uNzY3LjAwMy0uNDM3LS4zMy0uNzM4LS44MjctLjczOC0uNTAyIDAtLjkwOS4yODctLjkyMy43M2gtMS4xNzljLjAxNy0xLjAwNi45MTUtMS43MDUgMi4xMDgtMS43MDUgMS4yMDUgMCAyLjAwOS43MjIgMi4wMDYgMS42NC4wMDMuNjUzLS40NDYgMS4xMTMtMS4wOTcgMS4yM3YuMDQ1Yy44NTUuMTA4IDEuMzE2LjYyNSAxLjMxMyAxLjM1em01LjgyNy0xLjI4MmMwIDEuODE4LTEuMDc0IDIuOTE1LTIuODQ2IDIuOTE1aC0yLjA2M1Y2LjE4MmgyLjA4YzEuNzU1IDAgMi44MyAxLjA5IDIuODMgMi45MDN6IiBmaWxsPSIjZmZmIi8+PC9zdmc+'); +} + +.mapillary-slider-mode-icon-2d { + background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEzLjM0NSAxMC4yMzZ2My43MWguNzgxYzEuMDk0IDAgMS42Ny0uNTYzIDEuNjctMS44NiAwLTEuMjkzLS41NzYtMS44NS0xLjY2Ny0xLjg1aC0uNzg0eiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNC4zMjcgNS42MzhDNCA2LjI4IDQgNy4xMiA0IDguOHY2LjRjMCAxLjY4IDAgMi41Mi4zMjcgMy4xNjJhMyAzIDAgMCAwIDEuMzExIDEuMzExQzYuMjggMjAgNy4xMiAyMCA4LjggMjBoNi40YzEuNjggMCAyLjUyIDAgMy4xNjItLjMyN2EzIDMgMCAwIDAgMS4zMTEtMS4zMTFDMjAgMTcuNzIgMjAgMTYuODggMjAgMTUuMlY4LjhjMC0xLjY4IDAtMi41Mi0uMzI3LTMuMTYyYTMgMyAwIDAgMC0xLjMxMS0xLjMxMUMxNy43MiA0IDE2Ljg4IDQgMTUuMiA0SDguOGMtMS42OCAwLTIuNTIgMC0zLjE2Mi4zMjdhMyAzIDAgMCAwLTEuMzExIDEuMzExek0xMS4xODcgMTVINy4wNHYtLjg4NmwyLjA3LTEuOTE4Yy41MTgtLjUuODEtLjgyNy44MTMtMS4yOS0uMDAzLS41MTEtLjM4OS0uODMtLjkwMy0uODMtLjUzNCAwLS44OTguMzM5LS44OTguODk4SDYuOTU1Yy0uMDAzLTEuMTQyLjg1NS0xLjg3MiAyLjA3My0xLjg3MiAxLjI1NiAwIDIuMDQ2Ljc4MiAyLjA1MSAxLjc5LjAwMy43NDQtLjM4NiAxLjI5My0xLjQxMSAyLjIzbC0uODU1LjgzMnYuMDRoMi4zNzRWMTV6bTUuODM3LTIuOTE1YzAgMS44MTgtMS4wNzQgMi45MTUtMi44NDYgMi45MTVoLTIuMDYzVjkuMTgyaDIuMDhjMS43NTUgMCAyLjgzIDEuMDkgMi44MyAyLjkwM3oiIGZpbGw9IiNmZmYiLz48L3N2Zz4gIA=='); +} + +.mapillary-slider-mode-button-inactive .mapillary-slider-mode-icon { + opacity: 0.35; +} + +.mapillary-slider-mode-button-2d-inactive .mapillary-slider-mode-icon-2d { + opacity: 0.35; +} + +.mapillary-slider-position-container { + display: inline-block; + height: 100%; + vertical-align: top; + background: rgba(0, 0, 0, 0.5); + pointer-events: auto; + padding-right: 16px; + border-bottom-right-radius: 8px; + border-top-right-radius: 8px; + padding-left: 8px; +} + +.mapillary-slider-position { + pointer-events: auto; + margin: 0; + padding: 0; + margin-top: 7px; + height: 16px; + cursor: pointer; + outline: none; + background: none; + -webkit-appearance: none; +} + +.mapillary-slider-position::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: -6px; +} + +.mapillary-slider-position::-webkit-slider-runnable-track { + height: 4px; + border-radius: 2px; + background-color: white; +} + +.mapillary-slider-position::-moz-focus-outer { + border: 0; +} + +.mapillary-slider-position::-moz-range-track { + height: 4px; + border-radius: 2px; + background-color: white; +} + +.mapillary-slider-position::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: -6px; +} + +.mapillary-slider-position::-ms-track { + height: 2px; + border-radius: 2px; + background-color: white; +} + +.mapillary-slider-position::-ms-fill-lower { + height: 0; + background: none; +} + +.mapillary-slider-position::-ms-fill-upper { + height: 0; + background: none; +} + +.mapillary-slider-position::-ms-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: 0; +} + +.mapillary-slider-position::-ms-tooltip { + display: none; +} +.mapillary-viewer.component-tag-create .mapillary-interactive, +.mapillary-viewer.component-tag-edit-crosshair .mapillary-interactive { + cursor: crosshair; +} + +.mapillary-viewer.component-tag-edit-move .mapillary-interactive { + cursor: move; +} + +.mapillary-viewer.component-tag-edit-nesw-resize .mapillary-interactive { + cursor: nesw-resize; +} + +.mapillary-viewer.component-tag-edit-nwse-resize .mapillary-interactive { + cursor: nwse-resize; +} + +.mapillary-tag-container { + font-size: 12px; + font-family: system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", "Roboto", "Oxygen", "Ubuntu", + "Cantarell", "Fira Sans", "Droid Sans", + "Helvetica Neue", sans-serif; +} + +.mapillary-tag-completer, +.mapillary-tag-interactor, +.mapillary-tag-mover, +.mapillary-tag-resizer, +.mapillary-tag-spot-interactor, +.mapillary-tag-symbol, +.mapillary-tag-vertex { + position: absolute; + left: 0; + top: 0; +} + +.mapillary-tag-symbol { + cursor: pointer; + pointer-events: auto; +} + +.mapillary-tag-mover, +.mapillary-tag-resizer { + pointer-events: auto; +} + +.mapillary-tag-mover { + border-radius: 7px; + width: 14px; + height: 14px; + opacity: 0.2; + cursor: move; +} + +.mapillary-tag-mover:hover { + opacity: 0.5; +} + +.mapillary-tag-resizer { + border-radius: 7px; + width: 14px; + height: 14px; + opacity: 0; + cursor: crosshair; +} + +.mapillary-tag-resizer:hover { + opacity: 0.3; +} + +.mapillary-tag-vertex { + pointer-events: none; + border-radius: 3px; + width: 6px; + height: 6px; +} + +.mapillary-tag-dot { + pointer-events: none; + border-radius: 1.5px; + width: 3px; + height: 3px; + background-color: green; +} + +.mapillary-tag-vertex.mapillary-tag-larger { + border-radius: 4px; + width: 8px; + height: 8px; +} + +.mapillary-tag-interactor, +.mapillary-tag-completer, +.mapillary-tag-spot-interactor { + pointer-events: auto; + border-radius: 7px; + width: 14px; + height: 14px; + opacity: 0; +} + +.mapillary-tag-completer.mapillary-tag-larger { + border-radius: 8px; + width: 16px; + height: 16px; +} + +.mapillary-tag-interactor, +.mapillary-tag-completer { + cursor: pointer; +} + +.mapillary-tag-spot-interactor { + cursor: move; +} + +.mapillary-tag-completer { + background: limegreen; +} + +.mapillary-tag-interactor { + background: orangered; +} + +.mapillary-tag-completer:hover, +.mapillary-tag-interactor:hover { + opacity: 0.6; +} + +.mapillary-tag-spot-interactor:hover { + opacity: 0.3; +} +.mapillary-zoom-container { + position: absolute; + bottom: 90px; + right: 14px; + height: 66px; + width: 32px; + pointer-events: none; + z-index: 10; +} + +.mapillary-zoom-in-button, +.mapillary-zoom-in-button-inactive, +.mapillary-zoom-out-button, +.mapillary-zoom-out-button-inactive { + width: 100%; + height: 32px; + pointer-events: auto; + background: rgba(0, 0, 0, 0.5); + cursor: pointer; +} + +.mapillary-zoom-in-button, +.mapillary-zoom-in-button-inactive { + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} + +.mapillary-zoom-out-button, +.mapillary-zoom-out-button-inactive { + margin-top: 2px; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; +} + +.mapillary-zoom-in-button-inactive, +.mapillary-zoom-out-button-inactive { + cursor: default; +} + +.mapillary-zoom-in-icon, +.mapillary-zoom-out-icon { + position: relative; + top: 0; + left: 0; + background-size: contain; + background-repeat: no-repeat; + opacity: 1; + width: 32px; + height: 32px; +} + +.mapillary-zoom-compact.mapillary-zoom-container { + height: 34px; + width: 16px; + bottom: 58px; +} + +.mapillary-zoom-compact .mapillary-zoom-in-button, +.mapillary-zoom-compact .mapillary-zoom-in-button-inactive, +.mapillary-zoom-compact .mapillary-zoom-out-button, +.mapillary-zoom-compact .mapillary-zoom-out-button-inactive, +.mapillary-zoom-compact .mapillary-zoom-in-icon, +.mapillary-zoom-compact .mapillary-zoom-out-icon { + width: 16px; + height: 16px; +} + +.mapillary-zoom-compact .mapillary-zoom-in-button, +.mapillary-zoom-compact .mapillary-zoom-in-button-inactive { + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +.mapillary-zoom-compact .mapillary-zoom-out-button, +.mapillary-zoom-compact .mapillary-zoom-out-button-inactive { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} + +.mapillary-zoom-in-icon { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMCIgaGVpZ2h0PSIzMCIgdmlld0JveD0iMCAwIDMwIDMwIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPiAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTE2LDE0IEwyMSwxNCBDMjEuNTUyMjg0NywxNCAyMiwxNC40NDc3MTUzIDIyLDE1IEMyMiwxNS41NTIyODQ3IDIxLjU1MjI4NDcsMTYgMjEsMTYgTDE2LDE2IEwxNiwyMSBDMTYsMjEuNTUyMjg0NyAxNS41NTIyODQ3LDIyIDE1LDIyIEMxNC40NDc3MTUzLDIyIDE0LDIxLjU1MjI4NDcgMTQsMjEgTDE0LDE2IEw5LDE2IEM4LjQ0NzcxNTI1LDE2IDgsMTUuNTUyMjg0NyA4LDE1IEM4LDE0LjQ0NzcxNTMgOC40NDc3MTUyNSwxNCA5LDE0IEwxNCwxNCBMMTQsOSBDMTQsOC40NDc3MTUyNSAxNC40NDc3MTUzLDggMTUsOCBDMTUuNTUyMjg0Nyw4IDE2LDguNDQ3NzE1MjUgMTYsOSBMMTYsMTQgWiIvPjwvZz4gIDwvc3ZnPiAg'); +} + +.mapillary-zoom-out-icon { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMCIgaGVpZ2h0PSIzMCIgdmlld0JveD0iMCAwIDMwIDMwIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPiAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTIyLDE1IEMyMiwxNS41NTIyODQ3IDIxLjU1MjI4NDcsMTYgMjEsMTYgTDksMTYgQzguNDQ3NzE1MjUsMTYgOCwxNS41NTIyODQ3IDgsMTUgQzgsMTQuNDQ3NzE1MyA4LjQ0NzcxNTI1LDE0IDksMTQgTDIxLDE0IEMyMS41NTIyODQ3LDE0IDIyLDE0LjQ0NzcxNTMgMjIsMTUgWiIvPjwvZz4gIDwvc3ZnPiAg'); +} + +.mapillary-zoom-in-button-inactive .mapillary-zoom-in-icon, +.mapillary-zoom-out-button-inactive .mapillary-zoom-out-icon { + opacity: 0.35; +} diff --git a/src/ui/styles/mapillary.css b/src/ui/styles/mapillary.css new file mode 100644 index 000000000..8aa634ad0 --- /dev/null +++ b/src/ui/styles/mapillary.css @@ -0,0 +1,959 @@ +/* ── Mapillary dock ─────────────────────────── */ +#mapillary-dock { + --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); +} + +#mapillary-dock .panel-glow { + position: absolute; + inset: -18px; + background: radial-gradient( + ellipse at center, + rgba(5, 203, 99, 0.16) 0%, + transparent 65% + ); + filter: blur(16px); + opacity: 0.25; + pointer-events: none; +} + +.mly-dock-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); + 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; +} + +.mly-header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.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; +} + +.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; + letter-spacing: 0.1em; + padding: 2px 7px; + border-radius: 999px; + border: 1px solid var(--glass-border-hover); + color: var(--text-secondary); +} + +.mly-chip.is-on { + color: var(--mly-green); + border-color: rgba(5, 203, 99, 0.55); +} + +.mly-chip.is-busy { + color: var(--accent); + border-color: rgba(0, 212, 255, 0.55); +} + +.mly-chip.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 { + display: none; +} + +.mly-body { + 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 { + display: flex; + flex-direction: column; + gap: 8px; + flex: 0 1 auto; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; + padding-right: 2px; +} + +/* Children keep their natural height; the region scrolls instead. */ +.mly-scroll > * { + flex-shrink: 0; +} + +#mapillary-dock.mly-has-viewer .mly-scroll { + /* Let the imagery win the height fight when the window is short. */ + flex-shrink: 3; +} + +.mly-kicker { + font-size: 0.56rem; + letter-spacing: 0.16em; + color: var(--accent); +} + +.mly-query { + display: flex; + flex-direction: column; + gap: 6px; +} + +.mly-query-row { + display: flex; + gap: 6px; +} + +#mly-query-input { + flex: 1; + min-width: 0; + padding: 8px 10px; + border-radius: var(--btn-radius); + border: 1px solid var(--glass-border-hover); + background: rgba(0, 0, 0, 0.35); + color: var(--text-primary); + font: inherit; + font-size: 0.72rem; +} + +#mly-query-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-dim); +} + +.mly-run { + min-width: 56px; +} + +.mly-suggestions { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.mly-suggestion { + font: inherit; + font-size: 0.56rem; + letter-spacing: 0.06em; + padding: 3px 8px; + border-radius: 999px; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.03); + color: var(--text-secondary); + cursor: pointer; +} + +.mly-suggestion:hover { + color: var(--text-primary); + border-color: var(--glass-border-hover); +} + +.mly-answer { + font-family: var(--font-sans); + font-size: 0.74rem; + line-height: 1.4; + color: var(--text-primary); + min-height: 1.2em; +} + +.mly-answer.is-error { + color: #ff8a80; +} + +.mly-progress { + position: relative; + height: 14px; + border-radius: 999px; + overflow: hidden; + background: rgba(255, 255, 255, 0.06); +} + +.mly-progress-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--mly-green), var(--accent)); + transition: width 160ms linear; +} + +.mly-progress-label { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.55rem; + letter-spacing: 0.1em; + color: var(--text-primary); + text-shadow: 0 0 4px rgba(0, 0, 0, 0.8); +} + +.mly-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.mly-value-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.58rem; + padding: 3px 8px; + border-radius: 999px; + border: 1px solid var(--glass-border); + color: var(--text-primary); +} + +.mly-value-chip i { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.mly-value-chip b { + color: var(--text-secondary); + font-weight: 500; +} + +.mly-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.mly-actions .scene-btn { + flex: 1 1 auto; +} + +.scene-btn[aria-pressed='true'] { + color: #0a0a0f; + background: var(--accent); + border-color: var(--accent); +} + +.mly-viewer-wrap { + position: relative; + container-type: inline-size; + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 230px; + 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; +} + +#mapillary-dock.gev-floating .mly-viewer, +.mly-viewer-wrap-expanded .mly-viewer { + aspect-ratio: auto; + min-height: 180px; +} + +/* Caption bar, mapillary.com style: creator left, date right. */ +.mly-image-meta { + flex: 0 0 auto; + padding: 6px 10px; + font-size: 0.58rem; + letter-spacing: 0.06em; + color: var(--text-secondary); + background: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + min-height: 26px; +} + +.mly-image-by { + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.mly-image-right { + display: inline-flex; + align-items: center; + gap: 10px; + white-space: nowrap; +} + +.mly-image-link { + color: var(--mly-green); + text-decoration: none; + letter-spacing: 0.1em; +} + +.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; + 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; +} + +/* ── 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 . */ +.mly-viewer-wrap-expanded { + position: fixed; + left: 4vw; + right: 4vw; + top: 5vh; + bottom: 9vh; + z-index: 141; + display: flex; + flex-direction: column; + font-family: var(--font-mono); + border-color: rgba(0, 212, 255, 0.55); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7); +} + +.mly-viewer-wrap-expanded .mly-viewer { + flex: 1 1 auto; + min-height: 0; + aspect-ratio: auto; +} + +.mly-viewer-wrap-expanded .mly-image-meta { + 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 .scene-btn { + padding: 8px 14px; + font-size: 0.7rem; + letter-spacing: 0.12em; + color: var(--text-primary); + border-color: rgba(0, 212, 255, 0.6); + background: rgba(10, 10, 15, 0.82); +} + +.mly-viewer-wrap-expanded .mly-viewer-tools .scene-btn::after { + content: ' · ESC'; + color: var(--text-secondary); +} From 791e77fa3368fdd66844a57262f620cd611269aa Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:44:32 +0100 Subject: [PATCH 06/77] =?UTF-8?q?feat(mapillary):=20dev-server=20provider?= =?UTF-8?q?=20=E2=80=94=20cached=20tiles,=20feature=20stream,=20sprites,?= =?UTF-8?q?=20optional=20Claude=20planner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite middleware for /api/mapillary: status (capabilities, never values), vector tiles cached in memory and on disk with the 12 MB z14 image layer stripped in transit, an NDJSON feature stream capped at 600 tiles, MIT sprite icons behind a grammar-guarded path, and a structured-output planner that only activates when ANTHROPIC_API_KEY is set server-side. Co-Authored-By: Claude Fable 5.1 --- server/providers/mapillary/features.js | 2 +- server/providers/mapillary/planner.js | 41 ++++++++++++++++++++++---- server/providers/mapillary/sprites.js | 4 +++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/server/providers/mapillary/features.js b/server/providers/mapillary/features.js index ffa038b35..40f907d52 100644 --- a/server/providers/mapillary/features.js +++ b/server/providers/mapillary/features.js @@ -26,7 +26,7 @@ function sendJson(res, status, payload) { } /** Parse an ISO date (or epoch ms) into epoch ms, or null when absent/invalid. */ -export function parseEpochMs(value) { +function parseEpochMs(value) { if (value === null || value === undefined || value === '') return null; if (typeof value === 'number') return Number.isFinite(value) ? value : null; const parsed = Date.parse(String(value)); diff --git a/server/providers/mapillary/planner.js b/server/providers/mapillary/planner.js index 1001b9371..0160a72ce 100644 --- a/server/providers/mapillary/planner.js +++ b/server/providers/mapillary/planner.js @@ -1,6 +1,5 @@ import Anthropic from '@anthropic-ai/sdk'; import { z } from 'zod'; -import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'; import { readRequestBody } from '../common/request.js'; import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; import { @@ -26,7 +25,7 @@ import { * The plan the model returns. Every field is required (structured outputs * reject optional keys); "unknown" is spelled with null. */ -export const PlanSchema = z.object({ +const PlanSchema = z.object({ intent: z.enum([ 'map_features', 'traffic_signs', @@ -182,6 +181,35 @@ export function buildPlannerUserMessage({ query, context = {}, today }) { return lines.join('\n'); } +/** + * Structured-output format for the plan: the Zod schema as JSON Schema. Kept + * free of the SDK's zod helper so the provider only depends on package roots. + */ +function planOutputFormat() { + const { $schema: _schema, ...schema } = z.toJSONSchema(PlanSchema, { + reused: 'ref', + }); + return { type: 'json_schema', schema }; +} + +/** The plan object from a structured-output response, or null. */ +function parsePlanResponse(response) { + if (response?.parsed_output) return response.parsed_output; + const text = (response?.content || []) + .filter((block) => block?.type === 'text') + .map((block) => block.text) + .join(''); + if (!text.trim()) return null; + let raw; + try { + raw = JSON.parse(text); + } catch { + return null; + } + const result = PlanSchema.safeParse(raw); + return result.success ? result.data : null; +} + /** * Ask Claude for a plan. Exported for tests with an injectable client. * @returns {Promise<{plan: object, usage: object|null, model: string}>} @@ -190,7 +218,7 @@ export async function planQuery( { query, context }, { anthropic = client(), model = plannerModel(), now = new Date() } = {}, ) { - const response = await anthropic.messages.parse({ + const response = await anthropic.messages.create({ model, max_tokens: PLANNER_MAX_TOKENS, system: [ @@ -210,7 +238,7 @@ export async function planQuery( }), }, ], - output_config: { format: zodOutputFormat(PlanSchema), effort: 'low' }, + output_config: { format: planOutputFormat(), effort: 'low' }, }); if (response.stop_reason === 'refusal') { return { @@ -230,10 +258,11 @@ export async function planQuery( model, }; } - if (!response.parsed_output) + const parsed = parsePlanResponse(response); + if (!parsed) throw Object.assign(new Error('Planner returned no plan'), { status: 502 }); return { - plan: finalizePlan(response.parsed_output), + plan: finalizePlan(parsed), usage: response.usage ?? null, model, }; diff --git a/server/providers/mapillary/sprites.js b/server/providers/mapillary/sprites.js index c2f888555..15cf2ef15 100644 --- a/server/providers/mapillary/sprites.js +++ b/server/providers/mapillary/sprites.js @@ -1,3 +1,7 @@ +/** + * Sprite proxy. Icons come from github.com/mapillary/mapillary_sprite_source + * (MIT, © Mapillary), fetched on demand and cached on disk. + */ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { From 3169d205d9fa8f2a44ca6d3da48d99229909d401 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:44:32 +0100 Subject: [PATCH 07/77] 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 --- src/ui/applicationShell.js | 26 + src/ui/mapillaryControls.js | 710 ++++++++----------- src/ui/mapillaryPresentation.js | 205 ++++++ src/ui/panelChrome.js | 3 + src/ui/panelLayoutController.js | 15 + src/ui/shellElements.js | 2 +- src/ui/styles/layers.css | 15 +- src/ui/styles/mapillary.css | 1061 +++++++++++----------------- src/ui/styles/responsive.css | 2 + src/ui/templates/layer-panels.html | 101 +++ style.css | 5 + 11 files changed, 1073 insertions(+), 1072 deletions(-) create mode 100644 src/ui/mapillaryPresentation.js 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 @@ + + +