diff --git a/CHANGELOG.md b/CHANGELOG.md index 05e90cb05..88d19f068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,12 @@ `restoreMissionRosterFocus` helpers are gone; the roster keeps focus itself (samehkhamis). +- `createMapSourceControls()` (`./ui/maps`) no longer renders the map source + chips into a `container` or writes a `statusElement`: it presents the chip + row and status badge and hands them to its `publish` option, and Core's + dock renders them and calls `select()`. `renderMapStackChips` and + `syncMapStackChips` are deprecated (samehkhamis). + ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language - Show a Street Level view facing 359.5° or more as 0°. The viewer caption diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index a3c532026..8b3641cb2 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -118,7 +118,9 @@ They contain no application state. the same way), the command dock (in progress: the style buttons, the dock's style mini status and the ACTIVE STYLE corner render the style VisualSettings publishes to the `gev-display` store; the buttons keep - their owner's listeners), + their owner's listeners; the map source chips and status badge render + what `createMapSourceControls()` publishes beside it, so `./ui/maps` + needs no Vue), the phone shell, then Cockpit. Layers that write into the right rail directly gain a slot instead. diff --git a/scripts/qa-map-source-controls.mjs b/scripts/qa-map-source-controls.mjs index c03e96a79..d72961ca0 100644 --- a/scripts/qa-map-source-controls.mjs +++ b/scripts/qa-map-source-controls.mjs @@ -69,9 +69,10 @@ try { await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; return ( - manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset - .stackId === 'osm' && - manager._mapStackStatus.textContent === + document + .getElementById('map-stack-chips') + .querySelector('[aria-pressed="true"]')?.dataset.stackId === 'osm' && + document.getElementById('map-stack-status').textContent === manager.mapStackController.getState().activeStack.shortLabel ); }), @@ -81,32 +82,38 @@ try { await page.evaluate(async () => { const manager = window.__godsEyeView.styleManager; const result = await manager.setMapStack('esri-imagery'); + // The dock renders the published row on Vue's next flush. + await new Promise((resolve) => setTimeout(resolve, 0)); const active = manager.mapStackController.getActiveId(); return ( window.__qaMapSelections.at(-1) === 'esri-imagery' && - manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset - .stackId === active && + document + .getElementById('map-stack-chips') + .querySelector('[aria-pressed="true"]')?.dataset.stackId === active && (result.ok || !!manager.mapStackController.getState().lastError) ); }), ); check( - 'refresh revokes retained old chips', - await page.evaluate(() => { + 'refresh re-presents the chips without duplicating them', + await page.evaluate(async () => { const manager = window.__godsEyeView.styleManager; - const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]'); manager._mapSourceControls.refresh(); - const before = window.__qaMapSelections.length; - old.click(); - return window.__qaMapSelections.length === before; + await new Promise((resolve) => setTimeout(resolve, 0)); + return ( + document.querySelectorAll('#map-stack-chips [data-stack-id="osm"]') + .length === 1 + ); }), ); check( 'refreshed chips still select once', await page.evaluate(async () => { - const manager = window.__godsEyeView.styleManager; const before = window.__qaMapSelections.length; - manager._mapStackChips.querySelector('[data-stack-id="osm"]').click(); + document + .getElementById('map-stack-chips') + .querySelector('[data-stack-id="osm"]') + .click(); await Promise.resolve(); return window.__qaMapSelections.length === before + 1; }), @@ -188,7 +195,10 @@ try { const manager = window.__godsEyeView.styleManager; const before = window.__qaMapSelections.length; manager._mapSourceControls.destroy(); - manager._mapStackChips.querySelector('[data-stack-id="osm"]').click(); + document + .getElementById('map-stack-chips') + .querySelector('[data-stack-id="osm"]') + .click(); const result = await manager._mapSourceControls.select('esri-imagery'); return result === null && window.__qaMapSelections.length === before; }), diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 8a0c0b7fd..231725997 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -871,6 +871,9 @@ try { return originalSetStack(stackId); }; const switchPromise = styleManager._setMapStack('osm', { syncShare: false }); + // The dock renders the published row on Vue's next flush. + const rendered = () => new Promise((resolve) => setTimeout(resolve, 0)); + await rendered(); const during = { status: document.getElementById('map-stack-status').textContent, active: [...document.querySelectorAll('.map-stack-chip')] @@ -879,6 +882,7 @@ try { }; release(); await switchPromise; + await rendered(); const after = { status: document.getElementById('map-stack-status').textContent, active: [...document.querySelectorAll('.map-stack-chip')] diff --git a/src/mapStackChips.js b/src/mapStackChips.js index 1f77ec7b2..5798e9fcf 100644 --- a/src/mapStackChips.js +++ b/src/mapStackChips.js @@ -81,6 +81,8 @@ export function mapStackChipModels(stacks, activeId) { /** * Renders the chip row into `container`, replacing any previous chips. + * @deprecated Core's dock renders the row from `createMapSourceControls()`'s + * published view; this DOM builder is no longer used by Core. * @param {HTMLElement} container - Row element. * @param {Array} stacks - `MapStackController.getStacks()` output. * @param {object} [options] @@ -155,7 +157,8 @@ export function renderMapStackChips( } /** - * Re-points the active chip at controller state. Availability never changes at + * Re-points the active chip at controller state. + * @deprecated Core's dock renders the active chip from the published view. Availability never changes at * runtime (it tracks the ion token), so only the active/pressed pair is synced. * @param {HTMLElement} container - Row element. * @param {string|null} activeId - Currently active stack id. diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs index c2351b6b6..3a2c89e66 100644 --- a/src/mapStackChips.test.mjs +++ b/src/mapStackChips.test.mjs @@ -530,9 +530,13 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse /return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/, 'the application action uses the component selection path', ); + const dock = readFileSync( + new URL('./ui/views/CommandDockView.vue', import.meta.url), + 'utf8', + ); assert.match( - controls, - /onSelect: [\s\S]*?select\(id\)/, + dock, + /mapSourceControls\.value\?\.select\(chip\.id\)/, 'chip clicks use the same component selection path', ); assert.match( diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 2a2d47804..47c37bcf5 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -54,6 +54,7 @@ import { applyLocaleChoice, mountLocalePicker } from './localePicker.js'; import { PanelNavigation } from './panelNavigation.js'; import { applicationPinia, createUiRuntime } from './vueRuntime.js'; import { useContextStore } from './stores/context.js'; +import { useDisplayStore } from './stores/display.js'; /** * Central UI orchestrator for the God's Eye View application. @@ -890,11 +891,12 @@ export class StyleManager extends ShellFacade { _initMapStackControl() { if (!this.mapStackController) return; + const displayStore = useDisplayStore(applicationPinia()); this._mapSourceControls?.destroy(); + displayStore.publishMapSource(null); this._mapSourceControls = createMapSourceControls({ - container: this._mapStackChips, - statusElement: this._mapStackStatus, controller: this.mapStackController, + publish: (view) => displayStore.publishMapSource(view), subscribe: (onChange) => { window.addEventListener('gev:map-stack-changed', onChange); return () => @@ -904,6 +906,7 @@ export class StyleManager extends ShellFacade { onStateChanged: () => this._syncShareState(), onError: (message) => this._showToast(message), }); + displayStore.connectMapSource(this._mapSourceControls); } /** @@ -1768,6 +1771,8 @@ export class StyleManager extends ShellFacade { this._displayBindings.destroy(); this._mapSourceControls?.destroy(); + useDisplayStore(applicationPinia()).connectMapSource(null); + useDisplayStore(applicationPinia()).publishMapSource(null); this._cameraOrientationControls?.destroy(); this._clearLayersControl?.destroy(); this._cctvControls?.destroy(); diff --git a/src/ui/commandDock.test.mjs b/src/ui/commandDock.test.mjs index 0a065558c..5ebec996f 100644 --- a/src/ui/commandDock.test.mjs +++ b/src/ui/commandDock.test.mjs @@ -31,7 +31,7 @@ function fixture(t) { [...document.querySelectorAll('.style-btn.active')].map( (button) => button.dataset.style, ); - return { $, settings, active }; + return { $, settings, active, pinia }; } test('before VisualSettings publishes, the dock shows NORMAL', (t) => { @@ -79,3 +79,76 @@ test('the dock and the corner label the style in the UI language', async (t) => assert.equal($('#style-mini-value').textContent, 'НУАР'); assert.equal($('#active-style-name').textContent, 'НУАР'); }); + +const chipView = (activeId, { warn = false, text = 'OSM' } = {}) => ({ + chips: [ + { + id: 'bing-aerial', + label: 'Bing', + available: false, + active: false, + requirement: 'ION', + title: 'Needs a key', + ariaLabel: 'Bing unavailable: Needs a key', + }, + ...['esri-imagery', 'osm'].map((id) => ({ + id, + label: id.toUpperCase(), + available: true, + active: id === activeId, + requirement: '', + title: id.toUpperCase(), + ariaLabel: null, + })), + ], + status: { text, warn }, +}); + +test('the map source row renders the published chips and selects through the controls', async (t) => { + const { $, pinia } = fixture(t); + assert.equal($('#map-stack-status').textContent, '3D'); + assert.equal(document.querySelectorAll('.map-stack-chip').length, 0); + const store = useDisplayStore(pinia); + const selected = []; + store.connectMapSource({ + select: async (id) => selected.push(id), + }); + store.publishMapSource(chipView('osm')); + await nextTick(); + const chips = [ + ...document.querySelectorAll('#map-stack-chips .map-stack-chip'), + ]; + assert.deepEqual( + chips.map((chip) => chip.dataset.stackId), + ['bing-aerial', 'esri-imagery', 'osm'], + ); + const [bing, esri, osm] = chips; + assert.equal(bing.classList.contains('unavailable'), true); + assert.equal(bing.getAttribute('aria-disabled'), 'true'); + assert.equal( + bing.getAttribute('aria-label'), + 'Bing unavailable: Needs a key', + ); + assert.equal(bing.title, 'Needs a key'); + assert.equal(bing.querySelector('.map-stack-chip-req').textContent, 'ION'); + assert.equal(osm.classList.contains('active'), true); + assert.equal(osm.getAttribute('aria-pressed'), 'true'); + assert.equal(osm.hasAttribute('aria-label'), false); + assert.equal(esri.querySelector('.map-stack-chip-req'), null); + bing.click(); + esri.click(); + assert.deepEqual(selected, ['esri-imagery']); + esri.classList.add('voice-highlight'); + store.publishMapSource(chipView('esri-imagery', { text: '...', warn: true })); + await nextTick(); + assert.equal(esri.classList.contains('active'), true); + assert.equal(esri.classList.contains('voice-highlight'), true); + assert.equal(osm.classList.contains('active'), false); + assert.equal($('#map-stack-status').textContent, '...'); + assert.equal($('#map-stack-status').classList.contains('warn'), true); + store.publishMapSource(null); + await nextTick(); + assert.equal($('#map-stack-status').textContent, '3D'); + assert.equal($('#map-stack-status').classList.contains('warn'), false); + assert.equal(document.querySelectorAll('.map-stack-chip').length, 0); +}); diff --git a/src/ui/mapSourceControls.js b/src/ui/mapSourceControls.js index 8546f6cb3..ed23fe412 100644 --- a/src/ui/mapSourceControls.js +++ b/src/ui/mapSourceControls.js @@ -1,37 +1,48 @@ -import { renderMapStackChips, syncMapStackChips } from '../mapStackChips.js'; +import { mapStackChipModels } from '../mapStackChips.js'; import { t } from '../i18n/index.js'; /** * Own Map Source presentation and selection without constructing map providers. * The supplied controller remains authoritative for availability and active state. + * The chip row and the status badge are presented as a plain view and handed to + * `publish`; Core's dock renders them and calls `select()` for a chip. */ export function createMapSourceControls({ - container, - statusElement, controller, subscribe, + publish = () => {}, claimSelection = () => {}, onStateChanged = () => {}, onError = () => {}, }) { let destroyed = false; let generation = 0; - const removers = []; - const bind = (element, type, listener) => { - element.addEventListener(type, listener); - removers.push(() => element.removeEventListener(type, listener)); - }; + // Availability tracks the ion token, so the stacks are read on refresh only. + let stacks = []; + function present(state) { + const stack = state.activeStack; + return { + chips: mapStackChipModels(stacks, state.activeId).map((model) => ({ + ...model, + ariaLabel: model.available + ? null + : t('{label} unavailable: {hint}', { + label: model.label, + hint: model.unavailableHint, + }), + })), + status: { + text: + state.status === 'switching' + ? '...' + : stack?.shortLabel || stack?.label || t('MAP'), + warn: !!state.lastError, + }, + }; + } function render(state) { if (destroyed || !state) return; - syncMapStackChips(container, state.activeId); - if (statusElement) { - const stack = state.activeStack; - statusElement.textContent = - state.status === 'switching' - ? '...' - : stack?.shortLabel || stack?.label || t('MAP'); - statusElement.classList.toggle('warn', !!state.lastError); - } + publish(present(state)); } async function select(stackId, { syncShare = true } = {}) { if (destroyed) return null; @@ -58,14 +69,7 @@ export function createMapSourceControls({ } function refresh() { if (destroyed) return; - for (const remove of removers.splice(0)) remove(); - renderMapStackChips(container, controller.getStacks(), { - activeId: controller.getActiveId(), - onSelect: (id) => { - void select(id).catch(() => {}); - }, - bind, - }); + stacks = controller.getStacks(); render(controller.getState()); } const unsubscribe = subscribe(() => { @@ -83,7 +87,6 @@ export function createMapSourceControls({ destroyed = true; generation++; unsubscribe?.(); - for (const remove of removers.splice(0)) remove(); }, }; } diff --git a/src/ui/mapSourceControls.test.mjs b/src/ui/mapSourceControls.test.mjs index b2bceee8b..b33270393 100644 --- a/src/ui/mapSourceControls.test.mjs +++ b/src/ui/mapSourceControls.test.mjs @@ -2,69 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createMapSourceControls } from './mapSourceControls.js'; -function makeElement(tagName = 'div') { - const element = { - tagName, - type: '', - className: '', - title: '', - disabled: false, - textContent: '', - dataset: {}, - attributes: {}, - listeners: {}, - children: [], - classList: { - toggle(name, force) { - const classes = new Set( - String(element.className).split(/\s+/).filter(Boolean), - ); - const next = force === undefined ? !classes.has(name) : !!force; - if (next) classes.add(name); - else classes.delete(name); - element.className = [...classes].join(' '); - }, - contains(name) { - return String(element.className).split(/\s+/).includes(name); - }, - }, - appendChild(child) { - element.children.push(child); - return child; - }, - setAttribute(name, value) { - element.attributes[name] = String(value); - }, - getAttribute(name) { - return element.attributes[name] ?? null; - }, - addEventListener(type, handler) { - (element.listeners[type] ||= []).push(handler); - }, - removeEventListener(type, handler) { - element.listeners[type] = (element.listeners[type] || []).filter( - (current) => current !== handler, - ); - }, - click() { - for (const handler of element.listeners.click || []) handler(); - }, - }; - Object.defineProperty(element, 'innerHTML', { - get() { - return ''; - }, - set() { - element.children.length = 0; - }, - }); - return element; -} - function fixture() { - const container = makeElement(); - container.ownerDocument = { createElement: (tag) => makeElement(tag) }; - const statusElement = makeElement(); const sources = [ { id: 'osm', label: 'OSM' }, { id: 'esri-imagery', label: 'Esri' }, @@ -79,8 +17,9 @@ function fixture() { const listeners = new Set(); const requests = []; const calls = []; + const views = []; const controller = { - getStacks: () => sources, + getStacks: () => sources.map((source) => ({ ...source })), getActiveId: () => state.activeId, getState: (status) => ({ ...state, status: status || state.status }), setStack: (id) => { @@ -91,9 +30,8 @@ function fixture() { }, }; const controls = createMapSourceControls({ - container, - statusElement, controller, + publish: (view) => views.push(view), subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); @@ -102,8 +40,12 @@ function fixture() { onStateChanged: () => calls.push(['state']), onError: (message) => calls.push(['error', message]), }); - const chip = (id) => - container.children.find((el) => el.dataset.stackId === id); + const view = () => views.at(-1); + const chip = (id) => view().chips.find((model) => model.id === id); + const active = () => + view() + .chips.filter((model) => model.active) + .map((model) => model.id); function change(id, lastError = null) { state = { activeId: id, @@ -117,14 +59,15 @@ function fixture() { for (const listener of listeners) listener(); }; return { - container, - statusElement, - controller, + sources, controls, requests, calls, listeners, + views, + view, chip, + active, change, emit, }; @@ -132,17 +75,20 @@ function fixture() { test('initial presentation uses actual state and retains unavailable chip semantics', () => { const f = fixture(); - assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true'); - assert.equal(f.statusElement.textContent, 'OSM'); - const unavailable = f.chip('bing-aerial'); - assert.equal( - unavailable.disabled, - false, - 'unavailable chips remain focusable', + assert.deepEqual(f.active(), ['osm']); + assert.deepEqual(f.view().status, { text: 'OSM', warn: false }); + assert.deepEqual( + f.view().chips.map((model) => model.id), + ['bing-aerial', 'esri-imagery', 'osm'], ); - assert.equal(unavailable.getAttribute('aria-disabled'), 'true'); - unavailable.click(); - assert.equal(f.requests.length, 0); + const unavailable = f.chip('bing-aerial'); + assert.equal(unavailable.available, false); + assert.equal(unavailable.title, 'Unavailable for this test'); + assert.equal( + unavailable.ariaLabel, + 'Bing unavailable: Unavailable for this test', + ); + assert.equal(f.chip('osm').ariaLabel, null); f.controls.destroy(); }); @@ -150,12 +96,12 @@ test('selection claims authority before requesting and never lights a pending so const f = fixture(); const pending = f.controls.select('esri-imagery'); assert.deepEqual(f.calls, [['claim'], ['select', 'esri-imagery']]); - assert.equal(f.statusElement.textContent, '...'); - assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true'); + assert.equal(f.view().status.text, '...'); + assert.deepEqual(f.active(), ['osm']); f.requests[0].resolve(f.change('esri-imagery')); await pending; - assert.equal(f.chip('esri-imagery').getAttribute('aria-pressed'), 'true'); - assert.equal(f.statusElement.textContent, 'Esri'); + assert.deepEqual(f.active(), ['esri-imagery']); + assert.equal(f.view().status.text, 'Esri'); f.controls.destroy(); }); @@ -165,9 +111,8 @@ test('provider-driven fallback updates the active chip and durable state notific f.emit(); f.change('osm', 'Source unavailable; using OSM'); f.emit(); - assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true'); - assert.equal(f.statusElement.textContent, 'OSM'); - assert.equal(f.statusElement.classList.contains('warn'), true); + assert.deepEqual(f.active(), ['osm']); + assert.deepEqual(f.view().status, { text: 'OSM', warn: true }); assert.deepEqual(f.calls, [['state'], ['state']]); f.controls.destroy(); }); @@ -185,7 +130,7 @@ test('a late superseded response cannot overwrite the latest displayed selection lastError: 'obsolete failure', }); await first; - assert.equal(f.statusElement.textContent, 'OSM'); + assert.equal(f.view().status.text, 'OSM'); assert.equal(f.calls.length, count); f.controls.destroy(); }); @@ -195,8 +140,8 @@ test('failed selection keeps the real source lit and reports its error', async ( const pending = f.controls.select('esri-imagery'); f.requests[0].resolve(f.change('osm', 'Unavailable')); await pending; - assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true'); - assert.equal(f.statusElement.classList.contains('warn'), true); + assert.deepEqual(f.active(), ['osm']); + assert.equal(f.view().status.warn, true); assert.ok( f.calls.some((call) => call[0] === 'error' && call[1] === 'Unavailable'), ); @@ -208,7 +153,7 @@ test('rejected selection leaves switching presentation and propagates a useful e const pending = f.controls.select('esri-imagery'); f.requests[0].reject(new Error('Request failed')); await assert.rejects(pending, /Request failed/); - assert.equal(f.statusElement.textContent, 'OSM'); + assert.equal(f.view().status.text, 'OSM'); assert.ok( f.calls.some((call) => call[0] === 'error' && call[1] === 'Request failed'), ); @@ -224,17 +169,15 @@ test('restoration may select without claiming or publishing a user gesture', asy f.controls.destroy(); }); -test('refresh removes detached chip listeners and destruction removes all subscriptions', () => { +test('availability is read on refresh, and destruction removes the subscription', () => { const f = fixture(); - const old = f.chip('esri-imagery'); + f.sources[2].available = true; + f.emit(); + assert.equal(f.chip('bing-aerial').available, false); f.controls.refresh(); - old.click(); - assert.equal(f.requests.length, 0); - const current = f.chip('esri-imagery'); + assert.equal(f.chip('bing-aerial').available, true); f.controls.destroy(); f.controls.destroy(); - current.click(); - assert.equal(f.requests.length, 0); assert.equal(f.listeners.size, 0); }); @@ -243,10 +186,10 @@ test('completion after destruction cannot paint or notify', async () => { const pending = f.controls.select('esri-imagery'); f.controls.destroy(); const count = f.calls.length; - const label = f.statusElement.textContent; + const published = f.views.length; f.requests[0].resolve(f.change('esri-imagery')); await pending; assert.equal(f.calls.length, count); - assert.equal(f.statusElement.textContent, label); + assert.equal(f.views.length, published); assert.equal(await f.controls.select('osm'), null); }); diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 2ac57a9ef..93b4895f9 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -31,8 +31,6 @@ export function readShellElements(document = globalThis.document) { _celestialBtn: document.getElementById('celestial-toggle'), _scopeBtn: document.getElementById('scope-toggle'), _scopeFeatherSlider: document.getElementById('scope-feather-slider'), - _mapStackChips: document.getElementById('map-stack-chips'), - _mapStackStatus: document.getElementById('map-stack-status'), _cleanViewBtn: document.getElementById('clean-view-toggle'), _cleanViewExitBtn: document.getElementById('clean-view-exit'), _dataPanel: document.getElementById('data-panel'), diff --git a/src/ui/stores/display.js b/src/ui/stores/display.js index 0b3ef39d5..e9ccf41e7 100644 --- a/src/ui/stores/display.js +++ b/src/ui/stores/display.js @@ -6,7 +6,8 @@ import { markRaw, shallowRef } from 'vue'; * groups (`_presentDisplay()`), AircraftDisplay the 3D aircraft group, the * draw tool its controls and the file import its list, and each publishes * here; each keeps its listeners on the controls. VisualSettings also - * publishes the active style, which the dock and the scene chrome show. + * publishes the active style, which the dock and the scene chrome show, and + * the map source controls publish their chip row beside it. */ export const useDisplayStore = defineStore('gev-display', () => { /** The presented panel; null until VisualSettings first renders. */ @@ -49,6 +50,22 @@ export const useDisplayStore = defineStore('gev-display', () => { style.value = markRaw(next); } + /** The map source chips and status; null until the controls first render. */ + const mapSource = shallowRef(null); + + /** @param {object} next A fresh presentation of the map source row. */ + function publishMapSource(next) { + mapSource.value = next && markRaw(next); + } + + /** The map source controls whose `select(id)` a chip calls. */ + const mapSourceControls = shallowRef(null); + + /** @param {?object} controls The map source controls, or null to disconnect. */ + function connectMapSource(controls) { + mapSourceControls.value = controls && markRaw(controls); + } + return { view, publish, @@ -60,5 +77,9 @@ export const useDisplayStore = defineStore('gev-display', () => { publishGeoImport, style, publishStyle, + mapSource, + publishMapSource, + mapSourceControls, + connectMapSource, }; }); diff --git a/src/ui/views/CommandDockView.vue b/src/ui/views/CommandDockView.vue index f60972e43..a4d351e94 100644 --- a/src/ui/views/CommandDockView.vue +++ b/src/ui/views/CommandDockView.vue @@ -4,9 +4,15 @@ import { t } from '../../i18n/index.js'; import { vClassToggle } from '../rail/classToggle.js'; import { useDisplayStore } from '../stores/display.js'; -// VisualSettings publishes the active style; until it does, the dock shows -// its initial markup. The style buttons keep their owner's listeners. -const { style } = storeToRefs(useDisplayStore()); +// VisualSettings publishes the active style and the map source controls +// their chip row; until they do, the dock shows its initial markup. The +// style buttons keep their owner's listeners; a chip calls the controls. +const { style, mapSource, mapSourceControls } = storeToRefs(useDisplayStore()); + +function selectMapSource(chip) { + if (!chip.available) return; + void mapSourceControls.value?.select(chip.id).catch(() => {}); +}