Port the Data Library Browse dialog to Vue and Pinia

The dialog is now DataLibraryDialog.vue (with DataLibraryCard.vue for each
entry), placed with the Browse button by DataLibraryBrowse.vue in
LayerPanelsView and teleported to the body. It renders what the
gev-data-library store holds and sends clicks and typing to the owner.

src/ui/libraryBrowser.js is now the framework-free owner: it presents the
open state, filters, the rows they show, the stat summary and Remove all
through `publish`, and its controls answer the view. Its text helpers are
what the view renders with t(), so every label follows the locale without
rebuilding anything. Only showModal/close and focus moves stay imperative;
focus returns to Browse also when the browser closes the dialog on Escape
itself.

Component tests mount the view over the real owner (open, filters, Add to
Remove keeping focus, Zoom, Remove all, a language switch, clean view).
This commit is contained in:
Said Turksever committed 2026-11-12 23:01:00 +00:00
1 parent 24ccdd4dc3
commit 1a2589d4ef
11 files changed
+773 -474

No files matched your search

+6 -2
View File
@@ -600,8 +600,12 @@ partition index whose footers keep failing after two retries is
`complete: false`: neither the server nor the browser keeps it as whole, a
complete earlier index is served instead, and it is rebuilt after five
minutes. A view that leaves partitions touching it unread is PARTIAL. Listing
pages and footer reads are size-capped and ranges are validated. The Browse
dialog rebuilds on a language switch, keeping its filters.
pages and footer reads are size-capped and ranges are validated. The Browse dialog is a Vue view (src/ui/views/panels/DataLibraryDialog.vue
and DataLibraryCard.vue, teleported to the body from LayerPanelsView) over the
`gev-data-library` Pinia store; src/ui/libraryBrowser.js is its framework-free
owner (open state, filters, the rows they show, Remove all) and the text
helpers the view renders, so every label follows the locale. Only the modal
open/close and focus moves are imperative.
## Utilities panel — October 29, 2026
+7 -1
View File
@@ -105,6 +105,11 @@
"src/ui/views/panels/DrawSwatches.vue",
"src/annotations/drawMarks.js",
"src/ui/stores/display.js",
"src/ui/stores/dataLibrary.js",
"src/ui/views/panels/DataLibraryCard.vue",
"src/ui/views/panels/DataLibraryDialog.vue",
"src/ui/views/panels/DataLibraryBrowse.vue",
"src/ui/libraryBrowser.js",
"src/ui/localSdrPresentation.js",
"src/ui/unitLabels.js",
"src/layers/localAdsb/feedNames.js",
@@ -3750,7 +3755,8 @@
"src/layers/satellites/sensors.js",
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js"
"src/data/planespottersPhoto.js",
"src/ui/stores/dataLibrary.js"
],
"external": [
"hls.js",
+12 -6
View File
@@ -13,7 +13,8 @@ import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { createDataLibrary } from '../library/controller.js';
import { flyToLibraryExtent } from '../library/camera.js';
import { mountLibraryBrowser } from '../ui/libraryBrowser.js';
import { createLibraryBrowser } from '../ui/libraryBrowser.js';
import { useDataLibraryStore } from '../ui/stores/dataLibrary.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installViews, isEmbeddedInline } from './embed.js';
@@ -131,13 +132,18 @@ export function createApplicationTools({
) !== false,
});
defer(() => dataLibrary.destroy());
const libraryTrigger = document.getElementById('data-library-browse');
const libraryBrowser = mountLibraryBrowser({
// Browse: the owner presents the dialog through the store; LayerPanelsView
// draws the Browse button and the dialog from it.
const libraryStore = useDataLibraryStore(applicationPinia());
const libraryBrowser = createLibraryBrowser({
library: dataLibrary,
trigger: libraryTrigger,
publish: (view) => libraryStore.publish(view),
});
libraryStore.connect(libraryBrowser.controls);
defer(() => {
libraryBrowser.destroy();
libraryStore.connect(null);
});
defer(() => libraryBrowser.destroy());
if (libraryTrigger) libraryTrigger.hidden = false;
dataLibrary.start({ signal }).catch((error) => {
if (!signal?.aborted) console.warn('[Library] start failed:', error);
});
+170 -406
View File
@@ -1,13 +1,20 @@
/**
* DATA LAYERS ▸ Browse data library: the dialog that lists every Data Library
* entry this build knows, with its licence, coverage and what this instance
* needs to serve it, and adds or removes it from the panel.
* DATA LAYERS ▸ Browse data library: the owner of the dialog that lists every
* Data Library entry this build knows, with its licence, coverage and what
* this instance needs to serve it, and adds or removes it from the panel.
*
* A `dialog[data-panel-surface]` per docs/panel-surfaces.md: this module owns
* width, height, labels, initial and return focus, Escape and dismissal, and
* closes itself when clean view, recording or Cockpit hides the panels.
* Framework-free: `createLibraryBrowser` presents the dialog's state (open,
* filters, the rows they show, the stat summary, Remove all) through
* `publish`, and its `controls` answer the view
* (src/ui/views/panels/DataLibraryDialog.vue, placed by LayerPanelsView). The
* text helpers below are what the view renders; they call `t()`, so text
* rendered through them follows the locale.
*
* A `dialog[data-panel-surface]` per docs/panel-surfaces.md: the view owns
* width, height, labels and focus; this owner closes the dialog when clean
* view, recording or Cockpit hides the panels.
*/
import { formatNumber, msg, onLocaleChange, t, tn } from '../i18n/index.js';
import { formatNumber, msg, t, tn } from '../i18n/index.js';
const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode'];
/** How long Remove all stays armed after its first press. */
@@ -40,20 +47,43 @@ const STATUS_LABELS = Object.freeze({
archived: msg('ARCHIVED'),
});
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
/** The Theme filter's choices, translated: all themes first. */
export function libraryThemeOptions() {
return [
{ value: '', label: t('All themes') },
...Object.entries(FAMILY_LABELS).map(([value, label]) => ({
value,
label: t(label),
})),
];
}
function badge(text, tone = '') {
const node = element(
'span',
`data-library-badge${tone ? ` data-library-badge--${tone}` : ''}`,
text,
);
return node;
/**
* An entry card's badges: kind, status, then how it is served (operator
* opt-in, a provider key, or keyless). `tone` is '', 'good' or 'warn'.
* @param {object} entry
* @returns {Array<{ label: string, tone: string }>}
*/
export function libraryBadges(entry) {
const badges = [
{ label: t(KIND_LABELS[entry.kind]), tone: '' },
{
label: t(STATUS_LABELS[entry.status]),
tone:
entry.status === 'verified'
? 'good'
: entry.status === 'stale'
? 'warn'
: '',
},
];
if (entry.licence.operatorOptIn)
badges.push({ label: t('OPT-IN'), tone: 'warn' });
else if (entry.kind !== 'pack' && entry.source.key)
badges.push({ label: t('KEY'), tone: 'warn' });
else if (entry.kind !== 'pack')
badges.push({ label: t('KEYLESS'), tone: '' });
return badges;
}
const compact = (value) =>
@@ -129,7 +159,8 @@ export function libraryReasonText({ entry, available, reason }) {
return t('Checking what this instance serves…');
}
function coverageText(entry) {
/** Where an entry covers, for its card line. */
export function coverageText(entry) {
const { coverage } = entry;
if (coverage.global) return t('Global');
if (coverage.countries?.length) return coverage.countries.join(' · ');
@@ -210,32 +241,27 @@ export function sortLibraryRows(rows) {
}
/**
* @param {{ library: ReturnType<import('../library/controller.js').createDataLibrary>, trigger: HTMLElement|null }} options
* @returns {{ open: () => void, close: () => void, destroy: () => void }}
* The Browse dialog's owner. Presents its state through `publish` and answers
* the view through `controls`; never touches the dialog's markup.
* @param {{ library: ReturnType<import('../library/controller.js').createDataLibrary>, publish?: (view: object|null) => void, body?: HTMLElement|null }} options
* @returns {{ controls: object, open: () => void, close: () => void, destroy: () => void }}
*/
export function mountLibraryBrowser({ library, trigger }) {
const removers = [];
const listen = (node, type, listener) => {
node.addEventListener(type, listener);
removers.push(() => node.removeEventListener(type, listener));
};
// Listeners on the dialog's own nodes, released when it is rebuilt.
const dialogRemovers = [];
const listenDialog = (node, type, listener) => {
node.addEventListener(type, listener);
dialogRemovers.push(() => node.removeEventListener(type, listener));
};
export function createLibraryBrowser({
library,
publish = () => {},
body = globalThis.document?.body ?? null,
}) {
const filter = { query: '', family: '', availableOnly: false };
let dialog = null;
let list = null;
let status = null;
let stats = null;
let open = false;
let destroyed = false;
let rows = [];
let statsTimer = null;
let resetButton = null;
// Remove all asks twice: the first press arms it for a few seconds.
let resetArmed = false;
let resetTimer = null;
let resetting = false;
// Bumped when focus should go back to the search field (after Remove all).
let focusSearch = 0;
function disarmReset() {
clearTimeout(resetTimer);
@@ -243,396 +269,134 @@ export function mountLibraryBrowser({ library, trigger }) {
resetArmed = false;
}
function build() {
dialog = element('dialog', 'data-library-dialog');
dialog.id = 'data-library';
dialog.setAttribute('data-panel-surface', '');
dialog.setAttribute('aria-labelledby', 'data-library-title');
dialog.setAttribute('aria-describedby', 'data-library-intro');
const header = element('header', 'data-library-header');
header.setAttribute('data-panel-header', '');
const title = element(
'h2',
'panel-title panel-surface-title',
t('DATA LIBRARY'),
);
title.id = 'data-library-title';
const close = element(
'button',
'panel-surface-control data-library-close',
'×',
);
close.type = 'button';
close.setAttribute('aria-label', t('Close data library'));
listenDialog(close, 'click', () => api.close());
header.append(title, close);
const body = element('div', 'data-library-body');
body.setAttribute('data-panel-body', '');
stats = element('dl', 'data-library-stats');
stats.setAttribute('aria-label', t('Data Library at a glance'));
const intro = element(
'p',
'data-library-intro',
t(
'Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.',
),
);
intro.id = 'data-library-intro';
const filters = element('div', 'data-library-filters');
const searchLabel = element('label', 'data-library-field');
searchLabel.append(element('span', '', t('Search')));
const search = element('input', 'panel-surface-control');
search.type = 'search';
search.autocomplete = 'off';
search.placeholder = t('Name, provider or country');
search.value = filter.query;
listenDialog(search, 'input', () => {
filter.query = search.value;
render();
/**
* Publish the dialog. Rows are reread from the library unless only the
* stat tiles changed, so an unchanged row list keeps its identity and its
* cards do not re-render.
*/
function present({ rowsChanged = true } = {}) {
if (destroyed) return;
if (rowsChanged || !rows.length) rows = library.list();
const added = rows.filter((row) => row.added).length;
if (!added && !resetting && resetArmed) disarmReset();
publish({
open,
ready: library.ready,
filter: { ...filter },
shown: open
? sortLibraryRows(rows.filter((row) => libraryRowMatches(row, filter)))
: [],
total: rows.length,
added,
summary: library.summary(),
reset: {
visible: added > 0 || resetting,
armed: resetArmed,
busy: resetting,
},
focusSearch,
});
searchLabel.append(search);
}
const familyLabel = element('label', 'data-library-field');
familyLabel.append(element('span', '', t('Theme')));
const family = element('select', 'panel-surface-control');
const option = (text, value) => {
const node = element('option', '', text);
node.value = value;
return node;
};
family.append(option(t('All themes'), ''));
for (const [value, label] of Object.entries(FAMILY_LABELS))
family.append(option(t(label), value));
family.value = filter.family;
listenDialog(family, 'change', () => {
filter.family = family.value;
render();
});
familyLabel.append(family);
function close() {
disarmReset();
clearInterval(statsTimer);
statsTimer = null;
if (!open) return;
open = false;
present();
}
const availableLabel = element('label', 'data-library-check');
const available = element('input');
available.type = 'checkbox';
available.checked = filter.availableOnly;
listenDialog(available, 'change', () => {
filter.availableOnly = available.checked;
render();
});
availableLabel.append(
available,
element('span', '', t('Only what this instance serves')),
);
filters.append(searchLabel, familyLabel, availableLabel);
const toolbar = element('div', 'data-library-toolbar');
status = element('p', 'data-library-status');
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
resetButton = element('button', 'panel-surface-control data-library-reset');
resetButton.type = 'button';
listenDialog(resetButton, 'click', () => {
const controls = {
open() {
if (destroyed || open) return;
open = true;
present();
// Feature counts arrive as datasets finish loading, outside the
// library's own notifications; refresh the tiles while open.
clearInterval(statsTimer);
statsTimer = setInterval(
() => present({ rowsChanged: false }),
STATS_REFRESH_MS,
);
},
close,
setQuery(value) {
filter.query = String(value ?? '');
present({ rowsChanged: false });
},
setFamily(value) {
filter.family = String(value ?? '');
present({ rowsChanged: false });
},
setAvailableOnly(value) {
filter.availableOnly = Boolean(value);
present({ rowsChanged: false });
},
add(id) {
Promise.resolve(library.add(id)).catch((error) =>
console.warn(`[Library] ${id} failed:`, error),
);
},
remove(id) {
Promise.resolve(library.remove(id)).catch((error) =>
console.warn(`[Library] ${id} failed:`, error),
);
},
zoom(id) {
// Close first so the flight is visible; focus returns to Browse. An
// entry that is off is switched on first.
close();
Promise.resolve(library.zoom(id)).catch((error) =>
console.warn(`[Library] ${id} zoom failed:`, error),
);
},
reset() {
if (resetting) return;
if (!resetArmed) {
resetArmed = true;
resetTimer = setTimeout(() => {
disarmReset();
render();
present({ rowsChanged: false });
}, RESET_CONFIRM_MS);
render();
present({ rowsChanged: false });
return;
}
disarmReset();
resetting = true;
render();
present({ rowsChanged: false });
Promise.resolve(library.reset())
.catch((error) => console.warn('[Library] reset failed:', error))
.finally(() => {
resetting = false;
render();
dialog?.querySelector('input[type="search"]')?.focus();
focusSearch += 1;
present();
});
});
toolbar.append(status, resetButton);
list = element('ul', 'data-library-list');
listenDialog(list, 'click', (event) => {
const button = event.target?.closest?.('button[data-library-action]');
if (!button || button.disabled) return;
const id = button.dataset.libraryId;
const kind = button.dataset.libraryAction;
if (kind === 'zoom') {
// Close first so the flight is visible; focus returns to Browse.
// An entry that is off is switched on first.
api.close();
Promise.resolve(library.zoom(id)).catch((error) =>
console.warn(`[Library] ${id} zoom failed:`, error),
);
return;
}
const action = kind === 'add' ? library.add(id) : library.remove(id);
Promise.resolve(action).catch((error) =>
console.warn(`[Library] ${id} failed:`, error),
);
});
body.append(stats, intro, filters, toolbar, list);
dialog.append(header, body);
listenDialog(dialog, 'cancel', (event) => {
event.preventDefault();
api.close();
});
// A click on the backdrop (the dialog box itself, outside its content)
// dismisses, like Escape.
listenDialog(dialog, 'click', (event) => {
if (event.target === dialog) api.close();
});
document.body.append(dialog);
return search;
}
function renderRow({ entry, available, reason, added, on, busy, canZoom }) {
const item = element('li', 'data-library-item');
item.dataset.libraryId = entry.id;
item.classList.toggle('is-unavailable', !available);
const head = element('div', 'data-library-item-head');
const icon = element('span', 'data-library-icon', entry.icon);
icon.setAttribute('aria-hidden', 'true');
const name = element('span', 'data-library-name', entry.title);
const badges = element('span', 'data-library-badges');
badges.append(badge(t(KIND_LABELS[entry.kind])));
badges.append(
badge(
t(STATUS_LABELS[entry.status]),
entry.status === 'verified'
? 'good'
: entry.status === 'stale'
? 'warn'
: '',
),
);
if (entry.licence.operatorOptIn) badges.append(badge(t('OPT-IN'), 'warn'));
else if (entry.kind !== 'pack' && entry.source.key)
badges.append(badge(t('KEY'), 'warn'));
else if (entry.kind !== 'pack') badges.append(badge(t('KEYLESS')));
head.append(icon, name, badges);
const description = element('p', 'data-library-description');
description.textContent = entry.description;
const meta = element('p', 'data-library-meta');
const provider = element('span', '', entry.provider);
provider.setAttribute('translate', 'no');
const licence = element('a', '', entry.licence.name);
licence.href = entry.licence.url;
licence.target = '_blank';
licence.rel = 'noopener noreferrer';
meta.append(
provider,
document.createTextNode(' · '),
element('span', '', coverageText(entry)),
document.createTextNode(' · '),
licence,
);
item.append(head, description);
const specs = imagerySpecs(entry);
if (specs.length) {
const list = element('dl', 'data-library-specs');
for (const { label, value } of specs) {
const row = element('div', 'data-library-spec');
row.append(element('dt', '', label), element('dd', '', value));
list.append(row);
}
item.append(list);
}
item.append(meta);
const note = libraryReasonText({ entry, available, reason });
if (note) item.append(element('p', 'data-library-reason', note));
if (entry.kind !== 'pack') {
const actions = element('div', 'data-library-actions');
if (added && canZoom) {
const zoom = element(
'button',
'panel-surface-control data-library-action',
t('ZOOM'),
);
zoom.type = 'button';
zoom.dataset.libraryId = entry.id;
zoom.dataset.libraryAction = 'zoom';
zoom.disabled = busy;
zoom.setAttribute(
'aria-label',
t('Zoom to {name}', { name: entry.title }),
);
actions.append(zoom);
}
const action = element(
'button',
'panel-surface-control data-library-action',
);
action.type = 'button';
action.dataset.libraryId = entry.id;
action.dataset.libraryAction = added ? 'remove' : 'add';
action.textContent = busy
? t('WORKING…')
: added
? t('REMOVE')
: t('ADD');
action.disabled = busy || (!added && !available);
action.setAttribute(
'aria-label',
added
? t('Remove {name} from Data Layers', { name: entry.title })
: t('Add {name} to Data Layers', { name: entry.title }),
);
if (added && on) item.classList.add('is-on');
actions.append(action);
item.append(actions);
}
return item;
}
/** Rewrite the stat tiles in place; unchanged text is not touched. */
function renderStats() {
if (!dialog?.open || !stats) return;
for (const tile of libraryStatTiles(library.summary())) {
let node = stats.querySelector(`[data-stat="${tile.id}"]`);
if (!node) {
node = element('div', 'data-library-stat');
node.dataset.stat = tile.id;
node.append(
element('dt', 'data-library-stat-label'),
element('dd', 'data-library-stat-value'),
element('dd', 'data-library-stat-detail'),
);
stats.append(node);
}
const [label, value, detail] = node.children;
if (label.textContent !== tile.label) label.textContent = tile.label;
if (value.textContent !== tile.value) value.textContent = tile.value;
if (detail.textContent !== tile.detail) detail.textContent = tile.detail;
detail.hidden = !tile.detail;
}
}
function render() {
if (!dialog?.open) return;
renderStats();
const rows = library.list();
const shown = sortLibraryRows(
rows.filter((row) => libraryRowMatches(row, filter)),
);
const focusedId = document.activeElement?.dataset?.libraryId;
const focusedAction = document.activeElement?.dataset?.libraryAction;
list.replaceChildren(...shown.map(renderRow));
if (focusedId) {
const row = `button[data-library-id="${CSS.escape(focusedId)}"]`;
// The same control when it survived the render (Add becomes Remove).
(
list.querySelector(
`${row}[data-library-action="${CSS.escape(focusedAction || '')}"]`,
) ||
list.querySelector(`${row}:not([data-library-action="zoom"])`) ||
list.querySelector(row)
)?.focus();
}
const added = rows.filter((row) => row.added).length;
if (!added && !resetting) disarmReset();
resetButton.hidden = added === 0 && !resetting;
resetButton.disabled = resetting;
resetButton.classList.toggle('is-armed', resetArmed);
resetButton.textContent = resetting
? t('WORKING…')
: resetArmed
? tn(added, 'CONFIRM · REMOVE {count}', 'CONFIRM · REMOVE ALL {count}')
: t('REMOVE ALL');
resetButton.setAttribute(
'aria-label',
resetArmed
? t('Confirm: remove every added entry from Data Layers')
: t('Remove every added entry from Data Layers'),
);
// The stat tiles carry the totals; this line answers the filter.
status.textContent = !library.ready
? t('Checking what this instance serves…')
: t('Showing {shown} of {total}', {
shown: shown.length,
total: rows.length,
});
}
const api = {
open() {
const search = dialog ? null : build();
if (dialog.open) return;
render();
dialog.showModal();
render();
// Feature counts arrive as datasets finish loading, outside the
// library's own notifications; refresh the tiles while open.
clearInterval(statsTimer);
statsTimer = setInterval(renderStats, STATS_REFRESH_MS);
(search || dialog.querySelector('input[type="search"]'))?.focus();
},
close() {
disarmReset();
clearInterval(statsTimer);
statsTimer = null;
if (!dialog?.open) return;
dialog.close();
trigger?.focus?.();
},
destroy() {
api.close();
unsubscribe();
offLocale();
observer?.disconnect();
for (const remove of removers.splice(0)) remove();
for (const remove of dialogRemovers.splice(0)) remove();
dialog?.remove();
dialog = null;
},
};
/**
* Every label is translated when the dialog is built, so a language switch
* rebuilds it, keeping its filters and, if it was open, opening it again.
*/
function relabel() {
if (!dialog) return;
const wasOpen = dialog.open;
disarmReset();
clearInterval(statsTimer);
statsTimer = null;
if (wasOpen) dialog.close();
for (const remove of dialogRemovers.splice(0)) remove();
dialog.remove();
dialog = null;
if (wasOpen) api.open();
}
const offLocale = onLocaleChange(relabel);
const unsubscribe = library.subscribe(render);
if (trigger) listen(trigger, 'click', () => api.open());
const unsubscribe = library.subscribe(() => present());
const observer =
typeof MutationObserver === 'function'
typeof MutationObserver === 'function' && body
? new MutationObserver(() => {
if (
PANEL_HIDING_MODES.some((mode) =>
document.body.classList.contains(mode),
)
)
api.close();
if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode)))
close();
})
: null;
observer?.observe(document.body, {
attributes: true,
attributeFilter: ['class'],
});
return api;
observer?.observe(body, { attributes: true, attributeFilter: ['class'] });
present();
return {
controls,
open: controls.open,
close,
destroy() {
if (destroyed) return;
close();
destroyed = true;
unsubscribe();
observer?.disconnect();
publish(null);
},
};
}
-54
View File
@@ -1,54 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { installVueDom } from '../testSupport/vueDom.mjs';
import { setLocale, t } from '../i18n/index.js';
test('a language switch relabels the open dialog and keeps its filters', async (context) => {
context.after(installVueDom());
context.after(() => setLocale('en'));
const { mountLibraryBrowser } = await import('./libraryBrowser.js');
const library = {
ready: true,
subscribe: () => () => {},
list: () => [],
summary: () => ({
entries: 0,
kinds: { dataset: 0, tiles: 0, pack: 0 },
themes: [],
available: 0,
unavailable: 0,
added: 0,
on: 0,
features: 0,
featureLayers: 0,
}),
reset: async () => 0,
};
const browser = mountLibraryBrowser({ library, trigger: null });
context.after(() => browser.destroy());
browser.open();
const search = () =>
document.querySelector('#data-library input[type="search"]');
search().value = 'kartverket';
search().dispatchEvent(new Event('input'));
assert.equal(
document.querySelector('#data-library-title').textContent,
'DATA LIBRARY',
);
setLocale('de');
const title = document.querySelector('#data-library-title');
assert.equal(title.textContent, t('DATA LIBRARY'));
assert.notEqual(title.textContent, 'DATA LIBRARY');
assert.equal(document.querySelectorAll('#data-library').length, 1);
assert.equal(document.querySelector('#data-library').open, true);
assert.equal(search().value, 'kartverket');
// Closed and reopened later, it is still in the new language.
browser.close();
browser.open();
assert.equal(
document.querySelector('#data-library-title').textContent,
t('DATA LIBRARY'),
);
});
+29
View File
@@ -0,0 +1,29 @@
import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue';
/**
* DATA LAYERS ▸ Browse data library as its view renders it. The browser owner
* (src/ui/libraryBrowser.js) presents whether the dialog is open, the rows
* the filters show, the stat tiles' summary and Remove all's state, and
* publishes them here; the view sends clicks and typing to the connected
* owner.
*/
export const useDataLibraryStore = defineStore('gev-data-library', () => {
/** The presented browser; null until the owner first publishes. */
const view = shallowRef(null);
/** @param {?object} next A fresh presentation; every publish re-renders. */
function publish(next) {
view.value = next && markRaw(next);
}
/** The browser owner the controls call, or null while none is wired. */
const controls = shallowRef(null);
/** @param {?object} next The owner's controls, or null to disconnect. */
function connect(next) {
controls.value = next && markRaw(next);
}
return { view, publish, controls, connect };
});
+3 -5
View File
@@ -14,6 +14,7 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue';
import DrawControls from './panels/DrawControls.vue';
import GeoImport from './panels/GeoImport.vue';
import UiSelect from '../kit/UiSelect.js';
import DataLibraryBrowse from './panels/DataLibraryBrowse.vue';
// Scene sharing (src/scenes/sharing.js) offers its authoring bar and presents
// its dialog through this store; without this view, neither is drawn.
@@ -43,11 +44,8 @@ async function importChosen(event) {
<!-- Data Layer Toggle Panel -->
<RailPanel id="data-panel" start-collapsed class="active" :title="t('DATA LAYERS')">
<div id="data-toggles" class="data-toggle-list ui-scroll"></div>
<!-- Data Library: src/ui/libraryBrowser.js owns the dialog; the button
shows once the library controller is mounted (src/app/tools.js). -->
<div class="data-library-footer">
<button id="data-library-browse" type="button" class="data-library-browse" aria-haspopup="dialog" aria-controls="data-library" hidden>{{ t('BROWSE DATA LIBRARY') }}</button>
</div>
<!-- Data Library: Browse and its dialog (src/ui/libraryBrowser.js). -->
<DataLibraryBrowse />
</RailPanel>
<!-- Utilities: Directions (LayerPanel renders its row here), Draw, Import -->
+29
View File
@@ -0,0 +1,29 @@
<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../../i18n/index.js';
import UiButton from '../../kit/UiButton.vue';
import { useDataLibraryStore } from '../../stores/dataLibrary.js';
import DataLibraryDialog from './DataLibraryDialog.vue';
// DATA LAYERS ▸ Browse data library: the button at the foot of the panel and
// the dialog it opens, drawn once the library browser (src/ui/libraryBrowser.js)
// publishes. Kept apart from LayerPanelsView so the browser's publishes (the
// stat tiles refresh every second while open) re-render only this.
const { view, controls } = storeToRefs(useDataLibraryStore());
</script>
<template>
<div class="data-library-footer">
<UiButton
id="data-library-browse"
class="data-library-browse"
aria-haspopup="dialog"
aria-controls="data-library"
:hidden="!view"
:native-title="false"
@click="controls?.open()"
>{{ t('BROWSE DATA LIBRARY') }}</UiButton
>
</div>
<Teleport v-if="view" to="body"><DataLibraryDialog :view="view" /></Teleport>
</template>
+93
View File
@@ -0,0 +1,93 @@
<script setup>
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import UiButton from '../../kit/UiButton.vue';
import {
coverageText,
imagerySpecs,
libraryBadges,
libraryReasonText,
} from '../../libraryBrowser.js';
// One Data Library entry in Browse, as src/ui/libraryBrowser.js lists it:
// what it is, who provides it under what licence, what this instance needs
// to serve it, and Add / Remove / Zoom. The parent keys cards by entry id, so
// a card (and its focused button) survives Add becoming Remove.
const props = defineProps({ row: { type: Object, required: true } });
const emit = defineEmits(['add', 'remove', 'zoom']);
const entry = computed(() => props.row.entry);
const reason = computed(() => libraryReasonText(props.row));
const actionLabel = computed(() =>
props.row.added
? t('Remove {name} from Data Layers', { name: entry.value.title })
: t('Add {name} to Data Layers', { name: entry.value.title }),
);
</script>
<template>
<li
class="data-library-item"
:class="{
'is-unavailable': !row.available,
'is-on': entry.kind !== 'pack' && row.added && row.on,
}"
:data-library-id="entry.id"
>
<div class="data-library-item-head">
<span class="data-library-icon" aria-hidden="true">{{ entry.icon }}</span>
<span class="data-library-name">{{ entry.title }}</span>
<span class="data-library-badges">
<span
v-for="badge in libraryBadges(entry)"
:key="badge.label"
class="data-library-badge"
:class="badge.tone ? `data-library-badge--${badge.tone}` : undefined"
>{{ badge.label }}</span
>
</span>
</div>
<p class="data-library-description">{{ entry.description }}</p>
<dl v-if="entry.imagery" class="data-library-specs">
<div
v-for="spec in imagerySpecs(entry)"
:key="spec.label"
class="data-library-spec"
>
<dt>{{ spec.label }}</dt>
<dd>{{ spec.value }}</dd>
</div>
</dl>
<p class="data-library-meta">
<span translate="no">{{ entry.provider }}</span> ·
<span>{{ coverageText(entry) }}</span> ·
<a :href="entry.licence.url" target="_blank" rel="noopener noreferrer">{{
entry.licence.name
}}</a>
</p>
<p v-if="reason" class="data-library-reason">{{ reason }}</p>
<div v-if="entry.kind !== 'pack'" class="data-library-actions">
<UiButton
v-if="row.added && row.canZoom"
class="panel-surface-control data-library-action"
:data-library-id="entry.id"
data-library-action="zoom"
:disabled="row.busy"
:label="t('Zoom to {name}', { name: entry.title })"
:native-title="false"
@click="emit('zoom', entry.id)"
>{{ t('ZOOM') }}</UiButton
>
<UiButton
class="panel-surface-control data-library-action"
:data-library-id="entry.id"
:data-library-action="row.added ? 'remove' : 'add'"
:disabled="row.busy || (!row.added && !row.available)"
:label="actionLabel"
:native-title="false"
@click="emit(row.added ? 'remove' : 'add', entry.id)"
>{{ row.busy ? t('WORKING…') : row.added ? t('REMOVE') : t('ADD') }}</UiButton
>
</div>
</li>
</template>
@@ -0,0 +1,237 @@
// The Browse dialog renders what src/ui/libraryBrowser.js presents and sends
// clicks and typing back to it.
import '../../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, defineComponent, h, nextTick } from 'vue';
import { createPinia, storeToRefs } from 'pinia';
import { setLocale, t } from '../../../i18n/index.js';
import { libraryEntry } from '../../../library/catalog.js';
import { createLibraryBrowser } from '../../libraryBrowser.js';
import { useDataLibraryStore } from '../../stores/dataLibrary.js';
import { applicationI18n } from '../../i18n.js';
import DataLibraryDialog from './DataLibraryDialog.vue';
const IDS = ['nasa-eonet-events', 'eia-power-plants', 'cctv-nyc-dot'];
/** A library with three entries; Add and Remove change what it lists. */
function fakeLibrary() {
const added = new Set();
const listeners = new Set();
const notify = () => listeners.forEach((listener) => listener());
return {
ready: true,
added,
calls: [],
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
list: () =>
IDS.map((id) => ({
entry: libraryEntry(id),
available: id !== 'cctv-nyc-dot',
reason: id === 'cctv-nyc-dot' ? 'operator-opt-in' : null,
added: added.has(id),
on: added.has(id),
busy: false,
canZoom: added.has(id),
})),
summary: () => ({
entries: IDS.length,
kinds: { dataset: 2, tiles: 0, pack: 1 },
themes: ['cameras', 'events', 'infrastructure'],
available: 2,
unavailable: 1,
added: added.size,
on: added.size,
features: 0,
featureLayers: 0,
}),
async add(id) {
added.add(id);
notify();
return true;
},
async remove(id) {
added.delete(id);
notify();
return true;
},
async zoom(id) {
this.calls.push(['zoom', id]);
return true;
},
async reset() {
const count = added.size;
added.clear();
notify();
return count;
},
};
}
const settle = async () => {
for (let i = 0; i < 4; i += 1) {
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
}
};
function mount(t_) {
document.body.replaceChildren();
const browse = document.createElement('button');
browse.id = 'data-library-browse';
document.body.append(browse);
const pinia = createPinia();
const store = useDataLibraryStore(pinia);
const library = fakeLibrary();
const owner = createLibraryBrowser({
library,
publish: (view) => store.publish(view),
});
store.connect(owner.controls);
const Host = defineComponent({
setup() {
const { view } = storeToRefs(store);
return () => (view.value ? h(DataLibraryDialog, { view: view.value }) : null);
},
});
// The page's i18n, as mountApplicationViews installs it: the locale views
// re-render on.
const app = createApp(Host).use(pinia).use(applicationI18n());
app.mount(document.body.appendChild(document.createElement('div')));
t_.after(() => {
owner.destroy();
app.unmount();
setLocale('en');
});
return { owner, library };
}
const $ = (selector) => document.querySelector(selector);
const shownIds = () =>
[...document.querySelectorAll('#data-library .data-library-item')].map(
(item) => item.dataset.libraryId,
);
test('Browse opens a modal on the search field, packs last; Escape returns to Browse', async (t_) => {
const { owner } = mount(t_);
assert.equal($('#data-library').open, false);
owner.open();
await settle();
assert.equal($('#data-library').open, true);
assert.equal(document.activeElement, $('#data-library input[type="search"]'));
assert.equal($('#data-library-title').textContent.trim(), 'DATA LIBRARY');
assert.equal(shownIds().at(-1), 'cctv-nyc-dot');
assert.match(
$('[data-library-id="cctv-nyc-dot"] .data-library-reason').textContent,
/CCTV_NYC_ENABLED=1/,
);
assert.equal(
$('[data-library-id="cctv-nyc-dot"] .data-library-action'),
null,
'a pack offers no Add',
);
$('#data-library').dispatchEvent(new Event('cancel', { cancelable: true }));
await settle();
assert.equal($('#data-library').open, false);
assert.equal(document.activeElement, $('#data-library-browse'));
});
test('filters narrow the list as the viewer types and picks', async (t_) => {
const { owner } = mount(t_);
owner.open();
await settle();
const search = $('#data-library input[type="search"]');
search.value = 'eonet';
search.dispatchEvent(new Event('input'));
await settle();
assert.deepEqual(shownIds(), ['nasa-eonet-events']);
assert.match($('.data-library-status').textContent, /1 of 3/);
search.value = '';
search.dispatchEvent(new Event('input'));
const only = $('.data-library-check input');
only.checked = true;
only.dispatchEvent(new Event('change'));
await settle();
assert.equal(shownIds().includes('cctv-nyc-dot'), false);
const theme = $('#data-library select');
theme.value = 'events';
theme.dispatchEvent(new Event('change'));
await settle();
assert.deepEqual(shownIds(), ['nasa-eonet-events']);
});
test('Add becomes Remove on the same focused button; Zoom closes first', async (t_) => {
const { owner, library } = mount(t_);
owner.open();
await settle();
const add = $('button[data-library-id="nasa-eonet-events"][data-library-action="add"]');
add.focus();
add.click();
await settle();
assert.deepEqual([...library.added], ['nasa-eonet-events']);
const remove = $(
'button[data-library-id="nasa-eonet-events"]:not([data-library-action="zoom"])',
);
assert.equal(remove.dataset.libraryAction, 'remove');
assert.equal(document.activeElement, remove, 'focus stays on the button');
assert.equal($('[data-stat="added"] .data-library-stat-value').textContent, '1');
$('button[data-library-id="nasa-eonet-events"][data-library-action="zoom"]').click();
await settle();
assert.equal($('#data-library').open, false);
assert.deepEqual(library.calls, [['zoom', 'nasa-eonet-events']]);
});
test('Remove all asks twice, then focuses the search field', async (t_) => {
const { owner, library } = mount(t_);
await library.add('nasa-eonet-events');
await library.add('eia-power-plants');
owner.open();
await settle();
const reset = () => $('#data-library .data-library-reset');
assert.equal(reset().hidden, false);
reset().click();
await settle();
assert.equal(reset().classList.contains('is-armed'), true);
assert.match(reset().textContent, /CONFIRM/);
assert.equal(library.added.size, 2, 'one press removes nothing');
reset().click();
await settle();
assert.equal(library.added.size, 0);
assert.equal(reset().hidden, true);
assert.equal(document.activeElement, $('#data-library input[type="search"]'));
});
test('a language switch relabels the open dialog and keeps its filters', async (t_) => {
const { owner } = mount(t_);
owner.open();
await settle();
const search = $('#data-library input[type="search"]');
search.value = 'eonet';
search.dispatchEvent(new Event('input'));
await settle();
setLocale('de');
await settle();
const title = $('#data-library-title').textContent.trim();
assert.equal(title, t('DATA LIBRARY'));
assert.notEqual(title, 'DATA LIBRARY');
assert.equal(
$('.data-library-action').textContent.trim(),
t('ADD'),
'cards follow the locale too',
);
assert.equal($('#data-library').open, true);
assert.equal($('#data-library input[type="search"]').value, 'eonet');
});
test('clean view closes an open dialog', async (t_) => {
const { owner } = mount(t_);
owner.open();
await settle();
document.body.classList.add('ui-clean-view');
await settle();
assert.equal($('#data-library').open, false);
document.body.classList.remove('ui-clean-view');
});
+187
View File
@@ -0,0 +1,187 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed, nextTick, useTemplateRef, watch } from 'vue';
import { t, tn } from '../../../i18n/index.js';
import UiButton from '../../kit/UiButton.vue';
import UiSelect from '../../kit/UiSelect.js';
import {
libraryStatTiles,
libraryThemeOptions,
} from '../../libraryBrowser.js';
import { useDataLibraryStore } from '../../stores/dataLibrary.js';
import DataLibraryCard from './DataLibraryCard.vue';
// DATA LAYERS ▸ Browse data library, as src/ui/libraryBrowser.js presents it.
// A modal `dialog[data-panel-surface]` (docs/panel-surfaces.md): this view
// opens and closes it as the owner says, moves focus (search on open and
// after Remove all, Browse on close) and sends every click and keystroke to
// the owner. All text is translated here, so it follows the locale.
const props = defineProps({ view: { type: Object, required: true } });
const { controls } = storeToRefs(useDataLibraryStore());
const root = useTemplateRef('root');
const search = useTemplateRef('search');
watch(
() => props.view.open,
async (open, wasOpen) => {
await nextTick();
const dialog = root.value;
if (!dialog) return;
if (open) {
if (!dialog.open) dialog.showModal?.();
search.value?.focus();
return;
}
if (dialog.open) dialog.close();
// The browser may already have closed it (Escape is not always
// cancelable); either way focus returns to Browse, unless the viewer
// has moved it somewhere else meanwhile.
const active = document.activeElement;
if (wasOpen && (!active || active === document.body || dialog.contains(active)))
document.getElementById('data-library-browse')?.focus();
},
{ immediate: true },
);
watch(
() => props.view.focusSearch,
async () => {
await nextTick();
if (props.view.open) search.value?.focus();
},
);
const tiles = computed(() => libraryStatTiles(props.view.summary));
const themes = computed(() => libraryThemeOptions());
const status = computed(() =>
props.view.ready
? t('Showing {shown} of {total}', {
shown: props.view.shown.length,
total: props.view.total,
})
: t('Checking what this instance serves…'),
);
const resetText = computed(() => {
const { reset, added } = props.view;
if (reset.busy) return t('WORKING…');
return reset.armed
? tn(added, 'CONFIRM · REMOVE {count}', 'CONFIRM · REMOVE ALL {count}')
: t('REMOVE ALL');
});
const resetLabel = computed(() =>
props.view.reset.armed
? t('Confirm: remove every added entry from Data Layers')
: t('Remove every added entry from Data Layers'),
);
// A click on the backdrop (the dialog box itself, outside its content)
// dismisses, like Escape.
const onDialogClick = (event) => {
if (event.target === event.currentTarget) controls.value?.close();
};
</script>
<template>
<dialog
id="data-library"
ref="root"
class="data-library-dialog"
data-panel-surface
aria-labelledby="data-library-title"
aria-describedby="data-library-intro"
@cancel.prevent="controls?.close()"
@close="view.open && controls?.close()"
@click="onDialogClick"
>
<header class="data-library-header" data-panel-header>
<h2 id="data-library-title" class="panel-title panel-surface-title">
{{ t('DATA LIBRARY') }}
</h2>
<UiButton
class="panel-surface-control data-library-close"
:label="t('Close data library')"
:native-title="false"
@click="controls?.close()"
>×</UiButton
>
</header>
<div class="data-library-body" data-panel-body>
<dl class="data-library-stats" :aria-label="t('Data Library at a glance')">
<div
v-for="tile in tiles"
:key="tile.id"
class="data-library-stat"
:data-stat="tile.id"
>
<dt class="data-library-stat-label">{{ tile.label }}</dt>
<dd class="data-library-stat-value">{{ tile.value }}</dd>
<dd class="data-library-stat-detail" :hidden="!tile.detail">
{{ tile.detail }}
</dd>
</div>
</dl>
<p id="data-library-intro" class="data-library-intro">
{{
t(
'Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.',
)
}}
</p>
<div class="data-library-filters">
<label class="data-library-field">
<span>{{ t('Search') }}</span>
<input
ref="search"
class="panel-surface-control"
type="search"
autocomplete="off"
:placeholder="t('Name, provider or country')"
:value="view.filter.query"
@input="controls?.setQuery($event.target.value)"
/>
</label>
<label class="data-library-field">
<span>{{ t('Theme') }}</span>
<UiSelect
class="panel-surface-control"
:options="themes"
:model-value="view.filter.family"
@update:model-value="controls?.setFamily($event)"
/>
</label>
<label class="data-library-check">
<input
type="checkbox"
:checked="view.filter.availableOnly"
@change="controls?.setAvailableOnly($event.target.checked)"
/>
<span>{{ t('Only what this instance serves') }}</span>
</label>
</div>
<div class="data-library-toolbar">
<p class="data-library-status" role="status" aria-live="polite">
{{ status }}
</p>
<UiButton
class="panel-surface-control data-library-reset"
:class="{ 'is-armed': view.reset.armed }"
:hidden="!view.reset.visible"
:disabled="view.reset.busy"
:label="resetLabel"
:native-title="false"
@click="controls?.reset()"
>{{ resetText }}</UiButton
>
</div>
<ul class="data-library-list">
<DataLibraryCard
v-for="row in view.shown"
:key="row.entry.id"
:row="row"
@add="controls?.add($event)"
@remove="controls?.remove($event)"
@zoom="controls?.zoom($event)"
/>
</ul>
</div>
</dialog>
</template>