diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 621c1fe06..8f2f7ac4f 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -96,11 +96,12 @@ They contain no application state. descriptor before acting), Display (in progress: `DisplayPanel` renders the HUD, Sonar, Scope, Celestial, detection, bloom and sharpen from the `gev-display` store, which VisualSettings publishes to, and the 3D - aircraft group and the Draw controls, which AircraftDisplay and the draw - tool publish beside it; the style parameter rows render through Vue - behind `createStyleParameters()`, as a render function so the portable - `./ui/input` entry needs no component compiler; the other groups follow - one owner at a time), + aircraft group, the Draw controls and the imported file list, which + AircraftDisplay, the draw tool and the file import publish beside it; the + file import restores keyboard focus once the list has rendered; the + style parameter rows render through Vue behind `createStyleParameters()`, + as a render function so the portable `./ui/input` entry needs no + component compiler; the other groups follow one owner at a time), Context and Radio, the command dock, the phone shell, then Cockpit. Layers that write into the right rail directly gain a slot instead. diff --git a/src/app/tools.js b/src/app/tools.js index c200abbba..943815a68 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { SceneDirector } from '../scenes/director.js'; import { initAnnotations } from '../annotations/index.js'; import { initDrawTool } from '../annotations/drawTool.js'; @@ -73,6 +74,10 @@ export function createApplicationTools({ navigation: styleManager, ground: operations.surface?.groundFloor, subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener), + publish: (view) => { + displayStore.publishGeoImport(view); + return nextTick(); + }, }); defer(() => geoFileImport?.destroy()); // DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the diff --git a/src/ui/displayPanel.test.mjs b/src/ui/displayPanel.test.mjs index b666774f5..602cc902f 100644 --- a/src/ui/displayPanel.test.mjs +++ b/src/ui/displayPanel.test.mjs @@ -9,6 +9,7 @@ import { useDisplayStore } from './stores/display.js'; import DisplayPanel from './views/panels/DisplayPanel.vue'; import { AircraftDisplay } from './aircraftDisplay.js'; import { VisualSettings } from './visualSettings.js'; +import { initGeoFileImport } from './geoFileImport.js'; function fixture(t) { document.body.replaceChildren(); @@ -370,3 +371,88 @@ test('the Draw controls render what the draw tool publishes', async (t) => { assert.equal($('draw-mode-row').classList.contains('visible'), false); assert.equal($('draw-label-row').classList.contains('visible'), false); }); + +test('the imported files render what the file import publishes and keep focus', async (t) => { + const { store, $ } = fixture(t); + const list = $('geo-import-list'); + assert.equal(list.className, 'geo-import-list'); + assert.equal(list.children.length, 0); + assert.equal($('geo-import-hint').textContent, ''); + + const viewer = { + container: document.createElement('div'), + scene: { requestRender() {} }, + dataSources: { add: async (ds) => ds, remove: () => true }, + }; + const tool = initGeoFileImport({ + viewer, + load: async (file) => ({ + dataSource: { show: true }, + name: file.name, + format: file.name.endsWith('.kml') ? 'kml' : 'geojson', + entityCount: 2, + removed: 0, + revoke() {}, + }), + flyToSource() {}, + publish: (view) => { + store.publishGeoImport(view); + return nextTick(); + }, + }); + t.after(() => tool.destroy()); + await tool.importFiles([{ name: 'a.geojson' }, { name: 'b.kml' }]); + await nextTick(); + assert.equal(list.className, 'geo-import-list visible'); + assert.equal($('geo-import-hint').textContent, 'Added 2 files.'); + const rowsNow = () => [...list.querySelectorAll('.geo-import-row')]; + const [first, second] = rowsNow(); + assert.equal(first.children.length, 5); + assert.equal( + [...first.childNodes].some((node) => node.nodeType === 3 && node.data), + false, + 'no text between the parts', + ); + const [swatch, name, toggle] = first.children; + assert.equal(swatch.className, 'geo-import-swatch'); + assert.ok(swatch.style.background); + assert.equal(name.textContent, 'a.geojson'); + assert.equal(name.children.length, 0, 'the name is text'); + assert.match(name.title, /GEOJSON, 2 map objects$/); + assert.equal(toggle.className, 'geo-import-action'); + assert.equal(toggle.getAttribute('type'), 'button'); + assert.equal(toggle.getAttribute('aria-pressed'), 'true'); + assert.equal(toggle.getAttribute('aria-label'), 'Hide a.geojson'); + assert.equal(toggle.textContent, 'visibility'); + assert.equal(first.children[3].hasAttribute('aria-pressed'), false); + assert.equal(second.children[0].className, 'geo-import-swatch own-styles'); + assert.equal(second.children[0].getAttribute('style'), null); + + const button = (row, action) => + row.querySelector(`button[data-action="${action}"]`); + // The click lands on the icon inside the button, as in a browser. + const press = async (el) => { + el.focus(); + el.firstElementChild.click(); + await nextTick(); + await nextTick(); + }; + await press(button(first, 'toggle')); + assert.equal(rowsNow()[0] === first, true, 'the row is kept'); + assert.equal(document.activeElement === button(first, 'toggle'), true); + assert.equal(button(first, 'toggle').getAttribute('aria-pressed'), 'false'); + assert.equal(button(first, 'toggle').textContent, 'visibility_off'); + assert.equal(button(first, 'toggle').title, 'Show a.geojson'); + await press(button(first, 'toggle')); + assert.equal(document.activeElement === button(first, 'toggle'), true); + assert.equal(button(first, 'toggle').getAttribute('aria-pressed'), 'true'); + assert.equal(button(first, 'fly').title, 'Fly to a.geojson'); + assert.equal(button(first, 'remove').getAttribute('aria-label'), 'Remove a.geojson'); + + await press(button(first, 'remove')); + assert.equal(rowsNow().length, 1); + assert.equal(document.activeElement === button(second, 'remove'), true); + await press(button(second, 'remove')); + assert.equal(list.className, 'geo-import-list'); + assert.equal(document.activeElement === $('geo-import-button'), true); +}); diff --git a/src/ui/geoFileImport.js b/src/ui/geoFileImport.js index 8785f24fd..9390cbaf9 100644 --- a/src/ui/geoFileImport.js +++ b/src/ui/geoFileImport.js @@ -20,8 +20,11 @@ const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff']; /** * Wire the Import control. - * @param {{viewer: object, load?: Function, document?: Document}} deps + * @param {{viewer: object, load?: Function, document?: Document, + * publish?: Function}} deps * `load(file, {viewer, color})` defaults to `loadGeoFile`; tests pass a double. + * `publish({rows, hint})` hands the file list and hint to the view that + * renders them; it may return a promise that settles once they render. * @returns {{destroy: Function, importFiles: Function, list: Function, * setVisible: Function, flyTo: Function, remove: Function, * diagnostics: Function}|null} @@ -34,11 +37,11 @@ export function initGeoFileImport({ navigation = null, ground = null, subscribeMapSource = null, + publish = () => {}, }) { const button = doc?.getElementById('geo-import-button'); const input = doc?.getElementById('geo-import-input'); const listEl = doc?.getElementById('geo-import-list'); - const hint = doc?.getElementById('geo-import-hint'); if (!viewer || !button || !input || !listEl) return null; const imports = []; @@ -49,6 +52,7 @@ export function initGeoFileImport({ let destroyed = false; let dragDepth = 0; let cameraIntent = 0; + let hint = ''; const lifetime = new AbortController(); let removeMapSource; // Imports live for the page session: switching language (a reload) asks @@ -63,7 +67,8 @@ export function initGeoFileImport({ listeners.push([target, type, listener, options]); }; const setHint = (text) => { - if (hint) hint.textContent = text || ''; + hint = text || ''; + present(); }; const render = () => viewer.scene?.requestRender?.(); let groundTimer, @@ -121,77 +126,86 @@ export function initGeoFileImport({ if (!active || !listEl.contains?.(active)) return null; const row = active.closest?.('[data-import-id]'); return row - ? { id: row.dataset.importId, action: active.dataset?.action } + ? { + id: row.dataset.importId, + action: active.dataset?.action, + element: active, + next: [], + } : null; } function restoreFocus(was) { - if (!was) return; + // Focus moved on while the list rendered: leave it where it went. + const active = doc.activeElement; + if (active && active !== was.element && active !== doc.body) return; const rows = [...listEl.querySelectorAll('[data-import-id]')]; - let row = rows.find((r) => r.dataset.importId === was.id); + const rowOf = (id) => rows.find((r) => r.dataset.importId === id); // The row is gone (removed): the next row's same control, else the // previous row's, else the Import button. - if (!row && was.index != null) - row = rows[Math.min(was.index, rows.length - 1)]; + const row = [was.id, ...was.next].map(rowOf).find(Boolean); const target = row?.querySelector?.(`button[data-action="${was.action}"]`) || (row ? row.querySelector?.('button') : button); target?.focus?.(); } - function renderList(keep = focusedAction()) { - listEl.replaceChildren( - ...imports.map((item) => { - const row = doc.createElement('li'); - row.className = 'geo-import-row'; - row.dataset.importId = item.id; - const swatch = doc.createElement('span'); + /** Publish the file list and hint; the view renders them. */ + function present() { + return publish({ + rows: imports.map((item) => ({ + id: item.id, + name: item.name, // KML and KMZ draw with their own styles; their swatch says so // instead of claiming a color the globe does not show. - swatch.className = `geo-import-swatch${item.ownStyles ? ' own-styles' : ''}`; - if (!item.ownStyles) swatch.style.background = item.color; - const name = doc.createElement('span'); - name.className = 'geo-import-name'; - // textContent, never markup: the name comes from the file system. - name.textContent = item.name; - name.title = `${item.name} — ${item.format.toUpperCase()}, ${tn( + ownStyles: item.ownStyles, + color: item.color, + title: `${item.name} — ${item.format.toUpperCase()}, ${tn( item.entityCount, '{count} map object', '{count} map objects', { count: formatNumber(item.entityCount) }, - )}${item.ownStyles ? t(', drawn with its own styles') : ''}`; - const actions = [ - [ - item.visible ? 'visibility' : 'visibility_off', - item.visible + )}${item.ownStyles ? t(', drawn with its own styles') : ''}`, + actions: [ + { + icon: item.visible ? 'visibility' : 'visibility_off', + label: item.visible ? t('Hide {name}', { name: item.name }) : t('Show {name}', { name: item.name }), - 'toggle', - ], - ['my_location', t('Fly to {name}', { name: item.name }), 'fly'], - ['close', t('Remove {name}', { name: item.name }), 'remove'], - ].map(([icon, label, action]) => { - const btn = doc.createElement('button'); - btn.type = 'button'; - btn.className = 'geo-import-action'; - btn.dataset.action = action; - btn.title = label; - btn.setAttribute('aria-label', label); - if (action === 'toggle') - btn.setAttribute('aria-pressed', String(item.visible)); - const glyph = doc.createElement('span'); - glyph.className = 'material-symbols-outlined'; - glyph.setAttribute('aria-hidden', 'true'); - glyph.textContent = icon; - btn.append(glyph); - return btn; - }); - row.append(swatch, name, ...actions); - return row; - }), - ); - listEl.classList.toggle('visible', imports.length > 0); - restoreFocus(keep); + action: 'toggle', + pressed: String(item.visible), + }, + { + icon: 'my_location', + label: t('Fly to {name}', { name: item.name }), + action: 'fly', + }, + { + icon: 'close', + label: t('Remove {name}', { name: item.name }), + action: 'remove', + }, + ], + })), + hint, + }); + } + + // The focus to give back once the view renders; a second change before + // then keeps the first, whose control the page still shows. + let pendingFocus = null; + + function renderList(keep = focusedAction()) { + pendingFocus ||= keep; + const rendered = present(); + const was = pendingFocus; + // A removed row leaves the page only once the view renders. + if (was) + Promise.resolve(rendered).then(() => { + if (destroyed || pendingFocus !== was) return; + pendingFocus = null; + restoreFocus(was); + }); } const find = (id) => imports.find((item) => item.id === id) || null; @@ -249,7 +263,11 @@ export function initGeoFileImport({ const at = imports.findIndex((item) => item.id === id); if (at < 0) return false; const keep = focusedAction(); - if (keep) keep.index = at; + if (keep) + keep.next = [ + ...imports.slice(at + 1), + ...imports.slice(0, at).reverse(), + ].map((other) => other.id); const [item] = imports.splice(at, 1); viewer.dataSources?.remove?.(item.dataSource, true); item.revoke?.(); @@ -452,8 +470,6 @@ export function initGeoFileImport({ for (const [target, type, listener, options] of listeners.splice(0)) target.removeEventListener(type, listener, options); setDragging(false); - listEl.replaceChildren(); - listEl.classList.remove('visible'); setHint(''); if (globalThis.window?.__gevGeoImport === handle) delete globalThis.window.__gevGeoImport; diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs index ee66bf84f..570adbb9f 100644 --- a/src/ui/geoFileImport.test.mjs +++ b/src/ui/geoFileImport.test.mjs @@ -1,8 +1,9 @@ // Behavioral tests for DISPLAY ▸ Import: the controller is driven against // fake DOM and viewer doubles with an injected loader, and its observable -// effects are asserted — data sources added and removed, list rows, the hint, -// drop handling, the file limit, and what destroy() gives back. Real parsing -// and rendering run in `scripts/qa-geo-import.mjs`. +// effects are asserted — data sources added and removed, the rows and hint it +// publishes, drop handling, the file limit, and what destroy() gives back. +// The Display panel renders the published list (`displayPanel.test.mjs`); +// real parsing and rendering run in `scripts/qa-geo-import.mjs`. import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -135,7 +136,13 @@ class FakeElement { } } -function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) { +function setup({ + load, + navigation, + flyToSource, + subscribeMapSource, + rendered, +} = {}) { focus.active = null; const els = { 'geo-import-button': new FakeElement('button', 'geo-import-button'), @@ -156,6 +163,7 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) { const removed = []; const flights = []; let renders = 0; + let shown = null; const viewer = { container: new FakeElement('div', 'cesiumContainer'), scene: { requestRender: () => (renders += 1) }, @@ -193,6 +201,10 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) { ...(navigation ? { navigation } : {}), ...(subscribeMapSource ? { subscribeMapSource } : {}), flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)), + publish: (view) => { + shown = view; + return rendered?.(); + }, }); return { tool, @@ -205,12 +217,25 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) { revoked, loads, renders: () => renders, + shown: () => shown, }; } const file = (name, extra = {}) => ({ name, size: 10, ...extra }); -const rows = (els) => els['geo-import-list'].children; -const action = (row, name) => - row.children.find((c) => c.dataset.action === name); +const rows = (t) => t.shown().rows; +const action = (row, name) => row.actions.find((a) => a.action === name); +// A click on a row button, as the list's delegated listener receives it. +const press = (t, row, name) => + t.els['geo-import-list'].emit('click', { + target: { + closest: (selector) => + selector === 'button[data-action]' + ? { + dataset: { action: name }, + closest: () => ({ dataset: { importId: row.id } }), + } + : null, + }, + }); // Runs first: tools left by later tests would answer the same registry. test('imported files make the language switch ask first', async () => { @@ -256,24 +281,19 @@ test('the Import button opens the picker and a pick imports each file', async () t.loads[1].opts.color, 'each file gets its own color', ); - assert.equal(rows(t.els).length, 2); - assert.ok(t.els['geo-import-list'].classList.contains('visible')); + assert.equal(rows(t).length, 2); assert.equal(t.flights.length, 1, 'the first file of a batch is flown to'); - assert.equal(t.els['geo-import-hint'].textContent, 'Added 2 files.'); + assert.equal(t.shown().hint, 'Added 2 files.'); assert.deepEqual( t.tool.list().map((f) => f.name), ['a.geojson', 'b.kml'], ); }); -test('file names are text, never markup', async () => { +test('file names are published as given, for the view to show as text', async () => { const t = setup(); await t.tool.importFiles([file('.kml')]); - const name = rows(t.els)[0].children.find((c) => - c.classList.contains('geo-import-name'), - ); - assert.equal(name.textContent, '.kml'); - assert.equal(name.innerHTML, undefined); + assert.equal(rows(t)[0].name, '.kml'); }); test('row buttons hide, show, fly to and remove a file', async () => { @@ -281,19 +301,14 @@ test('row buttons hide, show, fly to and remove a file', async () => { await t.tool.importFiles([file('a.gpx')]); const [{ id }] = t.tool.list(); const ds = t.added[0]; - // the click lands on the glyph inside the button, as in a browser - const glyphClick = (name) => - t.els['geo-import-list'].emit('click', { - target: action(rows(t.els)[0], name).children[0], - }); + const glyphClick = (name) => press(t, rows(t)[0], name); assert.equal(ds.show, true); glyphClick('toggle'); assert.equal(ds.show, false); assert.equal(t.tool.list()[0].visible, false); - assert.equal( - action(rows(t.els)[0], 'toggle').getAttribute('aria-pressed'), - 'false', - ); + assert.equal(action(rows(t)[0], 'toggle').pressed, 'false'); + assert.equal(action(rows(t)[0], 'toggle').icon, 'visibility_off'); + assert.equal(action(rows(t)[0], 'toggle').label, 'Show a.gpx'); glyphClick('fly'); assert.equal(ds.show, true, 'flying to a hidden file shows it'); assert.equal(t.flights.at(-1), ds); @@ -301,7 +316,7 @@ test('row buttons hide, show, fly to and remove a file', async () => { assert.deepEqual(t.removed, [{ ds, destroy: true }]); assert.deepEqual(t.revoked, ['a.gpx']); assert.equal(t.tool.list().length, 0); - assert.ok(!t.els['geo-import-list'].classList.contains('visible')); + assert.deepEqual(rows(t), []); assert.equal(t.tool.remove(id), false); }); @@ -319,7 +334,7 @@ test('a refused file says why and does not stop the rest of the batch', async () assert.equal(out[0].error, 'That KML file is not valid XML.'); assert.match(out[1].error, /could not be read\./); assert.match( - t.els['geo-import-hint'].textContent, + t.shown().hint, /^Added 1 file\. 2 files could not be added \(bad\.kml: That KML file is not valid XML\.\)/, ); assert.equal(t.tool.list().length, 1); @@ -387,7 +402,7 @@ test('destroy removes every file and listener and the window handle', async () = t.els['geo-import-list'], ]) assert.equal(el.listeners.length, 0); - assert.equal(rows(t.els).length, 0); + assert.deepEqual(t.shown(), { rows: [], hint: '' }); assert.equal(globalThis.window.__gevGeoImport, undefined); assert.equal(t.tool.diagnostics().destroyed, true); } finally { @@ -436,6 +451,11 @@ test('the application composition wires the import and owns its teardown', () => /navigation: styleManager/, ); assert.ok(tools.indexOf('defer(() => geoFileImport?.destroy());') > importAt); + // The list renders from the store once Vue has flushed. + assert.match( + tools.slice(importAt, importAt + 400), + /displayStore\.publishGeoImport\(view\);\s*return nextTick\(\);/, + ); const markup = fs.readFileSync( new URL('./views/panels/DisplayPanel.vue', import.meta.url), 'utf8', @@ -665,32 +685,83 @@ test('a refused deferred stamp (Cockpit) imports without flying', async () => { assert.equal(flown.length, 0); }); -test('keyboard focus stays on the control after hide, show and remove', async () => { - const t = setup(); - await t.tool.importFiles([file('a.geojson'), file('b.geojson')]); - // Clicks reach the list's delegated listener, as they bubble in a page. - const press = (btn) => - t.els['geo-import-list'].emit('click', { target: btn }); - const [first] = rows(t.els); - action(first, 'toggle').focus(); - press(action(first, 'toggle')); - // The list was rebuilt; focus is on the NEW toggle of the same row. - const again = rows(t.els)[0]; - assert.notEqual(again, first); - assert.equal(t.document.activeElement, action(again, 'toggle')); - assert.equal(action(again, 'toggle').getAttribute('aria-pressed'), 'false'); - press(action(again, 'toggle')); - assert.equal(t.document.activeElement, action(rows(t.els)[0], 'toggle')); - // Remove the first row: focus moves to the same control on the next row. - action(rows(t.els)[0], 'remove').focus(); - press(action(rows(t.els)[0], 'remove')); - assert.equal(rows(t.els).length, 1); - assert.equal(t.document.activeElement, action(rows(t.els)[0], 'remove')); - // Remove the last row: focus goes back to the Import button. - press(action(rows(t.els)[0], 'remove')); +// The rendered view as the owner reads it back: a row per published file, +// each with its buttons. `settle()` finishes the held render. +async function heldRender(names) { + let settle = () => {}; + const t = setup({ rendered: () => new Promise((r) => (settle = r)) }); + await t.tool.importFiles(names.map((name) => file(name))); + const list = t.els['geo-import-list']; + const renderRow = ({ id }) => { + const row = new FakeElement('li'); + row.dataset.importId = id; + for (const name of ['toggle', 'fly', 'remove']) { + const btn = new FakeElement('button'); + btn.dataset.action = name; + row.append(btn); + } + return row; + }; + const render = async () => { + list.replaceChildren(...rows(t).map(renderRow)); + settle(); + await new Promise((r) => setImmediate(r)); + }; + await render(); + const removeButton = (at) => list.children[at].children[2]; + return { t, list, render, removeButton }; +} + +test('keyboard focus moves on only once the list without the removed row renders', async () => { + const { t, list, render, removeButton } = await heldRender([ + 'a.geojson', + 'b.geojson', + ]); + const first = removeButton(0); + first.focus(); + press(t, rows(t)[0], 'remove'); + await new Promise((r) => setImmediate(r)); + assert.equal(t.document.activeElement, first, 'not before the view renders'); + await render(); + assert.equal(list.children.length, 1); + assert.equal( + t.document.activeElement, + removeButton(0), + 'the same control on the next row', + ); + press(t, rows(t)[0], 'remove'); + await render(); assert.equal(t.document.activeElement, t.els['geo-import-button']); }); +test('focus moved elsewhere while the list renders stays where it went', async () => { + const { t, render, removeButton } = await heldRender([ + 'a.geojson', + 'b.geojson', + ]); + removeButton(0).focus(); + press(t, rows(t)[0], 'remove'); + t.els['geo-import-input'].focus(); + await render(); + assert.equal(t.document.activeElement, t.els['geo-import-input']); +}); + +test('two removes before one render give focus to the first one’s next row', async () => { + const names = ['a', 'b', 'c', 'd', 'e'].map((n) => `${n}.geojson`); + const { t, list, render, removeButton } = await heldRender(names); + removeButton(1).focus(); + press(t, rows(t)[1], 'remove'); + // The view has not rendered: b's button still holds focus. + press(t, rows(t)[2], 'remove'); + await render(); + assert.deepEqual( + t.tool.list().map((f) => f.name), + ['a.geojson', 'c.geojson', 'e.geojson'], + ); + assert.equal(t.document.activeElement, removeButton(1)); + assert.equal(list.children[1].dataset.importId, rows(t)[1].id); +}); + test('KML and KMZ rows say they keep their own styles instead of showing a color', async () => { const t = setup({ load: async (f) => ({ @@ -703,12 +774,11 @@ test('KML and KMZ rows say they keep their own styles instead of showing a color }), }); await t.tool.importFiles([file('a.kml'), file('b.gpx')]); - const [kml, gpx] = rows(t.els); - assert.ok(kml.children[0].classList.contains('own-styles')); - assert.equal(kml.children[0].style.background, undefined); - assert.match(kml.children[1].title, /drawn with its own styles/); - assert.ok(!gpx.children[0].classList.contains('own-styles')); - assert.ok(gpx.children[0].style.background); + const [kml, gpx] = rows(t); + assert.equal(kml.ownStyles, true); + assert.match(kml.title, /drawn with its own styles/); + assert.equal(gpx.ownStyles, false); + assert.ok(gpx.color); }); test('the map-object count in a row title keeps digit grouping', async () => { @@ -723,7 +793,7 @@ test('the map-object count in a row title keeps digit grouping', async () => { }), }); await t.tool.importFiles([file('big.geojson')]); - const [row] = rows(t.els); - assert.match(row.children[1].title, /12,345 map objects/); + const [row] = rows(t); + assert.match(row.title, /12,345 map objects/); t.tool.destroy(); }); diff --git a/src/ui/stores/display.js b/src/ui/stores/display.js index 53c904c7d..cad6c5dd2 100644 --- a/src/ui/stores/display.js +++ b/src/ui/stores/display.js @@ -3,9 +3,9 @@ import { markRaw, shallowRef } from 'vue'; /** * The Display panel as its view renders it. VisualSettings presents its - * groups (`_presentDisplay()`), AircraftDisplay the 3D aircraft group and - * the draw tool its controls, and each publishes here; each keeps its - * listeners on the controls. + * 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. */ export const useDisplayStore = defineStore('gev-display', () => { /** The presented panel; null until VisualSettings first renders. */ @@ -32,5 +32,22 @@ export const useDisplayStore = defineStore('gev-display', () => { draw.value = markRaw(next); } - return { view, publish, models3d, publishModels3d, draw, publishDraw }; + /** The imported files and hint; null until the file import renders. */ + const geoImport = shallowRef(null); + + /** @param {object} next A fresh presentation of the imported files. */ + function publishGeoImport(next) { + geoImport.value = markRaw(next); + } + + return { + view, + publish, + models3d, + publishModels3d, + draw, + publishDraw, + geoImport, + publishGeoImport, + }; }); diff --git a/src/ui/views/panels/DisplayPanel.vue b/src/ui/views/panels/DisplayPanel.vue index 6492fd2f8..6509f9b94 100644 --- a/src/ui/views/panels/DisplayPanel.vue +++ b/src/ui/views/panels/DisplayPanel.vue @@ -6,11 +6,10 @@ import { vRangeValue } from '../../rail/rangeValue.js'; import { useDisplayStore } from '../../stores/display.js'; // The Display panel. VisualSettings presents the HUD, Sonar, Scope, -// Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and -// the draw tool its controls, and each publishes them; until they do, the -// panel shows its initial markup. The other groups keep their owners' -// element bindings. -const { view, models3d, draw } = storeToRefs(useDisplayStore()); +// Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group, the +// draw tool its controls and the file import its list, and each publishes +// them; until they do, the panel shows its initial markup. +const { view, models3d, draw, geoImport } = storeToRefs(useDisplayStore()); const pct = (value) => `${value}%`; const hud = computed(() => view.value?.hud ?? null); const sonar = computed(() => view.value?.sonar ?? null); @@ -37,6 +36,8 @@ const modelsSegment = segment(modelsMode); const drawShape = computed(() => draw.value?.shape ?? 'area'); const drawSegment = segment(drawShape); const allocationSegment = segment(allocation); +const geoRows = computed(() => geoImport.value?.rows ?? []); +const geoListed = computed(() => geoRows.value.length > 0);