Measured all seven production maps at 390x844 with touch emulation. 195 controls sat below WCAG 2.5.8's AA minimum of 24x24 CSS px, spread across five maps -- but they were not five owners' bugs. Grouped by class, 195 failures were ten classes, and 119 of them lived in library/: the same PosterWall, PinHint, LegendMobile, SemanticTreeLegend and ResearchMapFilterPanel controls repeated down a list and across four maps. Routing the per-map counts would have filed hundreds of defects against owners who do not own the geometry. Raised the shared controls, scoped to (pointer:coarse). 2.5.8 is a touch ergonomics rule, and these components sit on maps this change does not own: a finger gets the bigger target, the mouse keeps the density those owners designed. The poster row is the reason this matters -- its height was tuned by eye against the card's blue outline, and growing it for a pointer that needs no help would have spent the artwork clearance to fix nothing. min-height states the 24 instead of deriving it. Growing the padding first MEASURED 23.7px and 23.0px -- under by a third of a pixel and by one, because the height is padding plus a line box the renderer computes from a 10.5px font. The declared minimum also survives a font swap or a locale with taller glyphs. The header tape now self-centres like the footer. It hung on top:-11px, half of its then-fixed 22px height, which silently becomes wrong the moment the height changes -- as it just did. top:0 with translate(-50%,-50%) sits on the border at any height, the mechanism the footer already used, and one less number to keep in sync. Two exceptions in 2.5.8 are now encoded in the probe rather than remembered. Inline: 48 .uupg__country-link "failures" are an <a> inside a paragraph, computed display:inline, sized by the line-height of the text around it -- the spec exempts them and the probe now skips display:inline, counting them separately. Equivalent: a .lpb-seg progress segment is 10px tall inside a track that clips overflow, so no hit-area extender can reach 24px; it resolves to a tree node and calls the same selectNode a full-width legend row calls. 195 failures on five maps -> 4 on two. UPG-100 alone went 137 -> 0. The poster tape gate and per-row persistence still pass.
1346 lines
64 KiB
Vue
1346 lines
64 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 { useI18n } from 'vue-i18n'
|
|
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
|
import { useInstance } from '../../composables/usePplrInstance.js'
|
|
import LegendProgressBar from './LegendProgressBar.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())
|
|
let t = (k) => humanizeKey(k)
|
|
let te = () => false
|
|
try { const i18n = useI18n(); t = i18n.t; te = i18n.te } catch (_) { /* no i18n on this host */ }
|
|
|
|
// 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 Driver 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: () => [] },
|
|
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)
|
|
// Driver 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')
|
|
// 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
|
|
// Driver'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 (Driver, 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; left: 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; }
|
|
/* 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 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;
|
|
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;
|
|
}
|
|
.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; 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;
|
|
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. Driver: "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-left: 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; right: 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 (Driver, 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 (Driver, 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="Export selection"
|
|
aria-label="Export selection"
|
|
@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">Export</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">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">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>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>
|
|
|
|
<button v-if="!panelOpen"
|
|
class="stl-reopen"
|
|
:data-theme="instance?.theme.value || 'dark'"
|
|
title="Expand legend"
|
|
@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>
|