mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
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:
1 parent
b7160492e0
commit
584e77dc4f
5 files changed
+512
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user