doxa-maps/plugin-designs/pplr-data-maps/src/components/SemanticTreeLegend.vue
doxa-maps-88655c70 17931ed031 PPLR data maps: every view loads under MapLibre, and the legend has accessible names
Under provider=maplibre (the keyless gallery) four of the seven map views never
finished loading. They built `new mapboxgl.NavigationControl` in the load
handler, and that global does not exist under MapLibre, so they sat on
"Loading…". Popups and the pin marker had the same fault. Every such call now
goes through glFor(map), the namespace of the engine that built the map. The
views' styles and the Dark/Satellite/Light switch now use registry names (ink,
hybrid, paper), which are the same Mapbox styles under Mapbox and OpenFreeMap
under MapLibre.

The search box loads its geocoder by engine, as GeocoderComponent does. Under
MapLibre it is MaplibreGeocoder over this map's own local index, so the keyless
gallery makes no request to api.mapbox.com at all.

The legend's row caret, clear chip and collapse/reopen buttons carry accessible
names and aria-expanded from legend.a11y.* and aria.* (optional i18n, English
fallback), the keys the library legend uses. check:shared-reached now counts
pplr-data-maps as carrying legend-a11y-keys.

window.__pplrMap() reports what the active view's map did (engine, load fired,
style layers, this bundle's data sources), so "loaded" is asserted, not inferred.

Measured on a served built tree, host page on another origin: all ten views
loaded under MapLibre and under Mapbox (load fired, style layers and data
sources present, canvas not blank, 0 errors), with 0 api.mapbox.com requests
under MapLibre. The same check on the parent build fails four map views under
MapLibre. A control with no provider makes 13 Mapbox requests.
2026-09-25 09:57:17 +00:00

967 lines
40 KiB
Vue

