Add a finder and a section index to Data Layers

The Data Layers panel gains a search field, an All / On / Off and category
filter, and a tick index down its side for jumping between sections. Both
sit beside the Data Library browse control.

Co-authored-by: Sameh Khamis <[email protected]>
This commit is contained in:
Manjunath PaparajuandSameh Khamis authored and Sameh Khamis committed 2026-11-14 16:12:59 -08:00
1 parent b7160492e0
commit 584e77dc4f
5 files changed
+512

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
/* ── 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;
}
.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;
}
#data-toggles.is-filtering[data-shown='0']::after {
content: 'No layers match';
display: block;
padding: 1rem 0.5rem;
color: rgba(199, 214, 222, 0.5);
font-size: 0.72rem;
text-align: center;
}
.layer-finder__menu {
max-height: 60vh;
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;
}
.layer-finder__menu-head:not(:first-child) {
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;
}
+98
View File
@@ -0,0 +1,98 @@
/* ── Data Layers group index: ticks along the panel's right edge ─────── */
#data-panel {
position: relative;
}
.layer-section-index {
position: absolute;
top: 64px;
right: 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, shown to the left (inside the panel) on hover or
focus. Hidden labels take no room, so nothing reaches past the panel. */
.layer-section-index__label {
position: absolute;
top: 50%;
right: 14px;
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;
display: none;
opacity: 0;
transform: translate(4px, -50%);
pointer-events: none;
z-index: 3;
transition:
opacity 120ms ease,
transform 120ms ease;
}
.layer-section-index__tick:hover .layer-section-index__label,
.layer-section-index__tick:focus-visible .layer-section-index__label {
display: block;
opacity: 1;
transform: translate(0, -50%);
}
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>
+148
View File
@@ -0,0 +1,148 @@
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { 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 state = globalThis.__gevLayerFinder ??= {
query: ref(''),
show: ref('all'),
searching: ref(false),
filtering: ref(false),
category: ref(''),
categories: ref([]),
};
const { query, show, searching, filtering, category, categories } = state;
const input = ref(null);
const SHOWS = [
{ id: 'all', label: 'All layers' },
{ id: 'on', label: 'On' },
{ id: 'off', label: 'Off' },
];
function apply() {
const list = document.getElementById('data-toggles');
if (!list) return;
const q = query.value.trim().toLowerCase();
let heading = null;
let headingHasRows = false;
let shown = 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.textContent.trim();
groups.push(group);
continue;
}
if (!el.classList.contains('data-toggle-row')) continue;
const name = `${el.dataset.layerName || ''} ${el.querySelector('.data-toggle-meta')?.textContent || ''}`.toLowerCase();
const on = (el.querySelector('.data-toggle-btn')?.dataset.feedState || 'off') !== 'off';
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;
shown++;
}
}
close();
list.classList.toggle('is-filtering', Boolean(q) || show.value !== 'all' || Boolean(category.value));
if (groups.join('|') !== categories.value.join('|')) categories.value = groups;
list.dataset.shown = String(shown);
}
let observer = null;
onMounted(() => {
if (props.part !== 'bar') return;
const list = document.getElementById('data-toggles');
if (!list) return;
observer = new MutationObserver(() => apply());
observer.observe(list, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-feed-state'] });
apply();
});
onBeforeUnmount(() => observer?.disconnect());
if (props.part === 'bar') watch([query, show, category], apply);
async function toggleSearch() {
searching.value = !searching.value;
if (!searching.value) query.value = '';
await nextTick();
if (searching.value) document.getElementById('layer-finder-input')?.focus();
}
function pick(id) {
show.value = id;
}
function pickCategory(name) {
category.value = category.value === name ? '' : name;
}
const active = () => show.value !== 'all' || Boolean(category.value);
// The menu stays open while choosing; a click outside it or Esc closes it.
const filterEl = ref(null);
function onOutside(e) {
if (filtering.value && filterEl.value && !filterEl.value.contains(e.target)) filtering.value = false;
}
function onEsc(e) {
if (e.key === 'Escape') filtering.value = false;
}
if (props.part === 'buttons') {
onMounted(() => {
document.addEventListener('pointerdown', onOutside, true);
document.addEventListener('keydown', onEsc);
});
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onOutside, true);
document.removeEventListener('keydown', onEsc);
});
}
</script>
<template>
<template v-if="part === 'buttons'">
<button 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 type="button" class="layer-finder__btn" :class="{ active: show !== 'all' || category }" :aria-expanded="String(filtering)" :aria-label="t('Filter layers')" :title="t('Filter layers')" @click="filtering = !filtering">
<span class="material-symbols-outlined" aria-hidden="true">filter_list</span>
</button>
<span v-if="filtering" class="layer-finder__menu" role="menu">
<span class="layer-finder__menu-head">{{ t('Status') }}</span>
<button v-for="s in SHOWS" :key="s.id" type="button" role="menuitemradio" :aria-checked="String(show === s.id)" :class="{ active: show === s.id }" @click="pick(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 v-if="categories.length" class="layer-finder__menu-head">{{ t('Category') }}</span>
<button v-for="c in categories" :key="c" type="button" role="menuitemradio" :aria-checked="String(category === c)" :class="{ active: category === c }" @click="pickCategory(c)">
<span class="material-symbols-outlined" aria-hidden="true">{{ category === c ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>{{ c }}
</button>
</span>
</span>
</template>
<div v-else-if="searching || active()" 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" ref="input" v-model="query" type="search" :placeholder="t('Search layers…')" :aria-label="t('Search layers')" @keydown.esc="toggleSearch" />
</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 = ''">
{{ category }}<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</div>
</template>
+74
View File
@@ -0,0 +1,74 @@
<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() {
sections.value = headings().map((h, i) => ({ i, label: h.textContent.trim() }));
syncCurrent();
}
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);
}
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(readSections);
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();
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)"
>
<span class="layer-section-index__label" aria-hidden="true">{{ section.label }}</span>
</button>
</nav>
</template>