doxa-maps/library/components/legends/SemanticTreeLegend.vue
Doxa Maps c74e191dbd
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source 835ec1ac
2026-09-26 22:25:05 +00:00

1377 lines
66 KiB
Vue

<script setup>
/**
* SemanticTreeLegend — reusable hierarchical legend for any one-to-many semantic tree.
*
* EXACT CLONE of the upstream PPLR module
* per user directive: "do a direct clone, only shave the API." Only difference vs.
* upstream is the <style scoped> block converted to useShadowStyles() so the CSS
* lands inside doxa-research-mfe's shadow root.
*
* 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[] }
*/
import { ref, computed, watch, nextTick, inject, unref } from 'vue'
import { tStatic } from '@map/i18n/index.js'
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in.
const T = (key, en) => tStatic(key) || en
import { useOptionalI18n } from '@map/composables/useOptionalT.js'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useInstance } from '../../composables/usePplrInstance.js'
import LegendProgressBar from './LegendProgressBar.vue'
import ApproxPinNote from './ApproxPinNote.vue'
// .stl-export-backdrop is an invisible click-catcher ("captures the next click"), so when it is
// clipped nothing LOOKS wrong — but inside the cap-5 shell contain:layout on .vtl-content limits
// it to the content pane (measured 1024x793 @ (256,107) at 1280x900), so a click on the rail or
// header fails to close the export menu.
// ★ NOT VERIFIED THROUGH THE UI, and honestly so: this backdrop sits behind the
// SHOW_EXPORT_BUTTON kill-switch (const SHOW_EXPORT_BUTTON = false below), so the menu that owns
// it cannot be opened in the shipped studio. The containment was measured at this component's own
// mount point (.stl-panel, same 256,107 origin as every other pane child) and the fix is the same
// one proven on .dt-scrim / .lt-overlay / .modal-scrim; it is correct-if-enabled, not observed.
import { useViewportScrim } from '../../utils/viewportScrim.js'
// ★ i18n IS OPTIONAL IN A LIBRARY COMPONENT. useI18n() THROWS on a host that never installed
// vue-i18n, and seven bundles here do not. Unguarded, this legend renders NOTHING on such a host —
// the failure that blanked people-groups-grid via library FilterDropdown (fixed in 6603dc17).
// Same shape of fix as PinHint.vue and PosterWall.vue.
// The fallback humanizes the key's last segment, so a bare host sees readable English
// ("legend.columns.name" -> "Name") rather than a raw key. `te` answers false so every caller
// falls through to its own default — which is what the defaultTab and columnLabel paths expect.
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
// C4: the guard is library/composables/useOptionalI18n now. humanizeKey stays the fallback -- NOT the
// catalogue -- so a bare host still reads 'Name' for legend.columns.name, and `te` still answers false
// so defaultTab and columnLabel keep falling through to their own defaults.
const { t, te } = useOptionalI18n('', { fallback: (k) => humanizeKey(k) })
// Translate a tab's display name from the `tabs.<key>` catalog, keyed by the
// tab's stable `i18nKey` (preferred) or `id`. Falls back to the hardcoded
// English `label` when no catalog entry exists — so untranslated/dynamic tabs
// (e.g. generated "Generation N") render unchanged. Active locale comes from
// profile-config.lang via ProfileLoader (loc-002 keystone).
function tabLabel(tab) {
const key = 'tabs.' + (tab?.i18nKey || tab?.id)
return tab && te(key) ? t(key) : (tab?.label ?? '')
}
// THE INFO TEXT IS THE PART A NON-ENGLISH READER NEEDS MOST, and it was the one part
// that never went through the catalog. `tab.info` rendered RAW in all three places it
// appears (the button's title=, and the popover body), so a Spanish reader saw a
// translated tab chip beside an English explanation of what that chip means. Measured
// 2026-09-24 on the served upg-100-list leaf: ?lang=es changed 2 of 76 rendered strings
// ("POPULATION" -> "POBLACIÓN") and left all three legend tabs AND all three info
// popovers in English — the locale switch works, the strings were simply never in a
// catalog. The agent named this affordance specifically ("the tab itself gives you a
// little information button that will explain what a UPG-100 list is"), which is exactly
// the sentence a reader who does not read English cannot use.
//
// Same shape as tabLabel: `legend.tabInfo.<i18nKey|id>`, te() guard, falling back to the
// hardcoded English `info` so a tab with no catalog entry renders unchanged rather than
// blank. PER KEY, so a half-finished translation shows its finished entries.
function tabInfo(tab) {
const key = 'legend.tabInfo.' + (tab?.i18nKey || tab?.id)
return tab && te(key) ? t(key) : (tab?.info ?? '')
}
const props = defineProps({
nodes: { type: Array, default: () => [] },
// APPROXPIN standing note (the agent card BUILD-APPROXIMATE-PIN-NOTICE: every map that shows people-group
// pins says plainly that a dot is a locality). null = decide from the map's own data: shown when the
// injected dataStore holds named Point features. true/false overrides, for a leaf that already says it.
pinNote: { type: Boolean, default: null },
tabs: { type: Array, default: null },
// WHICH GENERATION THE LEGEND OPENS ON. Index into `tabs`; 0 (the top of the
// tree) is the default, which is what every existing profile already gets.
// Added for country-posters, whose legend must open on the COUNTRY level rather
// than the region level above it:
// design note 2026-08-01: "when you open the map it doesn't open to the regions
// inside of the semantic tree legend, but it opens to the country's semantic
// level in the legend."
// A profile cannot do this from outside without it: `expandedIds` is internal
// state that resets on every `nodes` change, and activeTab is a computed over
// either the shared instance or local state. Clamped to the real tab count below,
// so an out-of-range value degrades to the top generation rather than blanking.
defaultTab: { type: Number, default: 0 },
columns: { type: Array, default: () => ['count', 'pop'] },
title: { type: String, default: 'Legend' },
hideTabs: { type: Boolean, default: false },
columnLabel: { type: String, default: '' },
open: { type: Boolean, default: null },
// Per-app-profile feature flag. Off by default so existing profiles are
// unchanged; a profile opts in by passing :export-enabled. Gates the
// top-right Export affordance (UX scaffold only — no export yet).
exportEnabled: { type: Boolean, default: false },
// Prayer progress payload for the header bar — { covered, total, segments } from
// useLegendData(...).progress. null → no bar (every non-prayer legend). Falls back
// to inject('legendProgress') so the mobile sheet's embedded legend (LegendMobile →
// this component) gets the bar from the profile's provide() without a prop.
progress: { type: Object, default: null },
})
const emit = defineEmits(['select', 'update:open', 'export'])
const instance = useInstance()
// ── Prayer progress bar (header) ───────────────────────────────────────────────
// Explicit prop wins, else the provided payload (a ref/computed from the profile).
const injectedProgress = inject('legendProgress', null)
const progressData = computed(() => props.progress ?? unref(injectedProgress) ?? null)
// the agent 2026-09-16: the prayer legend's header says "Progress" (never "Legend") —
// on the desktop title bar, the mobile sheet title and the collapsed reopen pill.
const displayTitle = computed(() => progressData.value ? t('legend.progress.title') : props.title)
const stlIsDark = computed(() => (instance?.theme.value || 'dark') === 'dark')
const pinDataStore = inject('dataStore', null)
const showPinNote = computed(() => {
if (props.pinNote !== null) return props.pinNote
const sources = pinDataStore?.sources || {}
for (const id in sources) {
const f = sources[id]?.features?.[0]
const p = f?.properties || {}
if (f?.geometry?.type === 'Point' && (p.name || p.peopleName || p.PeopNameInCountry)) return true
}
return false
})
// A click on a bar span selects the matching legend row through the SAME handler
// the row uses (selectNode → emit('select') → the profile's filter/camera path).
function findNodeById(nodes, id) {
for (const n of nodes || []) {
if (n?.id === id) return n
const f = n?.children?.length ? findNodeById(n.children, id) : null
if (f) return f
}
return null
}
function selectSegment(key) {
const node = findNodeById(safeNodes.value, key)
if (node) selectNode(node)
}
// ── 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 ────────────────────────────────────────────────────────────────────
// NULL-SAFE TREE (research-map tab crash, 2026-09-16): ONE malformed API record
// producing a null node used to kill the whole legend — and with it the map —
// on the People Groups / Regions / Languages / Religions tabs (TypeError:
// reading 'label' of null). Sanitize ONCE at the prop boundary: drop falsy
// nodes recursively and warn, so a data hole degrades to a missing row instead
// of a dead map.
function pruneNullNodes(nodes, path = 'root') {
if (!Array.isArray(nodes)) return []
const out = []
for (const n of nodes) {
if (!n || typeof n !== 'object') {
try { console.warn('[SemanticTreeLegend] dropped null node under', path) } catch (_) {}
continue
}
out.push(n.children?.length ? { ...n, children: pruneNullNodes(n.children, n.label || n.id || path) } : n)
}
return out
}
const safeNodes = computed(() => pruneNullNodes(props.nodes))
const treeDepth = computed(() => treeMaxDepth(safeNodes.value))
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 ─────────────────────────────────────────────────────────────────────
// Seeded from defaultTab so a profile can open on a deeper generation. Default 0
// keeps every existing profile exactly as it was.
const localActiveTab = ref(Math.max(0, props.defaultTab | 0))
const expandedIds = ref(new Set())
const localSelected = ref(null)
const panelOpen = ref(props.open !== null ? props.open : true)
const showInfoForTab = ref(null)
const exportMenuOpen = ref(false) // top-right Export dropdown (behind SHOW_EXPORT_BUTTON)
const { scrimRef, scrimStyle } = useViewportScrim(exportMenuOpen)
// Feature flag: show the Export affordance. Gated here in the SHARED legend so
// one switch covers every bundle. It was false by an earlier coder decision;
// design note 2026-07-31 asked for the button back on the 100-list legend, and the
// export path is functional (the menu emits 'export' with 'poster' | 'packet',
// and a profile wires @export to a real download).
// A profile STILL has to opt in with :export-enabled="true" — this flag only
// stops it being force-hidden.
//
// ON since 2026-09-24. It was held OFF with the note "until the export packet is
// finished: the button currently produces a broken packet" — that precondition is
// now met. The packet renderer (library/packet/packet-render.js) is shared by the
// build-time generator and the browser export, so both produce the same document,
// and it is gated at the rendered layer by check:packet-render (8 sheets -> 8
// letter-portrait pages, map painted, Mark 5 in full, 12 wheel segments).
// The packet is generated ON CLICK, never shipped: one is ~3.5 MB and 28 zones
// would add ~100 MB to a repo that already had a publish blocked at 74 MB.
const SHOW_EXPORT_BUTTON = true
const sortBy = ref(null) // null = default (pop desc), 'count' | 'pop'
const sortDir = ref('desc') // 'desc' | 'asc'
// ⚠ THE STORE USED TO WIN UNCONDITIONALLY, which made `defaultTab` dead for every profile
// that shares an instance. instance.activeTab starts at 0 and 0 is indistinguishable from
// "the user chose tab 0", so a profile passing :default-tab="1" still opened on tab 0.
// MEASURED 2026-09-24: my-upg-100-list opened on Region at desktop AND mobile, which is the
// the agent's first complaint about this legend word for word.
// Now the store only wins once something has ACTUALLY chosen a tab — a tab click, or a node
// selection that moves the legend to that node's generation (usePplrInstance sets the flag).
// Until then the profile's declared landing tab applies, which is what the prop always meant.
const activeTab = computed({
get: () => {
if (props.hideTabs) return 0
if (instance && !instance.activeTabTouched?.value) {
return Math.min(Math.max(0, props.defaultTab | 0), Math.max(0, tabList.value.length - 1))
}
return instance ? instance.activeTab.value : localActiveTab.value
},
set: v => {
if (props.hideTabs) return
if (instance) {
instance.activeTab.value = v
if (instance.activeTabTouched) instance.activeTabTouched.value = true
} 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)
// Scroll container for the legend rows — used to bring a freshly-selected row
// (e.g. from a People-Group search result) into view without the user scrolling.
const rowsContainer = ref(null)
watch(() => props.open, (v) => {
if (v !== null) panelOpen.value = v
})
watch(() => safeNodes.value, () => {
// Falls back to defaultTab, not a hardcoded 0 — otherwise a profile that opens on
// a deeper generation silently reverts to the top the first time its nodes change.
// ⚠ AND THE STORE ONLY COUNTS ONCE IT HAS BEEN TOUCHED. This read `instance?.activeTab.value`
// and kept it whenever it was `a number >= 0` — but the store's INITIAL value is 0, which
// satisfies that test, so defaultTab lost here too. Same root cause as the getter above.
const touched = instance ? instance.activeTabTouched?.value : false
const ext = touched ? instance?.activeTab.value : undefined
const base = Math.min(Math.max(0, props.defaultTab | 0), Math.max(0, tabList.value.length - 1))
const next = (typeof ext === 'number' && ext >= 0) ? ext : base
// ⚠ WRITE PAST THE SETTER. This ran `activeTab.value = next`, and the setter marks the store
// TOUCHED — but a nodes-change reset is not a user choice, so that flipped the sentinel on
// mount and made defaultTab lose again, one layer down. Measured: the fix appeared to do
// nothing until this line changed. Set the store directly and leave `touched` alone.
if (instance) {
instance.activeTab.value = next
if (!touched && instance.activeTabTouched) instance.activeTabTouched.value = false
} else {
localActiveTab.value = next
}
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(safeNodes.value, sel.id) || []
if (typeof sel.depth === 'number') {
activeTab.value = Math.min(sel.depth, tabList.value.length - 1)
}
// Expand ALL ancestors of the selection (not just from the active tab down)
// so a selected row in a collapsed sub-branch actually enters visibleRows —
// otherwise the scroll-to-selected watcher can't find its DOM row.
const newExp = new Set()
chain.forEach(p => newExp.add(p.id))
expandedIds.value = newExp
emit('select', sel)
})
}
// ── 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) {
if (sortDir.value === 'desc') sortDir.value = 'asc'
else { sortBy.value = null; sortDir.value = 'desc' }
} else {
sortBy.value = key
sortDir.value = 'desc'
}
}
// ── Visible rows ───────────────────────────────────────────────────────────────
const tabResults = ref([])
const tabLoading = ref(false)
let _pendingTab = -1
function ensureTabComputed(tab) {
if (tabResults.value[tab]) { tabLoading.value = false; return }
tabLoading.value = true
_pendingTab = tab
setTimeout(() => {
if (_pendingTab !== tab) return
const result = [...collectAtDepth(safeNodes.value, 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(() => [safeNodes.value, sortBy.value, sortDir.value], () => {
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) {
if (!node) continue
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, [])
for (let i = 0; i < out.length; i++) {
const row = out[i]
const segs = []
for (let L = 0; L < row.relDepth; L++) {
const ancAtL = row.ancestors[L]
let trunkContinues = false
for (let j = i + 1; j < out.length; j++) {
const next = out[j]
if (next.relDepth <= L) break
if (next.relDepth === L + 1 && next.ancestors[L] === ancAtL) {
trunkContinues = true; break
}
if (next.relDepth > L + 1 && next.ancestors[L] === ancAtL) {
continue
}
}
if (L === row.relDepth - 1) {
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'))
const selectedDescendants = computed(() => {
if (!selectedNode.value) return new Set()
return collectAllDescendantIds(selectedNode.value)
})
const parentBreadcrumb = computed(() => {
const sel = selectedNode.value
if (!sel) return null
const chain = findAncestorChain(safeNodes.value, 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, depth: parentDepth }
})
// ── 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)
}
}
function toggleExpand(id, e) {
e?.stopPropagation()
const s = new Set(expandedIds.value)
const collapsing = s.has(id)
collapsing ? s.delete(id) : s.add(id)
expandedIds.value = s
if (collapsing && selectedNode.value) {
const chain = findAncestorChain(safeNodes.value, selectedNode.value.id)
if (chain && chain.some(a => a.id === id)) {
_lastInternalId = null
selectedNode.value = null
emit('select', null)
}
}
}
// Drill UP the tree: re-root the legend on the breadcrumb parent shown in the
// header (e.g. drilled into "Telugu" on the Language tab → click "Dravidian" →
// jump to the Lang Family tab with Dravidian selected and the map filtered to
// the whole family).
//
// The active-tab switch MUST happen here. The internal selection watcher that
// adjusts the tab from sel.depth is guarded out for internally-originated
// selections (sel.id === _lastInternalId), and the external mapStore→selection
// bridge also skips re-applying a same-kind+label selection — so neither path
// moves the tab. We switch it explicitly to the parent's generation depth.
// Works for any tree tab (language-family / WAGF-regions / affinity) since it
// reuses the same parentBreadcrumb + selectNode mechanism.
function upToParent() {
const bc = parentBreadcrumb.value
if (!bc?.node) return
// Switch to the parent's generation tab first so the parent row is visible
// and highlighted on its own tab (selectNode highlights by id, and the
// parent only appears in that tab's rows). Clear any child-tab expansion so
// the parent tab opens clean — mirrors the geocoder-reveal flow's behavior.
if (!props.hideTabs && typeof bc.depth === 'number') {
activeTab.value = bc.depth
}
expandedIds.value = new Set()
// selectNode toggles off if the same id is already selected; the breadcrumb
// parent is never the current selection, so this always selects the parent
// and emits @select, which drives the map filter to the parent's scope.
selectNode(bc.node)
}
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
}
// Export affordance — the menu options emit('export', kind) to the profile.
function toggleExportMenu(e) {
e?.stopPropagation()
exportMenuOpen.value = !exportMenuOpen.value
}
// Fire the chosen export kind ('poster' | 'packet') up to the profile, then
// close the menu. The profile renders + downloads the PDF.
function emitExport(kind) {
exportMenuOpen.value = false
emit('export', kind)
}
// The Export button only exists while a row is selected; if the selection is
// cleared (deselect / tab switch / nodes change) the menu must not linger.
watch(selectedId, id => { if (!id) exportMenuOpen.value = false })
// Bring the selected row into view when the selection changes (e.g. a People-Group
// search result highlights a row that may be scrolled off-screen).
//
// Scroll EVERY scrollable ancestor of the row (mobile nests .stl-rows inside the
// sheet's .legend-content, so one scroller isn't enough) so the row lands ~8px below
// the top of whatever actually scrolls. We use getBoundingClientRect deltas, NOT
// row.offsetTop: offsetTop is relative to the nearest *positioned* ancestor — .stl-rows
// is position:static, so offsetTop included the header height above it and the scroll
// overshot by ~3 rows (the exact bug). Walking parentElement stays INSIDE the shadow
// DOM (its top element's parentElement is null at the shadow root), so this never
// scrolls the host page the way row.scrollIntoView() did (regression 554bf10).
function bringRowToTop(row) {
let n = row.parentElement
while (n) {
const oy = getComputedStyle(n).overflowY
if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight + 1) {
const rr = row.getBoundingClientRect(), nr = n.getBoundingClientRect()
n.scrollTop += (rr.top - nr.top) - 8
}
n = n.parentElement
}
}
let stlScrollGen = 0
watch(selectedId, async id => {
if (!id) return
const gen = ++stlScrollGen // cancel earlier runs if the selection changes again
const container = rowsContainer.value
if (!container) return
await nextTick()
// Re-run ACROSS the mobile sheet's open animation (~300ms). On mobile the legend is
// collapsed by default; the FIRST search after a page load opens it (a height
// transition) AND fires this watcher — so an early measurement is against the
// collapsed/animating container and the row lands wrong (the "first search after
// reload, mobile only" bug). Re-running as it settles corrects it; when the legend is
// already open these passes are no-ops (the row is already at the top).
const tryScroll = (attempt = 0) => {
if (gen !== stlScrollGen) return // superseded by a newer selection
const row = container.querySelector(`[data-stl-node-id="${CSS.escape(String(id))}"]`)
if (row) bringRowToTop(row)
if (attempt < 6) setTimeout(() => tryScroll(attempt + 1), 70)
}
tryScroll(0)
})
// ── 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() : '—' }
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,
}
}
// CSS — verbatim from PPLR's <style scoped>, injected into shadow root.
useShadowStyles(`
.stl-inner {
--stl-pad-x: 10px;
--stl-gap: 6px;
--stl-caret-w: 14px;
--stl-dot-w: 8px;
--stl-num-w: 60px; /* fits "POPULATION" (~59px) at the 9px header scale; desktop overrides below */
--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: var(--map-radius, 16px);
box-shadow: 0 6px 24px rgba(0,0,0,0.45);
overflow: hidden;
}
.stl-panel {
/* --stl-top: the panel's offset from the map's top edge. The 60px default reserves room for a
search bar ABOVE the legend, which most consumers (research-map's geocoder) still have. A
profile that has REMOVED its search bar sets --stl-top on the legend's wrapper so the panel
rises into the vacated space instead of keeping a dead gap (the agent, 2026-09-23, UPG-100:
"the search bar is removed, that means the legend needs to be moved up"). The max-height
mirrors it so a raised panel gains the same room at the bottom it gained at the top. */
position: absolute; inset-inline-start: 8px; top: var(--stl-top, 60px); width: var(--map-legend-width, 380px); z-index: 4;
display: flex; flex-direction: column;
max-height: calc(100% - var(--stl-top, 60px) - 12px);
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; }
/* RTL: the panel sits at the inline START (the right), so it slides off to the right and its collapse
arrow points the other way. Additive :dir(rtl) overrides, as in VerticalTabsLayout. */
.stl-panel.closed:dir(rtl) { transform: translateX(100%); }
.stl-collapse-btn:dir(rtl) svg { transform: rotate(0deg); }
/* Derived from --stl-top for the same reason as the base rule above: a fixed 72px here overflowed
whenever the profile's real offset was larger (UPG-100 sets 58px + a 12px bottom margin = 70px of
required clearance, against 72px only by luck; at --stl-top:8px it wasted 64px of panel height). */
.stl-panel.fit-content { max-height: calc(100% - var(--stl-top, 60px) - 12px); }
.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-caret-slot { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.stl-tb-caret-slot:empty { display: none; }
/* Prayer progress bar IN the header: the titlebar wraps to a second line that the
bar fills edge to edge (line 1: caret + "Progress" [+ export]; line 2: the bar). */
.stl-titlebar.has-progress { flex-wrap: wrap; row-gap: 7px; }
.stl-progress { flex: 1 1 100%; min-width: 0; }
.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;
}
/* Clickable header breadcrumb — drill UP the tree onto the parent. Styled to
read as part of the titlebar (transparent, inherits header layout) but with
hover/focus affordances that mark it interactive. Accent uses the legend's
green family (#73A17F); no brown tones. */
.stl-tb-up {
flex: 1; min-width: 0;
display: inline-flex; align-items: center; gap: 6px;
background: transparent; border: 1px solid transparent; border-radius: 6px;
margin: -2px -4px; padding: 2px 6px 2px 4px;
cursor: pointer; text-align: left;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.stl-tb-up:hover { background: rgba(115,161,127,0.10); border-color: rgba(115,161,127,0.35); }
.stl-tb-up:focus-visible {
outline: none;
border-color: #73A17F;
box-shadow: 0 0 0 2px rgba(115,161,127,0.30);
}
.stl-tb-up .stl-tb-text {
flex: 1; min-width: 0;
font: 600 12px system-ui; color: #e6edf3;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.stl-tb-up:hover .stl-tb-text { color: #fff; }
.stl-tb-up-icon { color: #73A17F; flex-shrink: 0; }
.stl-panel[data-theme="light"] .stl-tb-up:hover {
background: rgba(59,70,61,0.08); border-color: rgba(59,70,61,0.3);
}
.stl-panel[data-theme="light"] .stl-tb-up:focus-visible {
border-color: #3b463d; box-shadow: 0 0 0 2px rgba(59,70,61,0.25);
}
.stl-panel[data-theme="light"] .stl-tb-up .stl-tb-text { color: #1f2328; }
.stl-panel[data-theme="light"] .stl-tb-up-icon { color: #3b463d; }
.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;
}
.stl-col-hdr,
.stl-row {
display: flex; align-items: center; gap: var(--stl-gap);
padding: 0 var(--stl-pad-x);
}
.stl-col-hdr {
height: 26px; flex-shrink: 0;
background: #0d1117; border-bottom: 1px solid #21262d;
}
.stl-hdr-name {
flex: 1; min-width: 0;
font: 700 11px ui-monospace, monospace;
color: #8b949e; text-transform: uppercase; letter-spacing: 0.04em;
}
/* Center-aligned UUPGs/Pop column header (qa: 2026-05-02 — user explicitly
requested center over right-align so the header label sits on the same
x-center as the numeric value below; reduces eye drift across rows). */
.stl-hdr-num {
width: var(--stl-num-w); text-align: center; 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: center; gap: 3px;
}
.stl-hdr-num:hover { color: #c9d1d9; }
/* WCAG 2.5.8 at EVERY pointer, not only coarse: a 24x24 hit area that adds no box (the f29b8151 pattern). */
.stl-hdr-num{position:relative;}.stl-hdr-num::after{content:'';position:absolute;left:50%;top:50%;width:24px;height:24px;transform:translate(-50%,-50%);}
/* WCAG 2.5.8 Target Size (Minimum), AA = 24x24 CSS px. Scoped to (pointer:coarse):
2.5.8 governs touch ergonomics, and these are SHARED components sitting on maps this
change does not own -- a finger gets the bigger target, the mouse keeps the density
those owners designed. min-height states the 24 rather than deriving it from a font. */
/* min-height ONLY. The width is var(--stl-num-w), a layout variable the header's columns are
aligned on; forcing a min-width here would push those columns apart to fix a height. */
@media (pointer:coarse) { .stl-hdr-num { min-height: 24px; } }
.stl-hdr-num.active { color: #73A17F; }
.stl-sort-arrow { font-size: 8px; }
.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; }
.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; }
.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;
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));
}
.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;
}
.stl-trunk { position: relative; flex-shrink: 0; width: 12px; align-self: stretch; }
.stl-trunk-pipe::before {
content: ''; position: absolute;
inset-inline-start: 5px; top: 0; bottom: 0; width: 1px;
background: var(--trunk-color, #6e7681);
opacity: 0.45;
}
.stl-trunk-tee::before {
content: ''; position: absolute;
inset-inline-start: 5px; top: 0; bottom: 0; width: 1px;
background: var(--trunk-color, #6e7681);
opacity: 0.45;
}
.stl-trunk-tee::after {
content: ''; position: absolute;
inset-inline-start: 5px; top: 50%; height: 1px; width: 7px;
background: var(--trunk-color, #6e7681);
opacity: 0.55;
}
.stl-trunk-elbow::before {
content: ''; position: absolute;
inset-inline-start: 5px; top: 0; height: 50%; width: 1px;
background: var(--trunk-color, #6e7681);
opacity: 0.45;
}
.stl-trunk-elbow::after {
content: ''; position: absolute;
inset-inline-start: 5px; top: calc(50% - 0.5px); height: 1px; width: 7px;
background: var(--trunk-color, #6e7681);
opacity: 0.55;
}
.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;
}
.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); }
.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);
}
.stl-name {
flex: 1; min-width: 0;
font: 11.5px system-ui, sans-serif; color: #e6edf3;
white-space: normal; word-wrap: break-word; overflow-wrap: break-word;
line-height: 1.3;
}
/* Center-aligned numeric data — paired with center-aligned header above. */
.stl-num {
width: var(--stl-num-w); text-align: center; flex-shrink: 0;
font: 10.5px ui-monospace, monospace; color: #8b949e;
font-variant-numeric: tabular-nums;
}
.stl-row.selected .stl-num { color: #e6edf3; }
.stl-x-chip {
background: rgba(110,118,129,0.12); border: none; border-radius: 50%;
color: #6e7681; cursor: pointer;
width: 16px; height: 16px;
display: flex; align-items: center; justify-content: center;
transition: color 0.12s, background 0.12s;
}
.stl-x-chip:hover { color: #c9d1d9; background: rgba(110,118,129,0.25); }
.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); }
.stl-reopen {
position: absolute; inset-inline-start: 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;
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) {
/* ⚠ TWO BUGS LIVED IN THIS ONE LINE, and both are the same mistake: a number that has to agree
with another number, written out twice.
1. THE CAP MEASURED THE WRONG BOX. calc(100vh - 72px) sizes the panel against the VIEWPORT,
while the panel is positioned inside the MAP (position:absolute within .upg-map{inset:0}).
Those agree only when the map fills the screen — true of every standalone leaf, false the
moment a host puts a header above the map or content below it, which is how the element is
actually embedded. 100% is the map's own box, the same reference the desktop rule uses.
2. THE OFFSET AND THE CAP DISAGREED. top:64px with max-height:calc(... - 72px) subtracts a
constant that has nothing to do with the offset above it. --stl-top makes one value drive
both, so they cannot drift apart again.
MEASURED 2026-09-24 at 390px in a 420px-tall host: the panel ran 38px past the bottom of the
map. the agent: "the upg 100 list legend on the map is overflowing beyond the height of the map." */
.stl-panel { width: 92vw; max-width: 380px; --stl-top: 64px; }
.stl-reopen { top: 64px; }
}
.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-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; }
.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; }
.stl-panel[data-theme="light"] .stl-x-chip { color: #57606a; border-color: rgba(208,215,222,0.6); }
.stl-panel[data-theme="light"] .stl-x-chip:hover { color: #1f2328; background: rgba(208,215,222,0.3); border-color: #afb8c1; }
/* ── Export affordance (top-right of titlebar) ──────────────────────────────
margin-left:auto pins it to the right edge whether the titlebar shows the
plain title (flex-shrink:0) or the flex:1 breadcrumb button. */
.stl-export { margin-inline-start: auto; position: relative; flex-shrink: 0; }
.stl-export-btn {
display: inline-flex; align-items: center; gap: 5px;
background: rgba(110,118,129,0.15); border: 1px solid #30363d; border-radius: 6px;
color: #c9d1d9; cursor: pointer;
height: 22px; padding: 0 8px;
font: 600 10px ui-monospace, monospace; text-transform: uppercase; letter-spacing: 0.05em;
transition: color 0.12s, background 0.12s, border-color 0.12s;
}
.stl-export-btn:hover { color: #fff; background: rgba(59,70,61,0.25); border-color: #73A17F; }
.stl-export-btn.open { color: #fff; background: #3b463d; border-color: #73A17F; box-shadow: 0 0 0 2px rgba(115,161,127,0.22); }
.stl-export-btn:focus-visible { outline: none; border-color: #73A17F; box-shadow: 0 0 0 2px rgba(115,161,127,0.3); }
.stl-export-label { line-height: 1; }
/* Backdrop sits below the menu, above the panel — captures the next click. */
.stl-export-backdrop { position: fixed; inset: 0; z-index: 9; }
.stl-export-menu {
position: absolute; inset-inline-end: 0; top: calc(100% + 6px); z-index: 10;
min-width: 168px;
display: flex; flex-direction: column;
background: #0d1117; border: 1px solid #30363d; border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
padding: 4px; overflow: hidden;
}
.stl-export-item {
display: flex; align-items: center; gap: 8px;
background: transparent; border: none; border-radius: 5px;
padding: 7px 8px; width: 100%; text-align: left;
color: #6e7681; cursor: not-allowed;
font: 600 11px system-ui, sans-serif;
}
.stl-export-item[disabled] { opacity: 0.55; }
.stl-export-lock { color: #6e7681; flex-shrink: 0; }
.stl-export-item-label { flex: 1; min-width: 0; }
.stl-export-soon {
flex-shrink: 0;
font: 700 8px ui-monospace, monospace; text-transform: uppercase; letter-spacing: 0.06em;
color: #6e7681; background: rgba(110,118,129,0.15);
border-radius: 4px; padding: 2px 5px;
}
/* Light-theme overrides */
.stl-panel[data-theme="light"] .stl-export-btn {
background: rgba(208,215,222,0.4); border-color: #d0d7de; color: #57606a;
}
.stl-panel[data-theme="light"] .stl-export-btn:hover { color: #3b463d; background: rgba(59,70,61,0.12); border-color: #3b463d; }
.stl-panel[data-theme="light"] .stl-export-btn.open { color: #fff; background: #3b463d; border-color: #3b463d; }
.stl-panel[data-theme="light"] .stl-export-menu { background: #ffffff; border-color: #d0d7de; box-shadow: 0 8px 24px rgba(31,35,40,0.18); }
.stl-panel[data-theme="light"] .stl-export-item { color: #57606a; }
.stl-panel[data-theme="light"] .stl-export-lock { color: #57606a; }
.stl-panel[data-theme="light"] .stl-export-soon { color: #57606a; background: rgba(208,215,222,0.5); }
/* ── DESKTOP TYPE SCALE (the agent, 2026-09-16: "the desktop font sizes in the legends need to
be increased … larger data to be more readable on desktop"). Desktop panel only — the
mobile sheet (.legend-mobile-sheet) keeps its own compact sizes. Size only, never layout;
the base rules above stay as the mobile/default scale. ── */
.stl-panel:not(.legend-mobile-sheet *) .stl-tb-title,
.stl-panel:not(.legend-mobile-sheet *) .stl-tb-text { font-size: 14px; }
.stl-panel:not(.legend-mobile-sheet *) .stl-tb-eyebrow { font-size: 11px; }
.stl-panel:not(.legend-mobile-sheet *) .stl-tab { font-size: 12px; }
.stl-panel:not(.legend-mobile-sheet *) .stl-hdr-name { font-size: 13px; }
.stl-panel:not(.legend-mobile-sheet *) .stl-hdr-num { font-size: 10px; }
.stl-panel:not(.legend-mobile-sheet *) .stl-name { font-size: 15px; }
.stl-panel:not(.legend-mobile-sheet *) .stl-num { font-size: 13px; }
/* The numeric columns must grow with the type: "POPULATION" at 10px is ~65px, ~76px with
its sort arrow; "139.0M" at 13.5px is ~49px. A fixed 44px column let the header collide
with "UUPGs" and let values spill into the neighbouring column (the agent, 2026-09-16). */
.stl-panel:not(.legend-mobile-sheet *) .stl-inner { --stl-num-w: 70px; } /* the vars live on .stl-inner */
.stl-panel:not(.legend-mobile-sheet *) .stl-hdr-num,
.stl-panel:not(.legend-mobile-sheet *) .stl-num { white-space: nowrap; }
.stl-panel:not(.legend-mobile-sheet *) .stl-info-pop { font-size: 13px; }
.stl-panel:not(.legend-mobile-sheet *) .stl-empty,
.stl-panel:not(.legend-mobile-sheet *) .stl-overflow { font-size: 12.5px; }
`, 'semantic-tree-legend')
</script>
<template>
<div class="stl-panel" :class="{ closed: !panelOpen, 'fit-content': hideTabs }" :data-theme="instance?.theme.value || 'dark'">
<div class="stl-inner">
<div class="stl-titlebar" :class="{ 'has-progress': !!progressData }">
<!-- #title-caret slot — parent (e.g., LegendMobile) injects its own
caret here so it's vertically aligned with the title text via
flex align-items:center. Falls through to PPLR's default
.stl-collapse-btn when no slot is provided (desktop). -->
<span class="stl-tb-caret-slot"><slot name="title-caret" /></span>
<!--
★ aria-label MIRRORS title, and aria-expanded reports the STATE (2026-09-21).
This button's only content is an <svg> caret, so with `title` alone its accessible name
was EMPTY — a screen reader announced "button". title is a TOOLTIP, not an accessible
name: unreliably announced and never surfaced to touch users.
Caught by check:a11y-button-names, which measures the RENDERED DOM: a source grep cannot
answer this (132 `<button>` hits here, most with visible text, which IS a valid name).
aria-expanded matters because this is a DISCLOSURE control — without it AT announces the
action but not whether the panel is currently open.
-->
<button
v-if="!$slots['title-caret']"
class="stl-collapse-btn"
:class="{ closed: !panelOpen }"
:title="panelOpen ? t('aria.collapseLegend') : t('aria.expandLegend')"
:aria-label="panelOpen ? t('aria.collapseLegend') : t('aria.expandLegend')"
:aria-expanded="String(panelOpen)"
@click="panelOpen = !panelOpen; emit('update:open', 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>
<!-- Clickable breadcrumb: drills UP the tree onto the parent
family/group. Keyboard-focusable button with aria-label; Enter/
Space activate natively. -->
<button type="button" class="stl-tb-up"
:title="`Go up to ${parentBreadcrumb.node.label}`"
:aria-label="`Go up to ${parentBreadcrumb.tabName} ${parentBreadcrumb.node.label}`"
@click.stop="upToParent">
<svg class="stl-tb-up-icon" width="9" height="9" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M3 7.5L6 4.5L9 7.5" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<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>
</button>
</template>
<template v-else>
<span class="stl-tb-title">{{ displayTitle }}</span>
</template>
<!-- Export affordance — top-right of the titlebar. Gated by the shared
SHOW_EXPORT_BUTTON kill-switch (currently false), the per-app-profile
feature flag AND a current row selection. The path is functional:
the menu items emit('export', kind) and profiles wire @export to a
working Poster/Packet PDF download — flip the kill-switch to ship it. -->
<div v-if="SHOW_EXPORT_BUTTON && exportEnabled && selectedNode" class="stl-export">
<button type="button" class="stl-export-btn"
:class="{ open: exportMenuOpen }"
:aria-expanded="exportMenuOpen ? 'true' : 'false'"
aria-haspopup="menu"
:title="t('legend.exportSelection')"
:aria-label="t('legend.exportSelection')"
@click.stop="toggleExportMenu">
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M7 1.5V9M7 1.5L4.2 4.3M7 1.5L9.8 4.3" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 9.5V11.5C2 12.05 2.45 12.5 3 12.5H11C11.55 12.5 12 12.05 12 11.5V9.5"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span class="stl-export-label">{{ t('legend.exportButton') }}</span>
</button>
<template v-if="exportMenuOpen">
<!-- Click-away backdrop closes the menu without a global listener. -->
<div ref="scrimRef" :style="scrimStyle" class="stl-export-backdrop" @click.stop="exportMenuOpen = false"></div>
<div class="stl-export-menu" role="menu">
<button type="button" class="stl-export-item" role="menuitem" @click.stop="emitExport('poster')">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<rect x="2" y="1.5" width="8" height="9" rx="1" stroke="currentColor" stroke-width="1.3"/>
<path d="M4 4.5H8M4 6.5H8M4 8.5H6.5" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
</svg>
<span class="stl-export-item-label">{{ t('legend.export.poster') }}</span>
</button>
<button type="button" class="stl-export-item" role="menuitem" @click.stop="emitExport('packet')">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M3 1.5H7.5L9.5 3.5V10.5H3V1.5Z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M7.5 1.5V3.5H9.5" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M4.5 6H8M4.5 8H7" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
</svg>
<span class="stl-export-item-label">{{ t('legend.export.packet') }}</span>
</button>
</div>
</template>
</div>
<!-- Prayer progress bar — header line 2 (prayer legend only), bar only: the counts
live in the rows below. Hover / focus a span for its popover; click selects
the matching row (selectSegment). -->
<LegendProgressBar
v-if="progressData"
class="stl-progress"
:show-text="false"
:covered="progressData.covered"
:total="progressData.total"
:segments="progressData.segments"
:is-dark="stlIsDark"
:fmt-count="fmtCount"
@select-segment="selectSegment"
/>
</div>
<div v-if="!hideTabs" 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)">{{ tabLabel(tab) }}</button>
<button v-if="tab.info" class="stl-tab-info" :title="tabInfo(tab)"
@click="showInfo(idx, $event)">i</button>
</div>
</div>
<div v-if="showInfoForTab !== null && tabList[showInfoForTab]?.info" class="stl-info-pop">
{{ tabInfo(tabList[showInfoForTab]) }}
</div>
</div>
<div class="stl-col-hdr">
<span class="stl-caret-sp"></span>
<span class="stl-dot-sp"></span>
<span class="stl-hdr-name">{{ columnLabel || (hideTabs ? title : (tabLabel(tabList[activeTab]) || t('legend.columns.name'))) }}</span>
<button v-if="hasCount" class="stl-hdr-num"
:class="{ active: sortBy === 'count' }"
:title="t('legend.sortByUpgs')"
@click="clickSortHeader('count')">
{{ t('legend.columns.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="t('legend.sortByPop')"
@click="clickSortHeader('pop')">
{{ t('legend.columns.population') }}
<span v-if="sortBy === 'pop'" class="stl-sort-arrow">{{ sortDir === 'desc' ? '▼' : '▲' }}</span>
</button>
<span class="stl-x-slot"></span>
</div>
<div class="stl-rows" :class="{ 'has-sel': hasSel }" ref="rowsContainer">
<div v-for="row in visibleRows" :key="row.node.id"
class="stl-row" :class="rowClasses(row)"
:data-stl-node-id="row.node.id"
:style="{
'--trunk-color': (row.parent?.color || '#6e7681'),
'--row-color': (row.node.color || '#73A17F'),
}"
@click="selectNode(row.node)">
<span v-for="(seg, i) in row.trunks" :key="`tr${i}`"
class="stl-trunk" :class="`stl-trunk-${seg}`"></span>
<!-- ★ AN ICON-ONLY DISCLOSURE NEEDS A NAME AND A STATE. This button held a 9x9 <svg> and
nothing else — no text, no aria-label, not even a title — so assistive technology
announced "button" seven times per legend with no indication of what any of them
did. check:a11y-button-names found 14 visible nameless buttons across two leaves and
these are all of them: country-posters-mfe COMPOSES countries-map, so it is the same
seven counted twice. Naming them here fixes every bundle that renders this legend,
which is the point of it living in library/.
aria-expanded comes with it, because a name alone tells a screen-reader user what
the control is for and still not whether the branch is open. -->
<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="t(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">
<!-- The same defect, latent: this one carries a `title`, and a title is a TOOLTIP —
unreliably announced and never shown to touch users — so its accessible name was
also empty. It escaped the gate only because it renders while a row is SELECTED
and the sweep measured an unselected legend. Hardcoded English besides, on a
component whose every other string is translated. -->
<button v-if="selectedId === row.node.id"
class="stl-x-chip"
:title="t('legend.a11y.clearSelection')"
:aria-label="t('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>{{ T('legend.loadingRows', 'Loading {label}…').replace('{label}', tabList[activeTab]?.label || 'rows') }}</span>
</div>
<div v-else-if="!visibleRows.length && nodes.length === 0" class="stl-empty">{{ T('legend.loadingData', 'Loading data…') }}</div>
<div v-else-if="!visibleRows.length" class="stl-empty">{{ t('legend.noDataAtThisLevel') }}</div>
<div v-if="topNodesOverflow > 0" class="stl-overflow">
{{ T('legend.showingTop', 'Showing top {n} of {m}')
.replace('{n}', tabList[activeTab].topLimit.toLocaleString())
.replace('{m}', topNodesAll.length.toLocaleString()) }} —
<span class="stl-overflow-hint">{{ T('legend.useGeocoderHint', 'use the geocoder above to find the rest') }}</span>
</div>
</div>
<ApproxPinNote v-if="showPinNote" :dark="stlIsDark" />
</div>
</div>
<button v-if="!panelOpen"
class="stl-reopen"
:data-theme="instance?.theme.value || 'dark'"
:title="t('aria.expandLegend')"
:aria-label="t('aria.expandLegend')"
@click="panelOpen = true; emit('update:open', 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>{{ displayTitle }}</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>
/* All runtime styles injected via useShadowStyles for shadow DOM compatibility. */
</style>