Merge branch 'main' into sameh/scene-file-format

This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-14 18:04:21 -08:00
commit 35d3a1a8fe
30 files changed
+1100 -21

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
- Add a search and a filter (On / Off and category) to the Data Layers panel,
matching layer names in the UI language, and a section index down its side
that jumps to each group (manjunath22466, samehkhamis, #1740).
+21
View File
@@ -723,6 +723,27 @@ and touch screens, while the rails are hidden (clean view, recording), and it
follows the rails' sides in a right-to-left language. On a phone, Visual is in follows the rails' sides in a right-to-left language. On a phone, Visual is in
the More sheet with the dock's Visual Presets, as before. the More sheet with the dock's Visual Presets, as before.
## Data Layers finder and section index — November 15, 2026
The Data Layers header has a search button and a filter button
(`src/ui/views/panels/LayerFinder.vue`). Search opens a field under the
header that matches each layer's name on screen and its English name; Escape
closes it and leaves the panel open. The filter is a small popover with two
groups, Status (All layers, On, Off: whether the layer is enabled) and
Category (the list's groups, kept by their key, so a language change keeps
the choice). The arrows, Home and End move through it and Escape closes it.
Rows and groups that are left out hide; chips under the header show the
active filters and clear them, and "No layers match" says when nothing is
left. Neither the search nor the filter is saved or put in share links. A
voice request that points at a hidden layer clears the finder first
(`gev:layer-finder-clear`).
A column of ticks down the list's inline end
(`src/ui/views/panels/LayerSectionIndex.vue`) has one tick per group still
shown; hovering or focusing a tick names its group beside it, on whichever
side has room, and clicking scrolls the list there. The tick for the group in
view is lit. Phones do not show it.
## Cockpit side panels — November 14, 2026 ## Cockpit side panels — November 14, 2026
On the desktop, the cockpit's side panels match the main page's On the desktop, the cockpit's side panels match the main page's
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. --> a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,align_horizontal_left,align_horizontal_right,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,delete,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,palette,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,restart_alt,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,align_horizontal_left,align_horizontal_right,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,delete,directions_boat,draw,east,eco,egg_alt,filter_list,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,palette,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,radio_button_checked,radio_button_unchecked,raven,restart_alt,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" rel="stylesheet" />
</head> </head>
<body> <body>
<div class="gev-view" data-gev-view="scene-chrome"></div> <div class="gev-view" data-gev-view="scene-chrome"></div>
+4 -1
View File
@@ -1462,5 +1462,8 @@
"src/layers/vessels/placement.test.mjs", "src/layers/vessels/placement.test.mjs",
"src/ui/styles/visual-panel.css", "src/ui/styles/visual-panel.css",
"src/ui/styles/scope-off-shadows.css", "src/ui/styles/scope-off-shadows.css",
"src/ui/styles/cockpit-panels.css" "src/ui/styles/cockpit-panels.css",
"src/ui/styles/layer-section-index.css",
"src/ui/styles/layer-finder.css",
"src/ui/layerFinder.test.mjs"
] ]
+2
View File
@@ -113,6 +113,8 @@
"src/ui/views/panels/DataLibraryCard.vue", "src/ui/views/panels/DataLibraryCard.vue",
"src/ui/views/panels/DataLibraryDialog.vue", "src/ui/views/panels/DataLibraryDialog.vue",
"src/ui/views/panels/DataLibraryBrowse.vue", "src/ui/views/panels/DataLibraryBrowse.vue",
"src/ui/views/panels/LayerFinder.vue",
"src/ui/views/panels/LayerSectionIndex.vue",
"src/ui/libraryBrowser.js", "src/ui/libraryBrowser.js",
"src/ui/localSdrPresentation.js", "src/ui/localSdrPresentation.js",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
+11 -1
View File
@@ -3977,5 +3977,15 @@
"Scope opacity": "عتامة المنظار", "Scope opacity": "عتامة المنظار",
"How dark the scope shades the view outside the keyhole": "مدى تعتيم المنظار للعرض خارج الفتحة", "How dark the scope shades the view outside the keyhole": "مدى تعتيم المنظار للعرض خارج الفتحة",
"Reset to default": "إعادة التعيين إلى الافتراضي", "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": "لا توجد طبقات مطابقة"
} }
+11 -1
View File
@@ -3629,5 +3629,15 @@
"Scope opacity": "Scope-Deckkraft", "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", "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 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"
} }
+11 -1
View File
@@ -3716,5 +3716,15 @@
"Scope opacity": "Opacidad de la mirilla", "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", "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 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"
} }
+11 -1
View File
@@ -3716,5 +3716,15 @@
"Scope opacity": "Opacité de la lunette", "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", "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 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"
} }
+11 -1
View File
@@ -3716,5 +3716,15 @@
"Scope opacity": "אטימות הסקופ", "Scope opacity": "אטימות הסקופ",
"How dark the scope shades the view outside the keyhole": "עד כמה הסקופ מכהה את התצוגה מחוץ לחור המנעול", "How dark the scope shades the view outside the keyhole": "עד כמה הסקופ מכהה את התצוגה מחוץ לחור המנעול",
"Reset to default": "איפוס לברירת המחדל", "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": "אין שכבות תואמות"
} }
+11 -1
View File
@@ -3716,5 +3716,15 @@
"Scope opacity": "Opacità dello scope", "Scope opacity": "Opacità dello scope",
"How dark the scope shades the view outside the keyhole": "Quanto lo scope scurisce la vista fuori dal foro", "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 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"
} }
+11 -1
View File
@@ -3542,5 +3542,15 @@
"Scope opacity": "スコープの不透明度", "Scope opacity": "スコープの不透明度",
"How dark the scope shades the view outside the keyhole": "キーホール外のビューをスコープがどれだけ暗くするか", "How dark the scope shades the view outside the keyhole": "キーホール外のビューをスコープがどれだけ暗くするか",
"Reset to default": "既定に戻す", "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": "一致するレイヤーはありません"
} }
+11 -1
View File
@@ -3716,5 +3716,15 @@
"Scope opacity": "Opacidade da mira", "Scope opacity": "Opacidade da mira",
"How dark the scope shades the view outside the keyhole": "Quanto a mira escurece a vista fora do visor", "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 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"
} }
+11 -1
View File
@@ -3803,5 +3803,15 @@
"Scope opacity": "Непрозрачность прицела", "Scope opacity": "Непрозрачность прицела",
"How dark the scope shades the view outside the keyhole": "Насколько прицел затемняет вид за пределами маски", "How dark the scope shades the view outside the keyhole": "Насколько прицел затемняет вид за пределами маски",
"Reset to default": "Сбросить по умолчанию", "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": "Нет подходящих слоёв"
} }
+11 -1
View File
@@ -3629,5 +3629,15 @@
"Scope opacity": "Scope-opacitet", "Scope opacity": "Scope-opacitet",
"How dark the scope shades the view outside the keyhole": "Hur mycket scopet mörkar vyn utanför nyckelhålet", "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 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"
} }
+11 -1
View File
@@ -3803,5 +3803,15 @@
"Scope opacity": "Непрозорість прицілу", "Scope opacity": "Непрозорість прицілу",
"How dark the scope shades the view outside the keyhole": "Наскільки приціл затемнює вигляд за межами прицілу", "How dark the scope shades the view outside the keyhole": "Наскільки приціл затемнює вигляд за межами прицілу",
"Reset to default": "Скинути до типових", "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": "Немає відповідних шарів"
} }
+11 -1
View File
@@ -3542,5 +3542,15 @@
"Scope opacity": "视域不透明度", "Scope opacity": "视域不透明度",
"How dark the scope shades the view outside the keyhole": "视域遮罩使视域之外的画面变暗的程度", "How dark the scope shades the view outside the keyhole": "视域遮罩使视域之外的画面变暗的程度",
"Reset to default": "恢复默认", "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": "没有匹配的图层"
} }
+11 -1
View File
@@ -3542,5 +3542,15 @@
"Scope opacity": "視域不透明度", "Scope opacity": "視域不透明度",
"How dark the scope shades the view outside the keyhole": "視域遮罩使視域之外的畫面變暗的程度", "How dark the scope shades the view outside the keyhole": "視域遮罩使視域之外的畫面變暗的程度",
"Reset to default": "還原預設", "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": "沒有符合的圖層"
} }
+4 -2
View File
@@ -251,7 +251,8 @@
"Regional", "Regional",
"OPT-IN", "OPT-IN",
"{minutes} min", "{minutes} min",
"1 min/s" "1 min/s",
"Status"
], ],
"es": [ "es": [
"1 min/s", "1 min/s",
@@ -1275,7 +1276,8 @@
"Period", "Period",
"ICAO: {id}", "ICAO: {id}",
"NORAD: {id}", "NORAD: {id}",
"MMSI: {id}" "MMSI: {id}",
"Status"
], ],
"uk": [ "uk": [
"1900–1945", "1900–1945",
+3
View File
@@ -248,6 +248,9 @@ export function onKeyDown(event) {
if (event.target?.closest?.('.cesium-credit-lightbox')) return; if (event.target?.closest?.('.cesium-credit-lightbox')) return;
// An open Display or Radio popover takes Escape itself. // An open Display or Radio popover takes Escape itself.
if (this.isUtilityDisclosureOpen?.()) return; 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) { if (this.context?.contains(event.target) && !this.contextCollapsed) {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
+200
View File
@@ -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,
);
});
+5 -3
View File
@@ -22,9 +22,11 @@ defineProps({
<template> <template>
<template v-for="item in items" :key="item.key" <template v-for="item in items" :key="item.key"
><h3 v-if="item.heading" class="data-layer-group-heading">{{ ><h3
item.heading v-if="item.heading"
}}</h3 class="data-layer-group-heading"
:data-group="item.key"
>{{ item.heading }}</h3
><div ><div
v-else v-else
class="data-toggle-row" class="data-toggle-row"
+206
View File
@@ -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;
}
+107
View File
@@ -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;
}
+5
View File
@@ -14,6 +14,8 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue';
import DrawControls from './panels/DrawControls.vue'; import DrawControls from './panels/DrawControls.vue';
import GeoImport from './panels/GeoImport.vue'; import GeoImport from './panels/GeoImport.vue';
import UiSelect from '../kit/UiSelect.js'; import UiSelect from '../kit/UiSelect.js';
import LayerSectionIndex from './panels/LayerSectionIndex.vue';
import LayerFinder from './panels/LayerFinder.vue';
import DataLibraryBrowse from './panels/DataLibraryBrowse.vue'; import DataLibraryBrowse from './panels/DataLibraryBrowse.vue';
// Scene sharing (src/scenes/sharing.js) offers its authoring bar and presents // 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"> <div id="left-panel-stack" class="ui-scroll">
<!-- Data Layer Toggle Panel --> <!-- Data Layer Toggle Panel -->
<RailPanel id="data-panel" start-collapsed class="active" :title="t('DATA LAYERS')"> <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> <div id="data-toggles" class="data-toggle-list ui-scroll"></div>
<LayerSectionIndex />
<!-- Data Library: Browse and its dialog (src/ui/libraryBrowser.js). --> <!-- Data Library: Browse and its dialog (src/ui/libraryBrowser.js). -->
<DataLibraryBrowse /> <DataLibraryBrowse />
</RailPanel> </RailPanel>
+261
View File
@@ -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>
+124
View File
@@ -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>
+4
View File
@@ -3727,6 +3727,10 @@ function findDataLayerRow(layerId) {
} }
function focusDataLayerRow(row, 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.scrollIntoView({ block: 'center', behavior: 'smooth' });
row.classList.remove('gev-voice-focus'); row.classList.remove('gev-voice-focus');
void row.offsetWidth; void row.offsetWidth;
+5 -1
View File
@@ -1954,7 +1954,11 @@ test('showing a layer opens the panel that holds its row: Directions lives in Ut
const focused = []; const focused = [];
const rowIn = (panelId) => ({ const rowIn = (panelId) => ({
dataset: { layerName: panelId === 'utilities-panel' ? 'Directions' : 'Live Flights' }, 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() {}, scrollIntoView() {},
closest: (selector) => (selector === '[data-panel-id]' ? { id: panelId } : null), closest: (selector) => (selector === '[data-panel-id]' ? { id: panelId } : null),
}); });
+2
View File
@@ -50,6 +50,8 @@
@import './src/ui/styles/left-tools.css'; @import './src/ui/styles/left-tools.css';
@import './src/ui/styles/panel-edge.css'; @import './src/ui/styles/panel-edge.css';
@import './src/ui/styles/visual-panel.css'; @import './src/ui/styles/visual-panel.css';
@import './src/ui/styles/layer-section-index.css';
@import './src/ui/styles/layer-finder.css';
@import './src/ui/styles/cockpit-panels.css'; @import './src/ui/styles/cockpit-panels.css';
@import './src/ui/styles/ai-chat.css'; @import './src/ui/styles/ai-chat.css';
@import './src/ui/styles/scope-off-shadows.css'; @import './src/ui/styles/scope-off-shadows.css';