mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(ui): render the map source chips and status badge from the store
createMapSourceControls() (./ui/maps) no longer builds the chip row or writes the status badge: it presents both and hands them to its publish option, so the entry stays Vue-free. The shell publishes them to the gev-display store and connects the controls, and the dock renders the chips and calls select() for an available one. renderMapStackChips and syncMapStackChips are deprecated. The map source QA harnesses wait for the dock to render and no longer read the removed element refs.
This commit is contained in:
1 parent
decac29fbd
commit
4966877859
13 files changed
+233
-155
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -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')]
|
||||
|
||||
@@ -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<object>} 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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+29
-26
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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(() => {});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -59,9 +65,9 @@ const { style } = storeToRefs(useDisplayStore());
|
||||
<section class="map-source-section" aria-labelledby="map-source-label">
|
||||
<div class="map-source-heading">
|
||||
<span data-i18n id="map-source-label">MAP SOURCE</span>
|
||||
<span id="map-stack-status" class="pp-stack-status" aria-live="polite">3D</span>
|
||||
<span id="map-stack-status" class="pp-stack-status" v-class-toggle="{ warn: !!mapSource?.status.warn }" aria-live="polite">{{ mapSource ? mapSource.status.text : '3D' }}</span>
|
||||
</div>
|
||||
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source" data-i18n-aria-label></div>
|
||||
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source" data-i18n-aria-label><button v-for="chip in mapSource?.chips" :key="chip.id" type="button" class="map-stack-chip" v-class-toggle="{ active: chip.active, unavailable: !chip.available }" :data-stack-id="chip.id" :title="chip.title" :aria-pressed="String(chip.active)" :aria-disabled="String(!chip.available)" :aria-label="chip.ariaLabel" @click="selectMapSource(chip)"><span class="map-stack-chip-label">{{ chip.label }}</span><span v-if="chip.requirement" class="map-stack-chip-req">{{ chip.requirement }}</span></button></div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="style-mini-status" class="panel-mini-status" aria-live="polite">
|
||||
|
||||
Reference in new issue
Block a user