mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Merge branch 'main' into sameh/scene-file-format
This commit is contained in:
30 files changed
+1100
-21
No files matched your search
@@ -3977,5 +3977,15 @@
|
||||
"Scope opacity": "عتامة المنظار",
|
||||
"How dark the scope shades the view outside the keyhole": "مدى تعتيم المنظار للعرض خارج الفتحة",
|
||||
"Reset to default": "إعادة التعيين إلى الافتراضي",
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "إعادة المرئيات إلى إعداداتها الافتراضية: المظهر العادي وHUD والمنظار والتسميات والمؤثرات"
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "إعادة المرئيات إلى إعداداتها الافتراضية: المظهر العادي وHUD والمنظار والتسميات والمؤثرات",
|
||||
"All layers": "كل الطبقات",
|
||||
"On": "تشغيل",
|
||||
"Off": "إيقاف",
|
||||
"Search layers": "البحث في الطبقات",
|
||||
"Filter layers": "تصفية الطبقات",
|
||||
"Status": "الحالة",
|
||||
"Search layers…": "ابحث في الطبقات…",
|
||||
"Layer groups": "مجموعات الطبقات",
|
||||
"Jump to {name}": "الانتقال إلى {name}",
|
||||
"No layers match": "لا توجد طبقات مطابقة"
|
||||
}
|
||||
@@ -3629,5 +3629,15 @@
|
||||
"Scope opacity": "Scope-Deckkraft",
|
||||
"How dark the scope shades the view outside the keyhole": "Wie stark der Scope die Ansicht außerhalb des Schlüssellochs abdunkelt",
|
||||
"Reset to default": "Auf Standard zurücksetzen",
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Darstellung auf die Standardwerte zurücksetzen: normaler Look, HUD, Scope, Beschriftungen und Effekte"
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Darstellung auf die Standardwerte zurücksetzen: normaler Look, HUD, Scope, Beschriftungen und Effekte",
|
||||
"All layers": "Alle Ebenen",
|
||||
"On": "An",
|
||||
"Off": "Aus",
|
||||
"Search layers": "Ebenen suchen",
|
||||
"Filter layers": "Ebenen filtern",
|
||||
"Status": "Status",
|
||||
"Search layers…": "Ebenen suchen…",
|
||||
"Layer groups": "Ebenengruppen",
|
||||
"Jump to {name}": "Zu {name} springen",
|
||||
"No layers match": "Keine passenden Ebenen"
|
||||
}
|
||||
@@ -3716,5 +3716,15 @@
|
||||
"Scope opacity": "Opacidad de la mirilla",
|
||||
"How dark the scope shades the view outside the keyhole": "Cuánto oscurece la mirilla la vista fuera del ojo de cerradura",
|
||||
"Reset to default": "Restablecer valores predeterminados",
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Restablecer Visual a sus valores predeterminados: aspecto Normal, HUD, mirilla, etiquetas y efectos"
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Restablecer Visual a sus valores predeterminados: aspecto Normal, HUD, mirilla, etiquetas y efectos",
|
||||
"All layers": "Todas las capas",
|
||||
"On": "Activadas",
|
||||
"Off": "Desactivadas",
|
||||
"Search layers": "Buscar capas",
|
||||
"Filter layers": "Filtrar capas",
|
||||
"Status": "Estado",
|
||||
"Search layers…": "Buscar capas…",
|
||||
"Layer groups": "Grupos de capas",
|
||||
"Jump to {name}": "Ir a {name}",
|
||||
"No layers match": "Ninguna capa coincide"
|
||||
}
|
||||
@@ -3716,5 +3716,15 @@
|
||||
"Scope opacity": "Opacité de la lunette",
|
||||
"How dark the scope shades the view outside the keyhole": "À quel point la lunette assombrit la vue hors de l'ouverture",
|
||||
"Reset to default": "Rétablir les valeurs par défaut",
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Rétablir les valeurs par défaut de Visuel : aspect Normal, HUD, lunette, libellés et effets"
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Rétablir les valeurs par défaut de Visuel : aspect Normal, HUD, lunette, libellés et effets",
|
||||
"All layers": "Toutes les couches",
|
||||
"On": "Actives",
|
||||
"Off": "Inactives",
|
||||
"Search layers": "Rechercher des couches",
|
||||
"Filter layers": "Filtrer les couches",
|
||||
"Status": "État",
|
||||
"Search layers…": "Rechercher des couches…",
|
||||
"Layer groups": "Groupes de couches",
|
||||
"Jump to {name}": "Aller à {name}",
|
||||
"No layers match": "Aucune couche ne correspond"
|
||||
}
|
||||
@@ -3716,5 +3716,15 @@
|
||||
"Scope opacity": "אטימות הסקופ",
|
||||
"How dark the scope shades the view outside the keyhole": "עד כמה הסקופ מכהה את התצוגה מחוץ לחור המנעול",
|
||||
"Reset to default": "איפוס לברירת המחדל",
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "איפוס החזותי לברירות המחדל: מראה רגיל, HUD, סקופ, תוויות ואפקטים"
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "איפוס החזותי לברירות המחדל: מראה רגיל, HUD, סקופ, תוויות ואפקטים",
|
||||
"All layers": "כל השכבות",
|
||||
"On": "פעילות",
|
||||
"Off": "כבויות",
|
||||
"Search layers": "חיפוש שכבות",
|
||||
"Filter layers": "סינון שכבות",
|
||||
"Status": "מצב",
|
||||
"Search layers…": "חיפוש שכבות…",
|
||||
"Layer groups": "קבוצות שכבות",
|
||||
"Jump to {name}": "מעבר אל {name}",
|
||||
"No layers match": "אין שכבות תואמות"
|
||||
}
|
||||
@@ -3716,5 +3716,15 @@
|
||||
"Scope opacity": "Opacità dello scope",
|
||||
"How dark the scope shades the view outside the keyhole": "Quanto lo scope scurisce la vista fuori dal foro",
|
||||
"Reset to default": "Ripristina predefiniti",
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Ripristina Visuale ai valori predefiniti: aspetto Normale, HUD, scope, etichette ed effetti"
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Ripristina Visuale ai valori predefiniti: aspetto Normale, HUD, scope, etichette ed effetti",
|
||||
"All layers": "Tutti i livelli",
|
||||
"On": "Attivi",
|
||||
"Off": "Disattivati",
|
||||
"Search layers": "Cerca livelli",
|
||||
"Filter layers": "Filtra livelli",
|
||||
"Status": "Stato",
|
||||
"Search layers…": "Cerca livelli…",
|
||||
"Layer groups": "Gruppi di livelli",
|
||||
"Jump to {name}": "Vai a {name}",
|
||||
"No layers match": "Nessun livello corrisponde"
|
||||
}
|
||||
@@ -3542,5 +3542,15 @@
|
||||
"Scope opacity": "スコープの不透明度",
|
||||
"How dark the scope shades the view outside the keyhole": "キーホール外のビューをスコープがどれだけ暗くするか",
|
||||
"Reset to default": "既定に戻す",
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "ビジュアルを既定に戻す:標準の見た目、HUD、スコープ、ラベル、エフェクト"
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "ビジュアルを既定に戻す:標準の見た目、HUD、スコープ、ラベル、エフェクト",
|
||||
"All layers": "すべてのレイヤー",
|
||||
"On": "オン",
|
||||
"Off": "オフ",
|
||||
"Search layers": "レイヤーを検索",
|
||||
"Filter layers": "レイヤーを絞り込む",
|
||||
"Status": "状態",
|
||||
"Search layers…": "レイヤーを検索…",
|
||||
"Layer groups": "レイヤーのグループ",
|
||||
"Jump to {name}": "{name} へ移動",
|
||||
"No layers match": "一致するレイヤーはありません"
|
||||
}
|
||||
@@ -3716,5 +3716,15 @@
|
||||
"Scope opacity": "Opacidade da mira",
|
||||
"How dark the scope shades the view outside the keyhole": "Quanto a mira escurece a vista fora do visor",
|
||||
"Reset to default": "Redefinir para o padrão",
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Redefinir o Visual para o padrão: aparência Normal, HUD, mira, rótulos e efeitos"
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Redefinir o Visual para o padrão: aparência Normal, HUD, mira, rótulos e efeitos",
|
||||
"All layers": "Todas as camadas",
|
||||
"On": "Ativadas",
|
||||
"Off": "Desativadas",
|
||||
"Search layers": "Pesquisar camadas",
|
||||
"Filter layers": "Filtrar camadas",
|
||||
"Status": "Estado",
|
||||
"Search layers…": "Pesquisar camadas…",
|
||||
"Layer groups": "Grupos de camadas",
|
||||
"Jump to {name}": "Ir para {name}",
|
||||
"No layers match": "Nenhuma camada corresponde"
|
||||
}
|
||||
@@ -3803,5 +3803,15 @@
|
||||
"Scope opacity": "Непрозрачность прицела",
|
||||
"How dark the scope shades the view outside the keyhole": "Насколько прицел затемняет вид за пределами маски",
|
||||
"Reset to default": "Сбросить по умолчанию",
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Сбросить «Визуал» к настройкам по умолчанию: обычный вид, HUD, прицел, подписи и эффекты"
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Сбросить «Визуал» к настройкам по умолчанию: обычный вид, HUD, прицел, подписи и эффекты",
|
||||
"All layers": "Все слои",
|
||||
"On": "Включены",
|
||||
"Off": "Выключены",
|
||||
"Search layers": "Поиск слоёв",
|
||||
"Filter layers": "Фильтр слоёв",
|
||||
"Status": "Состояние",
|
||||
"Search layers…": "Поиск слоёв…",
|
||||
"Layer groups": "Группы слоёв",
|
||||
"Jump to {name}": "Перейти к «{name}»",
|
||||
"No layers match": "Нет подходящих слоёв"
|
||||
}
|
||||
@@ -3629,5 +3629,15 @@
|
||||
"Scope opacity": "Scope-opacitet",
|
||||
"How dark the scope shades the view outside the keyhole": "Hur mycket scopet mörkar vyn utanför nyckelhålet",
|
||||
"Reset to default": "Återställ till standard",
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Återställ Visuellt till standard: Normal stil, HUD, scope, etiketter och effekter"
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Återställ Visuellt till standard: Normal stil, HUD, scope, etiketter och effekter",
|
||||
"All layers": "Alla lager",
|
||||
"On": "På",
|
||||
"Off": "Av",
|
||||
"Search layers": "Sök lager",
|
||||
"Filter layers": "Filtrera lager",
|
||||
"Status": "Status",
|
||||
"Search layers…": "Sök lager…",
|
||||
"Layer groups": "Lagergrupper",
|
||||
"Jump to {name}": "Gå till {name}",
|
||||
"No layers match": "Inga lager matchar"
|
||||
}
|
||||
@@ -3803,5 +3803,15 @@
|
||||
"Scope opacity": "Непрозорість прицілу",
|
||||
"How dark the scope shades the view outside the keyhole": "Наскільки приціл затемнює вигляд за межами прицілу",
|
||||
"Reset to default": "Скинути до типових",
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Скинути «Візуал» до типових налаштувань: звичайний вигляд, HUD, приціл, підписи та ефекти"
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Скинути «Візуал» до типових налаштувань: звичайний вигляд, HUD, приціл, підписи та ефекти",
|
||||
"All layers": "Усі шари",
|
||||
"On": "Увімкнені",
|
||||
"Off": "Вимкнені",
|
||||
"Search layers": "Пошук шарів",
|
||||
"Filter layers": "Фільтр шарів",
|
||||
"Status": "Стан",
|
||||
"Search layers…": "Пошук шарів…",
|
||||
"Layer groups": "Групи шарів",
|
||||
"Jump to {name}": "Перейти до «{name}»",
|
||||
"No layers match": "Немає відповідних шарів"
|
||||
}
|
||||
@@ -3542,5 +3542,15 @@
|
||||
"Scope opacity": "视域不透明度",
|
||||
"How dark the scope shades the view outside the keyhole": "视域遮罩使视域之外的画面变暗的程度",
|
||||
"Reset to default": "恢复默认",
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "将视觉恢复为默认设置:常规外观、HUD、视域、标签和效果"
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "将视觉恢复为默认设置:常规外观、HUD、视域、标签和效果",
|
||||
"All layers": "全部图层",
|
||||
"On": "已开启",
|
||||
"Off": "已关闭",
|
||||
"Search layers": "搜索图层",
|
||||
"Filter layers": "筛选图层",
|
||||
"Status": "状态",
|
||||
"Search layers…": "搜索图层…",
|
||||
"Layer groups": "图层分组",
|
||||
"Jump to {name}": "跳转到{name}",
|
||||
"No layers match": "没有匹配的图层"
|
||||
}
|
||||
@@ -3542,5 +3542,15 @@
|
||||
"Scope opacity": "視域不透明度",
|
||||
"How dark the scope shades the view outside the keyhole": "視域遮罩使視域之外的畫面變暗的程度",
|
||||
"Reset to default": "還原預設",
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "將視覺還原為預設設定:常規外觀、HUD、視域、標籤和效果"
|
||||
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "將視覺還原為預設設定:常規外觀、HUD、視域、標籤和效果",
|
||||
"All layers": "全部圖層",
|
||||
"On": "已開啟",
|
||||
"Off": "已關閉",
|
||||
"Search layers": "搜尋圖層",
|
||||
"Filter layers": "篩選圖層",
|
||||
"Status": "狀態",
|
||||
"Search layers…": "搜尋圖層…",
|
||||
"Layer groups": "圖層分組",
|
||||
"Jump to {name}": "跳至{name}",
|
||||
"No layers match": "沒有符合的圖層"
|
||||
}
|
||||
@@ -251,7 +251,8 @@
|
||||
"Regional",
|
||||
"OPT-IN",
|
||||
"{minutes} min",
|
||||
"1 min/s"
|
||||
"1 min/s",
|
||||
"Status"
|
||||
],
|
||||
"es": [
|
||||
"1 min/s",
|
||||
@@ -1275,7 +1276,8 @@
|
||||
"Period",
|
||||
"ICAO: {id}",
|
||||
"NORAD: {id}",
|
||||
"MMSI: {id}"
|
||||
"MMSI: {id}",
|
||||
"Status"
|
||||
],
|
||||
"uk": [
|
||||
"1900–1945",
|
||||
|
||||
@@ -248,6 +248,9 @@ export function onKeyDown(event) {
|
||||
if (event.target?.closest?.('.cesium-credit-lightbox')) return;
|
||||
// An open Display or Radio popover takes Escape itself.
|
||||
if (this.isUtilityDisclosureOpen?.()) return;
|
||||
// So do the Data Layers search field and filter menu.
|
||||
if (event.target?.closest?.('.layer-finder__field, .layer-finder__menu'))
|
||||
return;
|
||||
if (this.context?.contains(event.target) && !this.contextCollapsed) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// src/ui/layerFinder.test.mjs — the Data Layers finder and section index work
|
||||
// on the rows LayerRows renders: search, the On/Off and category filter,
|
||||
// Escape, the clear request voice sends, and the group ticks.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, h, nextTick } from 'vue';
|
||||
import LayerFinder, {
|
||||
LAYER_FINDER_CLEAR_EVENT,
|
||||
} from './views/panels/LayerFinder.vue';
|
||||
import LayerSectionIndex from './views/panels/LayerSectionIndex.vue';
|
||||
|
||||
const ROWS = [
|
||||
{ group: 'movement', heading: 'Movement' },
|
||||
{ id: 'flights', name: 'Live Flights', english: 'Live Flights', on: true },
|
||||
{ id: 'ships', name: 'Ships', english: 'Ships', on: false },
|
||||
{ group: 'weather', heading: 'Weather' },
|
||||
{ id: 'radar', name: 'Radar météo', english: 'Weather Radar', on: false },
|
||||
];
|
||||
|
||||
const flush = async () => {
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
// Frames run at once, so the observers' batching does not hold a test up.
|
||||
globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
const panel = document.body.appendChild(document.createElement('section'));
|
||||
panel.id = 'data-panel';
|
||||
const header = panel.appendChild(document.createElement('div'));
|
||||
const bar = panel.appendChild(document.createElement('div'));
|
||||
const list = panel.appendChild(document.createElement('div'));
|
||||
list.id = 'data-toggles';
|
||||
for (const row of ROWS) {
|
||||
if (row.heading) {
|
||||
const heading = list.appendChild(document.createElement('h3'));
|
||||
heading.className = 'data-layer-group-heading';
|
||||
heading.dataset.group = row.group;
|
||||
heading.textContent = row.heading;
|
||||
continue;
|
||||
}
|
||||
const el = list.appendChild(document.createElement('div'));
|
||||
el.className = 'data-toggle-row';
|
||||
el.dataset.layerId = row.id;
|
||||
el.dataset.layerName = row.english;
|
||||
el.innerHTML = `<span class="data-name">${row.name}</span><button class="data-toggle-btn${row.on ? ' active' : ''}"></button>`;
|
||||
}
|
||||
const index = panel.appendChild(document.createElement('div'));
|
||||
const apps = [
|
||||
createApp(() => h(LayerFinder, { part: 'buttons' })),
|
||||
createApp(() => h(LayerFinder, { part: 'bar' })),
|
||||
createApp(LayerSectionIndex),
|
||||
];
|
||||
apps[0].mount(header);
|
||||
apps[1].mount(bar);
|
||||
apps[2].mount(index);
|
||||
t.after(async () => {
|
||||
document.dispatchEvent(new CustomEvent(LAYER_FINDER_CLEAR_EVENT));
|
||||
for (const app of apps) app.unmount();
|
||||
});
|
||||
const shown = () =>
|
||||
[...list.querySelectorAll('.data-toggle-row:not(.is-filtered)')].map(
|
||||
(row) => row.dataset.layerId,
|
||||
);
|
||||
const search = async (text) => {
|
||||
if (!document.getElementById('layer-finder-input')) {
|
||||
document.getElementById('layer-finder-search-btn').click();
|
||||
await flush();
|
||||
}
|
||||
const input = document.getElementById('layer-finder-input');
|
||||
input.value = text;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flush();
|
||||
};
|
||||
const openFilter = async () => {
|
||||
document.querySelector('[aria-controls="layer-finder-menu"]').click();
|
||||
await flush();
|
||||
return document.getElementById('layer-finder-menu');
|
||||
};
|
||||
return { list, panel, shown, search, openFilter };
|
||||
}
|
||||
|
||||
test('search finds a layer by the name on screen or its English name', async (t) => {
|
||||
const { shown, search, list } = fixture(t);
|
||||
await search('météo');
|
||||
assert.deepEqual(shown(), ['radar']);
|
||||
await search('weather radar');
|
||||
assert.deepEqual(shown(), ['radar']);
|
||||
// The Movement heading has no rows left, so it is hidden too.
|
||||
assert.equal(
|
||||
list
|
||||
.querySelector('[data-group="movement"]')
|
||||
.classList.contains('is-filtered'),
|
||||
true,
|
||||
);
|
||||
await search('nothing like it');
|
||||
assert.deepEqual(shown(), []);
|
||||
assert.match(
|
||||
document.querySelector('.layer-finder__empty')?.textContent || '',
|
||||
/No layers match/,
|
||||
);
|
||||
});
|
||||
|
||||
test('On and Off follow whether the layer is enabled', async (t) => {
|
||||
const { shown, openFilter } = fixture(t);
|
||||
const menu = await openFilter();
|
||||
const on = [...menu.querySelectorAll('button')].find((b) =>
|
||||
b.textContent.trim().endsWith('On'),
|
||||
);
|
||||
on.click();
|
||||
await flush();
|
||||
assert.deepEqual(shown(), ['flights']);
|
||||
assert.equal(on.getAttribute('aria-pressed'), 'true');
|
||||
});
|
||||
|
||||
test('a chosen category survives its heading being translated', async (t) => {
|
||||
const { shown, openFilter, list } = fixture(t);
|
||||
const menu = await openFilter();
|
||||
[...menu.querySelectorAll('button')]
|
||||
.find((b) => b.textContent.trim().endsWith('Weather'))
|
||||
.click();
|
||||
await flush();
|
||||
assert.deepEqual(shown(), ['radar']);
|
||||
list.querySelector('[data-group="weather"]').textContent = 'Météo';
|
||||
await flush();
|
||||
assert.deepEqual(shown(), ['radar']);
|
||||
assert.match(
|
||||
document.querySelector('.layer-finder__chip').textContent,
|
||||
/Météo/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Escape in the search field closes it without reaching the panel', async (t) => {
|
||||
const { search, panel } = fixture(t);
|
||||
let reached = false;
|
||||
panel.addEventListener('keydown', () => (reached = true));
|
||||
await search('ships');
|
||||
document
|
||||
.getElementById('layer-finder-input')
|
||||
.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
await flush();
|
||||
assert.equal(reached, false);
|
||||
assert.equal(document.getElementById('layer-finder-input'), null);
|
||||
assert.equal(document.activeElement?.id, 'layer-finder-search-btn');
|
||||
});
|
||||
|
||||
test('the filter menu takes focus, moves with the arrows and closes on Escape', async (t) => {
|
||||
const { openFilter } = fixture(t);
|
||||
const menu = await openFilter();
|
||||
const buttons = [...menu.querySelectorAll('button')];
|
||||
assert.equal(document.activeElement, buttons[0]);
|
||||
menu.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
|
||||
);
|
||||
assert.equal(document.activeElement, buttons[1]);
|
||||
menu.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'End', bubbles: true }),
|
||||
);
|
||||
assert.equal(document.activeElement, buttons.at(-1));
|
||||
menu.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
await flush();
|
||||
assert.equal(document.getElementById('layer-finder-menu'), null);
|
||||
assert.equal(
|
||||
document.activeElement,
|
||||
document.querySelector('[aria-controls="layer-finder-menu"]'),
|
||||
);
|
||||
});
|
||||
|
||||
test('a clear request shows every row again', async (t) => {
|
||||
const { shown, search } = fixture(t);
|
||||
await search('ships');
|
||||
assert.deepEqual(shown(), ['ships']);
|
||||
document.dispatchEvent(new CustomEvent(LAYER_FINDER_CLEAR_EVENT));
|
||||
await flush();
|
||||
assert.deepEqual(shown(), ['flights', 'ships', 'radar']);
|
||||
});
|
||||
|
||||
test('the section index has a tick per group and drops filtered groups', async (t) => {
|
||||
const { search } = fixture(t);
|
||||
await flush();
|
||||
assert.equal(
|
||||
document.querySelectorAll('.layer-section-index__tick').length,
|
||||
2,
|
||||
);
|
||||
await search('ships');
|
||||
await flush();
|
||||
// One group left: no index.
|
||||
assert.equal(
|
||||
document.querySelectorAll('.layer-section-index__tick').length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -22,9 +22,11 @@ defineProps({
|
||||
|
||||
<template>
|
||||
<template v-for="item in items" :key="item.key"
|
||||
><h3 v-if="item.heading" class="data-layer-group-heading">{{
|
||||
item.heading
|
||||
}}</h3
|
||||
><h3
|
||||
v-if="item.heading"
|
||||
class="data-layer-group-heading"
|
||||
:data-group="item.key"
|
||||
>{{ item.heading }}</h3
|
||||
><div
|
||||
v-else
|
||||
class="data-toggle-row"
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/* ── Data Layers search and filter ── */
|
||||
.layer-finder__btn {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
color: rgba(206, 232, 239, 0.65);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.layer-finder__btn .material-symbols-outlined {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.layer-finder__btn:hover,
|
||||
.layer-finder__btn:focus-visible {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.layer-finder__btn.active {
|
||||
color: #36dcff;
|
||||
background: rgba(54, 220, 255, 0.12);
|
||||
border-color: rgba(54, 220, 255, 0.45);
|
||||
}
|
||||
|
||||
.layer-finder__filter {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.layer-finder__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
min-width: 140px;
|
||||
padding: 4px;
|
||||
background: rgba(16, 20, 28, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.layer-finder__menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
color: rgba(225, 236, 242, 0.85);
|
||||
font-size: 0.72rem;
|
||||
text-align: start;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.layer-finder__menu button .material-symbols-outlined {
|
||||
font-size: 14px;
|
||||
color: rgba(199, 214, 222, 0.6);
|
||||
}
|
||||
|
||||
.layer-finder__menu button:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.layer-finder__menu button.active,
|
||||
.layer-finder__menu button.active .material-symbols-outlined {
|
||||
color: #7af0ff;
|
||||
}
|
||||
|
||||
.layer-finder__bar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0.2rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.layer-finder__field {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0.55rem;
|
||||
height: 30px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(54, 220, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.layer-finder__field .material-symbols-outlined {
|
||||
color: rgba(199, 214, 222, 0.6);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.layer-finder__field input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
color: rgba(236, 244, 248, 0.95);
|
||||
font-size: 0.75rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Focus shows on the field's frame, as a soft ring, in place of the hard
|
||||
keyboard outline round the bare input. */
|
||||
.layer-finder__field input:focus,
|
||||
.layer-finder__field input:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.layer-finder__field:focus-within {
|
||||
border-color: rgba(54, 220, 255, 0.7);
|
||||
box-shadow: 0 0 0 3px rgba(54, 220, 255, 0.1);
|
||||
}
|
||||
|
||||
.layer-finder__chip {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
height: 26px;
|
||||
padding: 0 0.5rem;
|
||||
color: #7af0ff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.56rem;
|
||||
letter-spacing: 0.1rem;
|
||||
text-transform: uppercase;
|
||||
background: rgba(54, 220, 255, 0.12);
|
||||
border: 1px solid rgba(54, 220, 255, 0.45);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.layer-finder__chip .material-symbols-outlined {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Rows and group headings the search or filter leaves out. */
|
||||
#data-toggles .is-filtered {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.layer-finder__empty {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.5rem;
|
||||
color: rgba(199, 214, 222, 0.5);
|
||||
font-size: 0.72rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.layer-finder__menu {
|
||||
max-height: 60vh;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.layer-finder__menu-head {
|
||||
padding: 0.45rem 0.55rem 0.2rem;
|
||||
color: rgba(199, 214, 222, 0.5);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.52rem;
|
||||
letter-spacing: 0.14rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Each group of options lays out as part of the menu's grid. */
|
||||
.layer-finder__menu > span {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.layer-finder__menu > span + span > .layer-finder__menu-head {
|
||||
margin-top: 0.2rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Several chips can show at once. */
|
||||
.layer-finder__bar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Chips sized like the rows' ON/OFF switches: small and square. */
|
||||
.layer-finder__chip {
|
||||
height: 20px;
|
||||
gap: 0.2rem;
|
||||
padding: 0 6px 0 8px;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.08rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.layer-finder__chip .material-symbols-outlined {
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/* ── Data Layers group index: ticks along the panel's right edge ─────── */
|
||||
#data-panel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.layer-section-index {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
inset-inline-end: 2px;
|
||||
bottom: 24px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
#data-panel.collapsed .layer-section-index {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.layer-section-index__tick {
|
||||
position: relative;
|
||||
width: 10px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.layer-section-index__tick::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
left: 4px;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: rgba(199, 214, 222, 0.32);
|
||||
transition:
|
||||
background 140ms ease,
|
||||
box-shadow 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.layer-section-index__tick:hover::before,
|
||||
.layer-section-index__tick:focus-visible::before {
|
||||
background: rgba(236, 244, 248, 0.85);
|
||||
transform: scaleX(1.5);
|
||||
}
|
||||
|
||||
.layer-section-index__tick.is-current::before {
|
||||
background: #36dcff;
|
||||
box-shadow: 0 0 6px rgba(54, 220, 255, 0.6);
|
||||
}
|
||||
|
||||
.layer-section-index__tick:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The group's name, drawn on the page just outside the panel's edge while a
|
||||
tick is pointed at or focused, so it covers none of the rows. */
|
||||
.layer-section-index__label {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
padding: 0.25rem 0.5rem;
|
||||
color: rgba(236, 244, 248, 0.92);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.56rem;
|
||||
letter-spacing: 0.12rem;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
background: rgba(12, 12, 20, 0.88);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
animation: layer-section-index-label 120ms ease both;
|
||||
}
|
||||
|
||||
/* Drawn before the tick when there is no room after it. */
|
||||
.layer-section-index__label--before {
|
||||
transform: translate(-100%, -50%);
|
||||
}
|
||||
|
||||
@keyframes layer-section-index-label {
|
||||
from {
|
||||
opacity: 0;
|
||||
translate: -4px 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.layer-section-index__label {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
body.mobile-shell .layer-section-index {
|
||||
display: none;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ 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 LayerSectionIndex from './panels/LayerSectionIndex.vue';
|
||||
import LayerFinder from './panels/LayerFinder.vue';
|
||||
import DataLibraryBrowse from './panels/DataLibraryBrowse.vue';
|
||||
|
||||
// Scene sharing (src/scenes/sharing.js) offers its authoring bar and presents
|
||||
@@ -43,7 +45,10 @@ async function importChosen(event) {
|
||||
<div id="left-panel-stack" class="ui-scroll">
|
||||
<!-- Data Layer Toggle Panel -->
|
||||
<RailPanel id="data-panel" start-collapsed class="active" :title="t('DATA LAYERS')">
|
||||
<template #actions><LayerFinder part="buttons" /></template>
|
||||
<LayerFinder part="bar" />
|
||||
<div id="data-toggles" class="data-toggle-list ui-scroll"></div>
|
||||
<LayerSectionIndex />
|
||||
<!-- Data Library: Browse and its dialog (src/ui/libraryBrowser.js). -->
|
||||
<DataLibraryBrowse />
|
||||
</RailPanel>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<script>
|
||||
import { ref as moduleRef } from 'vue';
|
||||
|
||||
/** Voice and other callers ask for this event to show every row again. */
|
||||
export const LAYER_FINDER_CLEAR_EVENT = 'gev:layer-finder-clear';
|
||||
|
||||
// The header buttons and the bar under the header are two instances that
|
||||
// share one finder.
|
||||
const finder = {
|
||||
query: moduleRef(''),
|
||||
show: moduleRef('all'),
|
||||
searching: moduleRef(false),
|
||||
filtering: moduleRef(false),
|
||||
// A group's stable key (LayerRows' `data-group`), not its translated name,
|
||||
// so a language change keeps the choice.
|
||||
category: moduleRef(''),
|
||||
categories: moduleRef([]),
|
||||
shown: moduleRef(0),
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch, computed } from 'vue';
|
||||
import { msg, t } from '../../../i18n/index.js';
|
||||
|
||||
// Search and filter for the Data Layers list. The rows are rendered elsewhere
|
||||
// (LayerRows), so this works on the list as it stands: it marks rows that
|
||||
// don't match as filtered out, hides a group heading when none of its rows
|
||||
// are left, and keeps doing so as rows update.
|
||||
const props = defineProps({
|
||||
/** 'buttons' for the header, 'bar' for the row under it. */
|
||||
part: { type: String, default: 'buttons' },
|
||||
});
|
||||
|
||||
const { query, show, searching, filtering, category, categories, shown } =
|
||||
finder;
|
||||
|
||||
const SHOWS = [
|
||||
{ id: 'all', label: msg('All layers') },
|
||||
{ id: 'on', label: msg('On') },
|
||||
{ id: 'off', label: msg('Off') },
|
||||
];
|
||||
|
||||
const narrowed = computed(
|
||||
() =>
|
||||
Boolean(query.value.trim()) || show.value !== 'all' || Boolean(category.value),
|
||||
);
|
||||
const categoryLabel = computed(
|
||||
() => categories.value.find((c) => c.key === category.value)?.label || '',
|
||||
);
|
||||
|
||||
function apply() {
|
||||
const list = document.getElementById('data-toggles');
|
||||
if (!list) return;
|
||||
const q = query.value.trim().toLocaleLowerCase();
|
||||
let heading = null;
|
||||
let headingHasRows = false;
|
||||
let count = 0;
|
||||
let group = '';
|
||||
const groups = [];
|
||||
const close = () =>
|
||||
heading?.classList.toggle('is-filtered', !headingHasRows);
|
||||
for (const el of list.children) {
|
||||
if (el.classList.contains('data-layer-group-heading')) {
|
||||
close();
|
||||
heading = el;
|
||||
headingHasRows = false;
|
||||
group = el.dataset.group || el.textContent.trim();
|
||||
groups.push({ key: group, label: el.textContent.trim() });
|
||||
continue;
|
||||
}
|
||||
if (!el.classList.contains('data-toggle-row')) continue;
|
||||
// The name on screen, in the UI language, and the English name too.
|
||||
const name = [el.querySelector('.data-name')?.textContent, el.dataset.layerName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLocaleLowerCase();
|
||||
// On means the layer is enabled, whatever its feed is doing.
|
||||
const on = Boolean(
|
||||
el.querySelector('.data-toggle-btn')?.classList.contains('active'),
|
||||
);
|
||||
const match =
|
||||
(!q || name.includes(q)) &&
|
||||
(show.value === 'all' || (show.value === 'on') === on) &&
|
||||
(!category.value || group === category.value);
|
||||
el.classList.toggle('is-filtered', !match);
|
||||
if (match) {
|
||||
headingHasRows = true;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
close();
|
||||
list.classList.toggle('is-filtering', narrowed.value);
|
||||
const same =
|
||||
groups.length === categories.value.length &&
|
||||
groups.every(
|
||||
(g, i) =>
|
||||
g.key === categories.value[i].key && g.label === categories.value[i].label,
|
||||
);
|
||||
if (!same) categories.value = groups;
|
||||
// A group that went away takes its filter with it.
|
||||
if (category.value && !groups.some((g) => g.key === category.value))
|
||||
category.value = '';
|
||||
shown.value = count;
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
query.value = '';
|
||||
show.value = 'all';
|
||||
category.value = '';
|
||||
searching.value = false;
|
||||
filtering.value = false;
|
||||
apply();
|
||||
}
|
||||
|
||||
let observer = null;
|
||||
let frame = 0;
|
||||
if (props.part === 'bar') {
|
||||
onMounted(() => {
|
||||
const list = document.getElementById('data-toggles');
|
||||
if (!list) return;
|
||||
// Rows repaint many times a second; filter once per frame at most.
|
||||
observer = new MutationObserver(() => {
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
apply();
|
||||
});
|
||||
});
|
||||
observer.observe(list, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-feed-state'],
|
||||
});
|
||||
apply();
|
||||
document.addEventListener(LAYER_FINDER_CLEAR_EVENT, clearAll);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener(LAYER_FINDER_CLEAR_EVENT, clearAll);
|
||||
});
|
||||
watch([query, show, category], apply);
|
||||
}
|
||||
|
||||
const searchBtn = () => document.getElementById('layer-finder-search-btn');
|
||||
async function toggleSearch() {
|
||||
searching.value = !searching.value;
|
||||
if (!searching.value) query.value = '';
|
||||
await nextTick();
|
||||
if (searching.value) document.getElementById('layer-finder-input')?.focus();
|
||||
}
|
||||
// Escape in the field closes the search and stays there: it must not also
|
||||
// fold the panel or leave the cockpit.
|
||||
async function closeSearch() {
|
||||
searching.value = false;
|
||||
query.value = '';
|
||||
await nextTick();
|
||||
searchBtn()?.focus();
|
||||
}
|
||||
|
||||
// The filter is a disclosure of two groups of toggle buttons. Opening it moves
|
||||
// focus to the chosen option; Escape, or a press outside, closes it and gives
|
||||
// focus back to its button.
|
||||
const filterEl = ref(null);
|
||||
const menuEl = ref(null);
|
||||
const triggerEl = ref(null);
|
||||
const options = () => [...(menuEl.value?.querySelectorAll('button') || [])];
|
||||
async function toggleFilter() {
|
||||
filtering.value = !filtering.value;
|
||||
if (!filtering.value) return;
|
||||
await nextTick();
|
||||
const menu = menuEl.value;
|
||||
if (!menu) return;
|
||||
// Keep the menu inside the panel, which clips what leaves it.
|
||||
const panel = menu.closest('.ui-panel, [data-panel-id]');
|
||||
const room = panel
|
||||
? panel.getBoundingClientRect().bottom - menu.getBoundingClientRect().top - 8
|
||||
: 0;
|
||||
menu.style.maxHeight = room > 80 ? `${Math.floor(room)}px` : '';
|
||||
(menu.querySelector('[aria-pressed="true"]') || options()[0])?.focus();
|
||||
}
|
||||
function closeFilter({ refocus = false } = {}) {
|
||||
filtering.value = false;
|
||||
if (refocus) triggerEl.value?.focus();
|
||||
}
|
||||
function onMenuKey(event) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeFilter({ refocus: true });
|
||||
return;
|
||||
}
|
||||
const step = { ArrowDown: 1, ArrowUp: -1 }[event.key];
|
||||
const edge = { Home: 0, End: -1 }[event.key];
|
||||
if (step === undefined && edge === undefined) return;
|
||||
event.preventDefault();
|
||||
const all = options();
|
||||
const at = all.indexOf(document.activeElement);
|
||||
const next =
|
||||
step !== undefined
|
||||
? all[(at + step + all.length) % all.length]
|
||||
: all.at(edge);
|
||||
next?.focus();
|
||||
}
|
||||
function onOutside(e) {
|
||||
if (filtering.value && filterEl.value && !filterEl.value.contains(e.target))
|
||||
closeFilter();
|
||||
}
|
||||
function pickCategory(key) {
|
||||
category.value = category.value === key ? '' : key;
|
||||
}
|
||||
if (props.part === 'buttons') {
|
||||
onMounted(() => document.addEventListener('pointerdown', onOutside, true));
|
||||
onBeforeUnmount(() =>
|
||||
document.removeEventListener('pointerdown', onOutside, true),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="part === 'buttons'">
|
||||
<button id="layer-finder-search-btn" type="button" class="layer-finder__btn" :class="{ active: searching || query }" :aria-pressed="String(searching)" :aria-label="t('Search layers')" :title="t('Search layers')" @click="toggleSearch">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">search</span>
|
||||
</button>
|
||||
<span ref="filterEl" class="layer-finder__filter">
|
||||
<button ref="triggerEl" type="button" class="layer-finder__btn" :class="{ active: show !== 'all' || category }" :aria-expanded="String(filtering)" aria-controls="layer-finder-menu" :aria-label="t('Filter layers')" :title="t('Filter layers')" @click="toggleFilter">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">filter_list</span>
|
||||
</button>
|
||||
<span v-if="filtering" id="layer-finder-menu" ref="menuEl" class="layer-finder__menu" role="group" :aria-label="t('Filter layers')" @keydown="onMenuKey">
|
||||
<span role="group" :aria-label="t('Status')">
|
||||
<span class="layer-finder__menu-head" aria-hidden="true">{{ t('Status') }}</span>
|
||||
<button v-for="s in SHOWS" :key="s.id" type="button" :aria-pressed="String(show === s.id)" :class="{ active: show === s.id }" @click="show = s.id">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">{{ show === s.id ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>{{ t(s.label) }}
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="categories.length" role="group" :aria-label="t('Category')">
|
||||
<span class="layer-finder__menu-head" aria-hidden="true">{{ t('Category') }}</span>
|
||||
<button v-for="c in categories" :key="c.key" type="button" :aria-pressed="String(category === c.key)" :class="{ active: category === c.key }" @click="pickCategory(c.key)">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">{{ category === c.key ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>{{ c.label }}
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<div v-else-if="searching || narrowed" class="layer-finder__bar">
|
||||
<label v-if="searching" class="layer-finder__field">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">search</span>
|
||||
<input id="layer-finder-input" v-model="query" type="search" :placeholder="t('Search layers…')" :aria-label="t('Search layers')" @keydown.esc.stop.prevent="closeSearch" />
|
||||
</label>
|
||||
<button v-if="show !== 'all'" type="button" class="layer-finder__chip" @click="show = 'all'">
|
||||
{{ show === 'on' ? t('On') : t('Off') }}<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
<button v-if="category" type="button" class="layer-finder__chip" @click="category = ''">
|
||||
{{ categoryLabel }}<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
<p v-if="narrowed && shown === 0" class="layer-finder__empty" role="status">{{ t('No layers match') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
// A slim index beside the Data Layers list: one tick per group (Movement,
|
||||
// Cameras, Infrastructure…). Hovering a tick names its group, clicking it
|
||||
// scrolls the list there, and the tick for the group in view is lit. It reads
|
||||
// the list's group headings, which LayerRows renders, so it follows whatever
|
||||
// groups the layers bring.
|
||||
const sections = ref([]);
|
||||
const current = ref(0);
|
||||
let list = null;
|
||||
let observer = null;
|
||||
let resize = null;
|
||||
|
||||
function headings() {
|
||||
return list ? [...list.querySelectorAll('.data-layer-group-heading:not(.is-filtered)')] : [];
|
||||
}
|
||||
function readSections() {
|
||||
const next = headings().map((h, i) => ({ i, label: h.textContent.trim() }));
|
||||
// Live counts repaint the rows all the time; keep the ticks unless the
|
||||
// groups themselves changed.
|
||||
const same =
|
||||
next.length === sections.value.length &&
|
||||
next.every((s, i) => s.label === sections.value[i].label);
|
||||
if (!same) sections.value = next;
|
||||
syncCurrent();
|
||||
}
|
||||
// Only headings matter: a heading added, removed, renamed (a language change)
|
||||
// or filtered out. Rows' counts and states change many times a second.
|
||||
const isHeading = (node) =>
|
||||
node?.classList?.contains('data-layer-group-heading') ||
|
||||
node?.parentElement?.classList?.contains('data-layer-group-heading');
|
||||
let frame = 0;
|
||||
function onMutations(mutations) {
|
||||
if (frame) return;
|
||||
const relevant = mutations.some(
|
||||
(m) =>
|
||||
(m.type === 'childList' && m.target === list) || isHeading(m.target),
|
||||
);
|
||||
if (!relevant) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
readSections();
|
||||
});
|
||||
}
|
||||
function syncCurrent() {
|
||||
if (!list) return;
|
||||
const top = list.getBoundingClientRect().top + 8;
|
||||
let index = 0;
|
||||
headings().forEach((h, i) => {
|
||||
if (h.getBoundingClientRect().top <= top + 4) index = i;
|
||||
});
|
||||
// At the very end of the list the last group is the one in view.
|
||||
const scrolls = list.clientHeight > 0 && list.scrollHeight > list.clientHeight + 2;
|
||||
if (scrolls && list.scrollTop + list.clientHeight >= list.scrollHeight - 2) index = sections.value.length - 1;
|
||||
current.value = Math.max(0, index);
|
||||
}
|
||||
// The name of the tick being pointed at or focused. It is drawn on the page
|
||||
// itself, just outside the panel's edge: the panel clips what leaves it, and a
|
||||
// label inside would cover the rows.
|
||||
const tip = ref(null);
|
||||
function showTip(event, section) {
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
// Beside the tick on whichever side has room: the stack sits on the right
|
||||
// in a right-to-left language.
|
||||
const before = window.innerWidth - box.right < 180;
|
||||
tip.value = {
|
||||
label: section.label,
|
||||
before,
|
||||
left: `${Math.round(before ? box.left - 10 : box.right + 10)}px`,
|
||||
top: `${Math.round(box.top + box.height / 2)}px`,
|
||||
};
|
||||
}
|
||||
const hideTip = () => {
|
||||
tip.value = null;
|
||||
};
|
||||
function jump(i) {
|
||||
const h = headings()[i];
|
||||
if (!h || !list) return;
|
||||
list.scrollTo({ top: h.offsetTop - list.offsetTop - 4, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
list = document.getElementById('data-toggles');
|
||||
if (!list) return;
|
||||
observer = new MutationObserver(onMutations);
|
||||
observer.observe(list, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] });
|
||||
list.addEventListener('scroll', syncCurrent, { passive: true });
|
||||
// The list is hidden while the panel is shut; read again when it opens.
|
||||
resize = new ResizeObserver(syncCurrent);
|
||||
resize.observe(list);
|
||||
readSections();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect();
|
||||
resize?.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
list?.removeEventListener('scroll', syncCurrent);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="sections.length > 1" class="layer-section-index" :aria-label="t('Layer groups')">
|
||||
<button
|
||||
v-for="section in sections"
|
||||
:key="section.i"
|
||||
type="button"
|
||||
class="layer-section-index__tick"
|
||||
:class="{ 'is-current': section.i === current }"
|
||||
:aria-label="t('Jump to {name}', { name: section.label })"
|
||||
:aria-current="section.i === current ? 'true' : undefined"
|
||||
@click="jump(section.i)"
|
||||
@mouseenter="showTip($event, section)"
|
||||
@mouseleave="hideTip"
|
||||
@focus="showTip($event, section)"
|
||||
@blur="hideTip"
|
||||
></button>
|
||||
<Teleport to="body">
|
||||
<span v-if="tip" class="layer-section-index__label"
|
||||
:class="{ 'layer-section-index__label--before': tip.before }" :style="{ left: tip.left, top: tip.top }" aria-hidden="true">{{ tip.label }}</span>
|
||||
</Teleport>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -3727,6 +3727,10 @@ function findDataLayerRow(layerId) {
|
||||
}
|
||||
|
||||
function focusDataLayerRow(row, layerId) {
|
||||
// A row the Data Layers finder hides could not be seen: show every row
|
||||
// again (LAYER_FINDER_CLEAR_EVENT in LayerFinder.vue).
|
||||
if (row.classList.contains('is-filtered'))
|
||||
document.dispatchEvent(new CustomEvent('gev:layer-finder-clear'));
|
||||
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
row.classList.remove('gev-voice-focus');
|
||||
void row.offsetWidth;
|
||||
|
||||
@@ -1954,7 +1954,11 @@ test('showing a layer opens the panel that holds its row: Directions lives in Ut
|
||||
const focused = [];
|
||||
const rowIn = (panelId) => ({
|
||||
dataset: { layerName: panelId === 'utilities-panel' ? 'Directions' : 'Live Flights' },
|
||||
classList: { add: (name) => focused.push(name), remove() {} },
|
||||
classList: {
|
||||
add: (name) => focused.push(name),
|
||||
remove() {},
|
||||
contains: () => false,
|
||||
},
|
||||
scrollIntoView() {},
|
||||
closest: (selector) => (selector === '[data-panel-id]' ? { id: panelId } : null),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user