feat(ui): render the Display panel's imported file list from the store

The file import publishes its rows and hint to the gev-display store and DisplayPanel renders them, keyed by file, so a toggled row keeps its controls. Keyboard focus is given back once the list has rendered: a removed row's next row, else the previous one, else the Import button; a second change before the render keeps the first target, and focus moved elsewhere meanwhile stays there.
This commit is contained in:
Sameh Khamis committed 2026-10-31 00:05:25 -07:00
1 parent 9eb969c682
commit a068a45a89
7 files changed
+327 -131

No files matched your search

+6 -5
View File
@@ -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.
+5
View File
@@ -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
+86
View File
@@ -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: '<b>a</b>.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, '<b>a</b>.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 <b>a</b>.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 <b>a</b>.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 <b>a</b>.geojson');
assert.equal(button(first, 'remove').getAttribute('aria-label'), 'Remove <b>a</b>.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);
});
+72 -56
View File
@@ -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;
+129 -59
View File
@@ -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('<img src=x onerror=alert(1)>.kml')]);
const name = rows(t.els)[0].children.find((c) =>
c.classList.contains('geo-import-name'),
);
assert.equal(name.textContent, '<img src=x onerror=alert(1)>.kml');
assert.equal(name.innerHTML, undefined);
assert.equal(rows(t)[0].name, '<img src=x onerror=alert(1)>.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();
});
+21 -4
View File
@@ -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,
};
});
+8 -7
View File
@@ -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);
</script>
<template>
@@ -190,8 +191,8 @@ const allocationSegment = segment(allocation);
<span class="pp-label" data-i18n>Import</span>
</button>
<input type="file" id="geo-import-input" accept=".geojson,.json,.kml,.kmz,.gpx" multiple hidden aria-label="Choose GeoJSON, KML, KMZ or GPX files" data-i18n-aria-label>
<ul class="geo-import-list" id="geo-import-list" aria-label="Imported files" data-i18n-aria-label></ul>
<span class="draw-hint geo-import-hint" id="geo-import-hint" aria-live="polite"></span>
<ul class="geo-import-list" :class="{ visible: geoListed }" id="geo-import-list" aria-label="Imported files" data-i18n-aria-label><li v-for="row in geoRows" :key="row.id" class="geo-import-row" :data-import-id="row.id"><span :class="['geo-import-swatch', { 'own-styles': row.ownStyles }]" :style="row.ownStyles ? null : { background: row.color }"></span><span class="geo-import-name" :title="row.title">{{ row.name }}</span><button v-for="item in row.actions" :key="item.action" type="button" class="geo-import-action" :data-action="item.action" :title="item.label" :aria-label="item.label" :aria-pressed="item.pressed"><span class="material-symbols-outlined" aria-hidden="true">{{ item.icon }}</span></button></li></ul>
<span class="draw-hint geo-import-hint" id="geo-import-hint" aria-live="polite">{{ geoImport?.hint ?? '' }}</span>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" :class="{ active: celestial?.active }" id="celestial-toggle" :title="celestialTitle" :aria-pressed="String(celestial?.active ?? false)" :disabled="celestial ? !celestial.supported : undefined" :aria-disabled="celestial ? String(!celestial.supported) : undefined">