<script setup>
/**
* SemanticTreeLegend — reusable hierarchical legend for any one-to-many semantic tree.
*
* Decoupled from map and geocoder; communicates via the shared per-instance store
* (Mediator Pattern) when one is provided. Falls back to local state when standalone.
*
* Tree node shape:
* { id, label, color?, count?, pop?, filter?, info?, children?: TreeNode[] }
*
* Per QA architecture decisions:
* - Tab N = Generation N (1-indexed in user-facing labels). Tab 1 = root.
* - Click-through deselect: clicking a different row replaces selection.
* - Inline X chip on the selected row (slot reserved on every row → columns stay aligned).
* - 30% dim on non-selected rows EXCEPT descendants of the selected node (so the user
* can drill down into a selected family without losing visibility).
* - Breadcrumb: title row swaps to "{ParentTabName}: {ParentLabel}" when a child is
* selected (e.g. "Lang Family: Afro-Asiatic" when Arabic is selected on Languages tab).
* - Sortable UPGs / Pop column headers — click to toggle, click again to flip direction.
* - No in-legend search bar — search lives in the parent's PplrGeocoder above this panel
* (one search per map cluster, per QA "unified geocoder" decision).
*/
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
// Accessible names from the shared catalog (legend.a11y.*, aria.*), the keys the library legend
// uses, so they are translated in all eleven locales. i18n is OPTIONAL: on a host without it, the
// English fallback below is used, never a raw key.
const A11Y_EN = {
'legend.a11y.expandRow': 'Expand {label}', 'legend.a11y.collapseRow': 'Collapse {label}',
'legend.a11y.clearSelection': 'Clear selection',
'aria.expandLegend': 'Expand legend', 'aria.collapseLegend': 'Collapse legend',
}
const fillA11y = (str, p) => String(str).replace(/\{(\w+)\}/g, (m, k) => (p && k in p ? p[k] : m))
let ta = (key, p) => fillA11y(A11Y_EN[key] ?? key, p)
try {
const i18n = useI18n()
ta = (key, p) => { const v = i18n.t(key, p || {}); return v === key ? fillA11y(A11Y_EN[key] ?? key, p) : v }
} catch (_) { /* no i18n on this host */ }
import { useInstance } from '../composables/usePplrInstance.js'
const props = defineProps({
nodes: { type: Array, default: () => [] },
tabs: { type: Array, default: null },
columns: { type: Array, default: () => ['count', 'pop'] },
title: { type: String, default: 'Legend' },
})
const emit = defineEmits(['select'])
const instance = useInstance()
// ── Tree traversal ─────────────────────────────────────────────────────────────
function treeMaxDepth(nodes, d = 0) {
if (!nodes?.length) return d
return Math.max(...nodes.map(n => treeMaxDepth(n.children ?? [], d + 1)))
}
function collectAtDepth(nodes, target, curr = 0) {
if (curr === target) return nodes.slice()
return nodes.flatMap(n => collectAtDepth(n.children ?? [], target, curr + 1))
}
function findAncestorChain(nodes, targetId, chain = []) {
for (const node of nodes) {
if (node.id === targetId) return chain
if (node.children?.length) {
const found = findAncestorChain(node.children, targetId, [...chain, node])
if (found) return found
}
}
return null
}
function collectAllDescendantIds(node, out = new Set()) {
if (!node?.children?.length) return out
for (const c of node.children) {
out.add(c.id)
collectAllDescendantIds(c, out)
}
return out
}
// ── Derived ────────────────────────────────────────────────────────────────────
const treeDepth = computed(() => treeMaxDepth(props.nodes))
const tabList = computed(() => {
const src = props.tabs ?? []
const len = src.length || (treeDepth.value + 1)
return Array.from({ length: len }, (_, i) =>
src[i] ?? { id: `gen${i+1}`, label: `Generation ${i + 1}` }
)
})
// ── State ─────────────────────────────────────────────────────────────────────
const localActiveTab = ref(0)
const expandedIds = ref(new Set())
const localSelected = ref(null)
const panelOpen = ref(true)
const showInfoForTab = ref(null)
const sortBy = ref(null) // null = default (pop desc), 'count' | 'pop'
const sortDir = ref('desc') // 'desc' | 'asc'
const activeTab = computed({
get: () => instance ? instance.activeTab.value : localActiveTab.value,
set: v => {
if (instance) instance.activeTab.value = v
else localActiveTab.value = v
},
})
const selectedNode = computed({
get: () => instance ? instance.selection.value : localSelected.value,
set: v => {
if (instance) instance.selection.value = v
else localSelected.value = v
},
})
const selectedId = computed(() => selectedNode.value?.id || null)
watch(() => props.nodes, () => {
activeTab.value = 0
expandedIds.value = new Set()
selectedNode.value = null
emit('select', null)
}, { deep: false })
let _lastInternalId = null
if (instance) {
watch(() => instance.selection.value, sel => {
if (sel?.id === _lastInternalId) return
if (!sel) { _lastInternalId = null; return }
_lastInternalId = sel.id
const chain = findAncestorChain(props.nodes, sel.id) || []
if (typeof sel.depth === 'number') {
activeTab.value = Math.min(sel.depth, tabList.value.length - 1)
}
const tabIdx = activeTab.value
// Clear OTHER expansions; only expand the ancestor chain so the selection
// is actually visible in the rows (per QA: don't leave Indo-European open
// when user picked an Afro-Asiatic-equivalent result).
const newExp = new Set()
chain.slice(tabIdx).forEach(p => newExp.add(p.id))
expandedIds.value = newExp
emit('select', sel)
scrollSelectedIntoView()
})
}
// Scroll the currently-selected row into view inside the legend's scroll
// container — useful after geocoder picks where the row may be far down.
//
// Shadow-DOM caveat (per user 2026-05-02 — geocoder picks "selected" the row
// but the highlight never showed because the row was off-screen + the old
// document.querySelector couldn't see into the shadow root, so the
// scroll-into-view silently failed). Walk via panelRoot to query inside the
// shadow root, fall back to document for non-shadow hosts.
function scrollSelectedIntoView() {
nextTick(() => {
const id = selectedNode.value?.id
if (!id) return
const sel = `[data-row-id="${CSS.escape(id)}"]`
const root = panelRoot.value?.getRootNode?.()
const el = (root && root !== document && root.querySelector?.(sel))
|| panelRoot.value?.querySelector?.(sel)
|| document.querySelector(sel)
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
}
const panelRoot = ref(null)
// ── Sort ───────────────────────────────────────────────────────────────────────
function defaultSort(a, b) {
const pd = (b.pop ?? 0) - (a.pop ?? 0)
return pd !== 0 ? pd : (b.count ?? 0) - (a.count ?? 0)
}
const byActiveSort = computed(() => {
if (!sortBy.value) return defaultSort
const key = sortBy.value
const dir = sortDir.value === 'asc' ? 1 : -1
return (a, b) => (((a[key] ?? 0) - (b[key] ?? 0)) * dir)
})
function clickSortHeader(key) {
if (sortBy.value === key) {
// toggle direction; third click clears back to default
if (sortDir.value === 'desc') sortDir.value = 'asc'
else { sortBy.value = null; sortDir.value = 'desc' }
} else {
sortBy.value = key
sortDir.value = 'desc'
}
}
// ── Visible rows ───────────────────────────────────────────────────────────────
// Deferred per-tab rendering: tab switches happen INSTANTLY (activeTab updates
// synchronously, "Loading…" placeholder shows). The expensive walk + sort runs
// in setTimeout(0) so the click handler returns immediately. Cache prevents
// repeated work when re-visiting a tab.
//
// Why: clicking Tab 4 (PGIC, ~21k leaves) used to block the click handler for
// ~50ms while collectAtDepth walked 34k nodes. Deferred render makes the tab
// switch feel instant; rows fill in on the next frame.
const tabResults = ref([]) // tabIdx → sorted Array of nodes
const tabLoading = ref(false) // true while async work for current tab is in flight
let _pendingTab = -1
function ensureTabComputed(tab) {
if (tabResults.value[tab]) { tabLoading.value = false; return }
tabLoading.value = true
_pendingTab = tab
setTimeout(() => {
if (_pendingTab !== tab) return // user moved on; skip stale work
const result = [...collectAtDepth(props.nodes, tab)].sort(byActiveSort.value)
const next = [...tabResults.value]
next[tab] = result
tabResults.value = next
tabLoading.value = false
}, 0)
}
watch(activeTab, t => ensureTabComputed(t), { immediate: true })
watch(() => [props.nodes, sortBy.value, sortDir.value], () => {
// Underlying data or sort changed → invalidate every cached tab and recompute
// the current one.
tabResults.value = []
ensureTabComputed(activeTab.value)
}, { deep: false })
const topNodesAll = computed(() => tabResults.value[activeTab.value] || [])
const topNodes = computed(() => {
const limit = tabList.value[activeTab.value]?.topLimit
return limit ? topNodesAll.value.slice(0, limit) : topNodesAll.value
})
const topNodesOverflow = computed(() => {
const limit = tabList.value[activeTab.value]?.topLimit
if (!limit) return 0
return Math.max(0, topNodesAll.value.length - limit)
})
function buildRows(out, nodes, relDepth, parent, ancestors) {
for (const node of nodes) {
out.push({ node, relDepth, parent, ancestors })
if (expandedIds.value.has(node.id) && node.children?.length) {
buildRows(out, [...node.children].sort(byActiveSort.value), relDepth + 1, node, [...ancestors, node])
}
}
}
const visibleRows = computed(() => {
const out = []
buildRows(out, topNodes.value, 0, null, [])
// Annotate each row with hairline-trunk segments so we can render
// pipe / tee / elbow / blank lines per indent column. This makes the legend
// look like an ASCII tree (└── / ├──) — sibling-aware so the trunk only
// continues past a row if there are more siblings below at that ancestor level.
for (let i = 0; i < out.length; i++) {
const row = out[i]
const segs = []
for (let L = 0; L < row.relDepth; L++) {
// Identity of ancestor at level L for THIS row
const ancAtL = row.ancestors[L]
// Look forward to find: any later row whose ancestor at L is the same?
let trunkContinues = false
for (let j = i + 1; j < out.length; j++) {
const next = out[j]
if (next.relDepth <= L) break // walked out of this branch
if (next.relDepth === L + 1 && next.ancestors[L] === ancAtL) {
trunkContinues = true; break
}
if (next.relDepth > L + 1 && next.ancestors[L] === ancAtL) {
// descendants of an EARLIER child of ancestor — keep looking
continue
}
}
if (L === row.relDepth - 1) {
// Connector to MY parent — does my parent have more children below me?
let hasLaterSibling = false
for (let j = i + 1; j < out.length; j++) {
const next = out[j]
if (next.relDepth < row.relDepth) break
if (next.relDepth === row.relDepth && next.parent === row.parent) {
hasLaterSibling = true; break
}
}
segs.push(hasLaterSibling ? 'tee' : 'elbow')
} else {
segs.push(trunkContinues ? 'pipe' : 'blank')
}
}
row.trunks = segs
}
return out
})
const hasSel = computed(() => selectedId.value !== null)
const hasCount = computed(() => props.columns.includes('count'))
const hasPop = computed(() => props.columns.includes('pop'))
// Descendants of the selected node — kept full-opacity so the user can drill in
const selectedDescendants = computed(() => {
if (!selectedNode.value) return new Set()
return collectAllDescendantIds(selectedNode.value)
})
// ── Parent breadcrumb (uses parent's TAB name, not generic "Parent:") ────────
const parentBreadcrumb = computed(() => {
const sel = selectedNode.value
if (!sel) return null
const chain = findAncestorChain(props.nodes, sel.id)
if (!chain || !chain.length) return null
const parent = chain[chain.length - 1]
const parentDepth = chain.length - 1
const tabName = tabList.value[parentDepth]?.label || 'Parent'
return { node: parent, tabName }
})
// ── Actions ────────────────────────────────────────────────────────────────────
function selectNode(node) {
if (selectedId.value === node.id) {
_lastInternalId = null
selectedNode.value = null
emit('select', null)
} else {
_lastInternalId = node.id
selectedNode.value = node
emit('select', node)
scrollSelectedIntoView()
}
}
function toggleExpand(id, e) {
e?.stopPropagation()
const s = new Set(expandedIds.value)
s.has(id) ? s.delete(id) : s.add(id)
expandedIds.value = s
}
function switchTab(idx) {
if (activeTab.value === idx) {
showInfoForTab.value = showInfoForTab.value === idx ? null : idx
return
}
activeTab.value = idx
expandedIds.value = new Set()
_lastInternalId = null
selectedNode.value = null
showInfoForTab.value = null
emit('select', null)
}
function deselect(e) {
e?.stopPropagation()
_lastInternalId = null
selectedNode.value = null
emit('select', null)
}
function showInfo(idx, e) {
e?.stopPropagation()
showInfoForTab.value = showInfoForTab.value === idx ? null : idx
}
// ── Formatters ─────────────────────────────────────────────────────────────────
function fmtPop(n) {
if (!n) return '—'
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
if (n >= 1e3) return Math.round(n / 1e3) + 'k'
return n.toLocaleString()
}
function fmtCount(n) { return n != null ? n.toLocaleString() : '—' }
// Per-row class helper — descendants of selected stay full-opacity
function rowClasses(row) {
return {
selected: selectedId.value === row.node.id,
descendant: hasSel.value && selectedDescendants.value.has(row.node.id),
child: row.relDepth > 0,
leaf: !row.node.children?.length,
}
}
</script>
<template>
<!-- Two roots so the collapsed-state chip isn't dragged off-screen by the
panel's translateX(-100%). The chip is a sibling, not a child. -->
<div ref="panelRoot" class="stl-panel" :class="{ closed: !panelOpen }" :data-theme="instance?.theme.value || 'dark'">
<div class="stl-inner">
<!-- Title bar — swaps to "{Tab name}: {Parent label}" when a child is selected.
Corner caret on the LEFT collapses the panel (replaces the old right-edge ribbon). -->
<div class="stl-titlebar">
<button
class="stl-collapse-btn"
:class="{ closed: !panelOpen }"
:title="panelOpen ? 'Collapse legend' : 'Expand legend'"
:aria-label="panelOpen ? ta('aria.collapseLegend') : ta('aria.expandLegend')"
:aria-expanded="String(panelOpen)"
@click="panelOpen = !panelOpen">
<svg width="9" height="9" viewBox="0 0 12 12" fill="none">
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<template v-if="parentBreadcrumb && selectedNode">
<span class="stl-tb-eyebrow">{{ parentBreadcrumb.tabName }}</span>
<span v-if="parentBreadcrumb.node.color" class="stl-tb-dot"
:style="{ background: parentBreadcrumb.node.color }"></span>
<span class="stl-tb-text" :title="parentBreadcrumb.node.label">{{ parentBreadcrumb.node.label }}</span>
</template>
<template v-else>
<span class="stl-tb-title">{{ title }}</span>
</template>
</div>
<!-- Tabs (one per generation) — single row -->
<div class="stl-tabs-wrap">
<div class="stl-tabs">
<div v-for="(tab, idx) in tabList" :key="tab.id"
class="stl-tab-chip" :class="{ active: activeTab === idx }">
<button class="stl-tab" @click="switchTab(idx)">{{ tab.label }}</button>
<button v-if="tab.info" class="stl-tab-info" :title="tab.info"
@click="showInfo(idx, $event)">i</button>
</div>
</div>
<div v-if="showInfoForTab !== null && tabList[showInfoForTab]?.info" class="stl-info-pop">
{{ tabList[showInfoForTab].info }}
</div>
</div>
<!-- Column headers — sortable -->
<div class="stl-col-hdr">
<span class="stl-caret-sp"></span>
<span class="stl-dot-sp"></span>
<span class="stl-hdr-name">{{ tabList[activeTab]?.label || 'Name' }}</span>
<button v-if="hasCount" class="stl-hdr-num"
:class="{ active: sortBy === 'count' }"
title="Sort by UPGs"
@click="clickSortHeader('count')">
UPGs
<span v-if="sortBy === 'count'" class="stl-sort-arrow">{{ sortDir === 'desc' ? '▼' : '▲' }}</span>
</button>
<button v-if="hasPop" class="stl-hdr-num"
:class="{ active: sortBy === 'pop' }"
title="Sort by Population"
@click="clickSortHeader('pop')">
Pop
<span v-if="sortBy === 'pop'" class="stl-sort-arrow">{{ sortDir === 'desc' ? '▼' : '▲' }}</span>
</button>
<span class="stl-x-slot"></span>
</div>
<!-- Rows -->
<div class="stl-rows" :class="{ 'has-sel': hasSel }">
<div v-for="row in visibleRows" :key="row.node.id"
class="stl-row" :class="rowClasses(row)"
:data-row-id="row.node.id"
:style="{
'--trunk-color': (row.parent?.color || '#6e7681'),
'--row-color': (row.node.color || '#73A17F'),
}"
@click="selectNode(row.node)">
<!-- Hairline tree connectors (one per indent level) — sibling-aware -->
<span v-for="(seg, i) in row.trunks" :key="`tr${i}`"
class="stl-trunk" :class="`stl-trunk-${seg}`"></span>
<button v-if="row.node.children?.length"
class="stl-caret" :class="{ open: expandedIds.has(row.node.id) }"
:aria-expanded="expandedIds.has(row.node.id) ? 'true' : 'false'"
:aria-label="ta(expandedIds.has(row.node.id) ? 'legend.a11y.collapseRow' : 'legend.a11y.expandRow', { label: row.node.label })"
@click.stop="toggleExpand(row.node.id, $event)">
<svg width="9" height="9" viewBox="0 0 12 12" fill="none">
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<span v-else class="stl-caret-sp"></span>
<span v-if="row.node.color" class="stl-dot" :style="{ background: row.node.color }"></span>
<span v-else class="stl-dot-sp"></span>
<span class="stl-name">{{ row.node.label }}</span>
<span v-if="hasCount" class="stl-num">{{ fmtCount(row.node.count) }}</span>
<span v-if="hasPop" class="stl-num">{{ fmtPop(row.node.pop) }}</span>
<span class="stl-x-slot">
<button v-if="selectedId === row.node.id"
class="stl-x-chip" :title="ta('legend.a11y.clearSelection')" :aria-label="ta('legend.a11y.clearSelection')"
@click.stop="deselect($event)">
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
<path d="M2 2L10 10M10 2L2 10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</span>
</div>
<div v-if="tabLoading && !visibleRows.length" class="stl-empty stl-loading">
<span class="stl-spinner"></span>
<span>Loading {{ tabList[activeTab]?.label || 'rows' }}…</span>
</div>
<div v-else-if="!visibleRows.length && nodes.length === 0" class="stl-empty">Loading data…</div>
<div v-else-if="!visibleRows.length" class="stl-empty">No data at this level</div>
<div v-if="topNodesOverflow > 0" class="stl-overflow">
Showing top {{ tabList[activeTab].topLimit.toLocaleString() }} of
{{ topNodesAll.length.toLocaleString() }} —
<span class="stl-overflow-hint">use the geocoder above to find the rest</span>
</div>
</div>
</div>
</div>
<!-- Collapsed-state pill — sibling of .stl-panel so the panel's translateX
doesn't take it off-screen. Sits where the panel used to be. -->
<button v-if="!panelOpen"
class="stl-reopen"
:data-theme="instance?.theme.value || 'dark'"
:title="ta('aria.expandLegend')"
:aria-label="ta('aria.expandLegend')"
aria-expanded="false"
@click="panelOpen = true">
<svg class="stl-reopen-icon" width="11" height="11" viewBox="0 0 12 12" fill="none">
<path d="M2 4h8M2 6h8M2 8h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
<span class="stl-reopen-label">
<template v-if="selectedNode">
<span v-if="selectedNode.color" class="stl-reopen-dot"
:style="{ background: selectedNode.color }"></span>
{{ selectedNode.label }}
</template>
<template v-else>{{ title }}</template>
</span>
<svg width="9" height="9" viewBox="0 0 12 12" fill="none" class="stl-reopen-caret">
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</template>
<style scoped>
/* ── Single source of truth for column widths ─────────────────────────────────── */
.stl-inner {
--stl-pad-x: 10px;
--stl-gap: 6px;
--stl-caret-w: 14px;
--stl-dot-w: 8px;
--stl-num-w: 44px;
--stl-x-w: 22px;
flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column;
background: #161b22; border: 1px solid #30363d;
border-radius: 12px;
box-shadow: 0 6px 24px rgba(0,0,0,0.45);
overflow: hidden;
}
/* ── Panel wrapper — wider sidebar (no more right-edge ribbon eating space) ─── */
.stl-panel {
position: absolute; left: 8px; top: 60px; bottom: 12px; width: 380px; z-index: 4;
display: flex; flex-direction: column;
transition: transform 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.2s;
pointer-events: auto;
}
.stl-panel.closed {
transform: translateX(-100%);
opacity: 0; pointer-events: none;
}
/* ── Title bar (~32px) ────────────────────────────────────────────────────────── */
.stl-titlebar {
display: flex; align-items: center; gap: 6px;
padding: 8px 12px; flex-shrink: 0;
border-bottom: 1px solid #21262d;
min-height: 32px;
}
.stl-tb-title,
.stl-tb-eyebrow {
font: 600 10px ui-monospace, monospace;
color: #73A17F; text-transform: uppercase; letter-spacing: 0.07em;
flex-shrink: 0;
}
.stl-tb-eyebrow { color: #6e7681; font-size: 9px; letter-spacing: 0.08em; }
.stl-tb-dot {
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
box-shadow: 0 0 0 1px rgba(0,0,0,0.4), 0 0 0 3px rgba(115,161,127,0.15);
}
.stl-tb-text {
flex: 1; min-width: 0;
font: 600 12px system-ui; color: #e6edf3;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* ── Tabs (single row) ────────────────────────────────────────────────────────── */
.stl-tabs-wrap { flex-shrink: 0; }
.stl-tabs {
display: flex; flex-wrap: nowrap; gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid #21262d;
overflow-x: auto;
scrollbar-width: none;
}
.stl-tabs::-webkit-scrollbar { display: none; }
.stl-tab-chip {
display: inline-flex; align-items: stretch; flex-shrink: 0;
background: #1c2333; border: 1px solid #30363d; border-radius: 6px;
overflow: hidden; transition: border-color 0.12s, box-shadow 0.12s;
}
.stl-tab-chip:hover { border-color: #484f58; }
.stl-tab-chip.active {
background: #3b463d; border-color: #73A17F;
box-shadow: 0 0 0 2px rgba(115,161,127,0.22), 0 1px 4px rgba(59,70,61,0.4);
}
.stl-tab {
display: inline-flex; align-items: center;
background: transparent; border: none;
color: #8b949e; font: 600 10px ui-monospace, monospace;
text-transform: uppercase; letter-spacing: 0.04em;
padding: 4px 8px; cursor: pointer; white-space: nowrap;
}
.stl-tab:hover { color: #c9d1d9; }
.stl-tab-chip.active .stl-tab { color: #fff; }
.stl-tab-info {
background: rgba(110,118,129,0.15); border: none; border-left: 1px solid #30363d;
color: #6e7681; cursor: pointer;
width: 18px; padding: 0; font: 700 9px ui-monospace, monospace;
}
.stl-tab-info:hover { background: rgba(59,70,61,0.2); color: #73A17F; }
.stl-tab-chip.active .stl-tab-info {
border-left-color: rgba(0,0,0,0.3); color: #fff;
background: rgba(0,0,0,0.2);
}
.stl-info-pop {
margin: 2px 8px 6px; padding: 7px 10px;
background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
font: 11px system-ui; color: #c9d1d9; line-height: 1.45;
}
/* ── Shared row layout (header + data rows) ───────────────────────────────────── */
.stl-col-hdr,
.stl-row {
display: flex; align-items: center; gap: var(--stl-gap);
padding: 0 var(--stl-pad-x);
}
.stl-col-hdr {
height: 22px; flex-shrink: 0;
background: #0d1117; border-bottom: 1px solid #21262d;
}
.stl-hdr-name {
flex: 1; min-width: 0;
font: 600 9px ui-monospace, monospace;
color: #6e7681; text-transform: uppercase; letter-spacing: 0.05em;
}
.stl-hdr-num {
width: var(--stl-num-w); text-align: right; flex-shrink: 0;
font: 600 9px ui-monospace, monospace;
color: #6e7681; text-transform: uppercase; letter-spacing: 0.05em;
background: transparent; border: none; cursor: pointer;
padding: 0; transition: color 0.1s;
display: inline-flex; align-items: center; justify-content: flex-end; gap: 3px;
}
.stl-hdr-num:hover { color: #c9d1d9; }
.stl-hdr-num.active { color: #73A17F; }
.stl-sort-arrow { font-size: 8px; }
/* ── Rows container ───────────────────────────────────────────────────────────── */
.stl-rows { flex: 1; overflow-y: auto; padding-bottom: 6px; }
.stl-rows::-webkit-scrollbar { width: 6px; }
.stl-rows::-webkit-scrollbar-track { background: transparent; }
.stl-rows::-webkit-scrollbar-thumb { background: #21262d; border-radius: 3px; }
.stl-rows::-webkit-scrollbar-thumb:hover { background: #30363d; }
.stl-empty {
padding: 20px 12px; text-align: center;
font: 11px system-ui; color: #484f58;
}
.stl-loading {
display: inline-flex; align-items: center; gap: 8px;
width: 100%; justify-content: center;
color: #73A17F;
}
.stl-spinner {
width: 12px; height: 12px; border-radius: 50%;
border: 2px solid rgba(115,161,127,0.25);
border-top-color: #73A17F;
animation: stl-spin 0.7s linear infinite;
}
@keyframes stl-spin { to { transform: rotate(360deg); } }
.stl-overflow {
padding: 8px 10px; margin: 4px 0 0;
background: #0d1117; border-top: 1px dashed #30363d;
text-align: center; font: 10px system-ui; color: #6e7681;
}
.stl-overflow-hint { color: #73A17F; }
/* ── Data rows ────────────────────────────────────────────────────────────────── */
.stl-row {
min-height: 27px;
cursor: pointer; transition: opacity 0.12s, background 0.1s;
border-bottom: 1px solid rgba(33,38,45,0.45);
}
.stl-row:last-child { border-bottom: none; }
.stl-row:hover { background: rgba(255,255,255,0.04); }
.stl-row.child .stl-name { color: #adbac7; }
.stl-row.leaf .stl-name { font-style: italic; }
/* Dim non-selected rows EXCEPT descendants of the selected node (so the user can
drill down into a selected family without the children being grayed out). */
.stl-rows.has-sel .stl-row:not(.selected):not(.descendant) { opacity: 0.30; }
.stl-rows.has-sel .stl-row:not(.selected):not(.descendant):hover { opacity: 0.6; }
.stl-rows.has-sel .stl-row.descendant { opacity: 1; }
.stl-rows.has-sel .stl-row.selected {
opacity: 1;
/* Subtle neutral background tint + soft outer glow in the row's own color so
the selection pops without competing with the family color of the row. */
background: rgba(255,255,255,0.05);
box-shadow:
inset 3px 0 0 0 var(--row-color, #73A17F),
inset 0 0 0 1px rgba(255,255,255,0.05),
0 0 14px -2px var(--row-color, rgba(115,161,127,0.4));
}
.stl-panel[data-theme="light"] .stl-rows.has-sel .stl-row.selected {
background: rgba(255,255,255,0.7);
box-shadow:
inset 3px 0 0 0 var(--row-color, #3b463d),
inset 0 0 0 1px rgba(0,0,0,0.04),
0 0 14px -2px var(--row-color, rgba(59,70,61,0.35));
}
/* ── Reserved slots ───────────────────────────────────────────────────────────── */
.stl-caret-sp { width: var(--stl-caret-w); flex-shrink: 0; }
.stl-dot-sp { width: var(--stl-dot-w); flex-shrink: 0; }
.stl-x-slot {
width: var(--stl-x-w); flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
}
/* ── ASCII-tree hairline trunks ───────────────────────────────────────────────
One span per indent level. Each span is 12px wide. Pseudo-elements draw
thin lines tinted by the row's parent color (--trunk-color):
- pipe = vertical line through full row height (ancestor at this level
still has more siblings below)
- tee = vertical line + right stub at row middle (this row has later siblings)
- elbow = vertical line stops at row middle + right stub (this row is the last child)
- blank = no line (ancestor's chain ended above this row)
*/
.stl-trunk {
position: relative; flex-shrink: 0;
width: 12px; align-self: stretch;
}
.stl-trunk-pipe::before {
content: ''; position: absolute;
left: 5px; top: 0; bottom: 0; width: 1px;
background: var(--trunk-color, #6e7681);
opacity: 0.45;
}
.stl-trunk-tee::before {
content: ''; position: absolute;
left: 5px; top: 0; bottom: 0; width: 1px;
background: var(--trunk-color, #6e7681);
opacity: 0.45;
}
.stl-trunk-tee::after {
content: ''; position: absolute;
left: 5px; top: 50%; height: 1px; width: 7px;
background: var(--trunk-color, #6e7681);
opacity: 0.55;
}
.stl-trunk-elbow::before {
content: ''; position: absolute;
left: 5px; top: 0; height: 50%; width: 1px;
background: var(--trunk-color, #6e7681);
opacity: 0.45;
}
.stl-trunk-elbow::after {
content: ''; position: absolute;
left: 5px; top: calc(50% - 0.5px); height: 1px; width: 7px;
background: var(--trunk-color, #6e7681);
opacity: 0.55;
}
/* selected/descendant rows: brighter trunks */
.stl-row.selected .stl-trunk-pipe::before,
.stl-row.selected .stl-trunk-tee::before,
.stl-row.selected .stl-trunk-tee::after,
.stl-row.selected .stl-trunk-elbow::before,
.stl-row.selected .stl-trunk-elbow::after,
.stl-row.descendant .stl-trunk-pipe::before,
.stl-row.descendant .stl-trunk-tee::before,
.stl-row.descendant .stl-trunk-tee::after,
.stl-row.descendant .stl-trunk-elbow::before,
.stl-row.descendant .stl-trunk-elbow::after {
opacity: 0.85;
}
/* ── Caret button ─────────────────────────────────────────────────────────────── */
.stl-caret {
background: none; border: none; padding: 0; cursor: pointer;
color: #6e7681; width: var(--stl-caret-w); height: var(--stl-caret-w); flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
border-radius: 3px;
}
.stl-caret svg { transition: transform 0.15s; }
.stl-caret.open svg { transform: rotate(90deg); }
.stl-caret:hover { color: #c9d1d9; background: rgba(255,255,255,0.06); }
/* ── Color dot ────────────────────────────────────────────────────────────────── */
.stl-dot {
width: var(--stl-dot-w); height: var(--stl-dot-w); border-radius: 50%; flex-shrink: 0;
box-shadow: 0 0 0 1px rgba(0,0,0,0.4);
}
.stl-row.selected .stl-dot {
box-shadow: 0 0 0 1px rgba(0,0,0,0.4), 0 0 0 3px rgba(115,161,127,0.25);
}
/* ── Label ────────────────────────────────────────────────────────────────────── */
.stl-name {
flex: 1; min-width: 0;
font: 11.5px system-ui, sans-serif; color: #e6edf3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── Numeric columns ──────────────────────────────────────────────────────────── */
.stl-num {
width: var(--stl-num-w); text-align: right; flex-shrink: 0;
font: 10.5px ui-monospace, monospace; color: #8b949e;
font-variant-numeric: tabular-nums;
}
.stl-row.selected .stl-num { color: #e6edf3; }
/* ── Inline X chip — clearer "delete" affordance, no overlap with badges ─────── */
.stl-x-chip {
background: #ef4444; border: none; border-radius: 50%;
color: #fff; cursor: pointer;
width: 18px; height: 18px;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 0 2px rgba(239,68,68,0.18);
transition: transform 0.12s, box-shadow 0.12s;
}
.stl-x-chip:hover { transform: scale(1.08); box-shadow: 0 0 0 3px rgba(239,68,68,0.3); }
/* ── Corner caret (in titlebar) — primary collapse affordance ────────────────── */
.stl-collapse-btn {
background: rgba(110,118,129,0.15); border: 1px solid #30363d; border-radius: 5px;
color: #8b949e; cursor: pointer;
width: 22px; height: 20px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: color 0.1s, background 0.1s, transform 0.18s;
}
.stl-collapse-btn:hover { color: #c9d1d9; background: rgba(59,70,61,0.18); border-color: #73A17F; }
.stl-collapse-btn svg { transition: transform 0.18s; transform: rotate(180deg); }
/* ── Collapsed-state pill — rounded "Legend" chip — matches the search-bar
height/radius/left-edge so the chrome reads as a vertical stack of pills.
z-index intentionally LOWER than the geocoder slot (5) so the search
dropdown can extend over the chip when the user is searching. */
.stl-reopen {
position: absolute; left: 8px; top: 60px; z-index: 3;
display: inline-flex; align-items: center; gap: 8px;
background: #0d1117; border: 1px solid #30363d;
border-radius: 999px;
color: #c9d1d9; cursor: pointer;
height: 36px; /* exact match to search-bar height */
padding: 0 14px 0 12px;
box-shadow: 0 4px 14px rgba(0,0,0,0.25);
font: 600 11px ui-monospace, monospace;
text-transform: uppercase; letter-spacing: 0.06em;
max-width: 320px;
transition: color 0.12s, background 0.12s, border-color 0.12s, transform 0.12s,
box-shadow 0.12s;
}
.stl-reopen:hover {
color: #fff; background: #1c2333; border-color: #73A17F;
box-shadow: 0 6px 20px rgba(59,70,61,0.25);
transform: translateY(-1px);
}
.stl-reopen-icon { color: #73A17F; flex-shrink: 0; }
.stl-reopen-label {
display: inline-flex; align-items: center; gap: 6px;
min-width: 0; max-width: 200px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
text-transform: none; letter-spacing: 0;
font: 600 12px system-ui, sans-serif;
}
.stl-reopen-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
box-shadow: 0 0 0 1px rgba(0,0,0,0.4);
}
.stl-reopen-caret { color: #6e7681; flex-shrink: 0; }
.stl-reopen:hover .stl-reopen-caret { color: #c9d1d9; }
@media (max-width: 640px) {
.stl-panel { width: 92vw; max-width: 380px; top: 64px; bottom: 8px; }
.stl-reopen { top: 64px; }
}
/* ── Light-mode palette ──────────────────────────────────────────────────────
Overrides triggered by [data-theme="light"] on the panel root. Mirrors the
underlying Mapbox light-v11 basemap so chrome and map read as one surface. */
.stl-panel[data-theme="light"] .stl-inner {
background: #ffffff;
border-color: #d0d7de;
box-shadow: 0 6px 24px rgba(31, 35, 40, 0.12);
}
.stl-panel[data-theme="light"] .stl-titlebar { border-color: #d8dee4; }
.stl-panel[data-theme="light"] .stl-tb-title,
.stl-panel[data-theme="light"] .stl-tb-eyebrow { color: #3b463d; }
.stl-panel[data-theme="light"] .stl-tb-eyebrow { color: #57606a; }
.stl-panel[data-theme="light"] .stl-tb-text { color: #1f2328; }
.stl-panel[data-theme="light"] .stl-collapse-btn {
background: rgba(208,215,222,0.4); border-color: #d0d7de; color: #57606a;
}
.stl-panel[data-theme="light"] .stl-collapse-btn:hover {
background: rgba(59,70,61,0.12); border-color: #3b463d; color: #3b463d;
}
.stl-panel[data-theme="light"] .stl-tabs { border-color: #d8dee4; }
.stl-panel[data-theme="light"] .stl-tab-chip {
background: #f6f8fa; border-color: #d0d7de;
}
.stl-panel[data-theme="light"] .stl-tab { color: #57606a; }
.stl-panel[data-theme="light"] .stl-tab:hover { color: #1f2328; }
.stl-panel[data-theme="light"] .stl-tab-chip.active {
background: #3b463d; border-color: #3b463d;
box-shadow: 0 0 0 2px rgba(59,70,61,0.18);
}
.stl-panel[data-theme="light"] .stl-tab-chip.active .stl-tab { color: #fff; }
.stl-panel[data-theme="light"] .stl-tab-info {
background: rgba(208,215,222,0.4); border-color: #d0d7de; color: #57606a;
}
.stl-panel[data-theme="light"] .stl-info-pop {
background: #f6f8fa; border-color: #d0d7de; color: #1f2328;
}
.stl-panel[data-theme="light"] .stl-col-hdr {
background: #f6f8fa; border-color: #d8dee4;
}
.stl-panel[data-theme="light"] .stl-hdr-name,
.stl-panel[data-theme="light"] .stl-hdr-num { color: #57606a; }
.stl-panel[data-theme="light"] .stl-hdr-num:hover { color: #3b463d; }
.stl-panel[data-theme="light"] .stl-hdr-num.active { color: #3b463d; }
.stl-panel[data-theme="light"] .stl-rows::-webkit-scrollbar-thumb { background: #d0d7de; }
.stl-panel[data-theme="light"] .stl-rows::-webkit-scrollbar-thumb:hover { background: #afb8c1; }
.stl-panel[data-theme="light"] .stl-empty,
.stl-panel[data-theme="light"] .stl-overflow {
color: #57606a; background: #f6f8fa; border-color: #d8dee4;
}
.stl-panel[data-theme="light"] .stl-overflow-hint { color: #3b463d; }
.stl-panel[data-theme="light"] .stl-row {
border-color: rgba(208,215,222,0.5);
}
.stl-panel[data-theme="light"] .stl-row:hover { background: rgba(59,70,61,0.04); }
.stl-panel[data-theme="light"] .stl-row.child .stl-name { color: #57606a; }
.stl-panel[data-theme="light"] .stl-rows.has-sel .stl-row.selected {
background: rgba(59,70,61,0.12); box-shadow: inset 3px 0 0 0 #3b463d, 0 0 12px rgba(59,70,61,0.08);
}
.stl-panel[data-theme="light"] .stl-name { color: #1f2328; }
.stl-panel[data-theme="light"] .stl-num { color: #57606a; }
.stl-panel[data-theme="light"] .stl-row.selected .stl-num { color: #1f2328; }
.stl-panel[data-theme="light"] .stl-caret { color: #57606a; }
.stl-panel[data-theme="light"] .stl-caret:hover { background: rgba(59,70,61,0.08); color: #3b463d; }
/* Light-mode reopen pill */
.stl-reopen[data-theme="light"] {
background: #ffffff; border-color: #d0d7de; color: #1f2328;
box-shadow: 0 4px 16px rgba(31,35,40,0.18);
}
.stl-reopen[data-theme="light"]:hover {
background: #f6f8fa; border-color: #3b463d; color: #3b463d;
box-shadow: 0 6px 20px rgba(59,70,61,0.2);
}
.stl-reopen[data-theme="light"] .stl-reopen-icon { color: #3b463d; }
.stl-reopen[data-theme="light"] .stl-reopen-caret { color: #57606a; }
</style>