PPLR maps: the legend and the instance store are the library's, not drifted copies

pplr carried private copies of SemanticTreeLegend and usePplrInstance that had drifted.
The instance copy lacked activeTabTouched, so the legend tab snapped back to its
default after a pick; the legend copy spoke English accessibility labels in every
locale. Both are deleted and the views import the library versions.
This commit is contained in:
Doxa Maps 2026-09-28 03:58:06 +00:00
parent 7077c55502
commit 5d67a3969d
9 changed files with 9 additions and 1073 deletions

View file

@ -19,7 +19,7 @@
* and writes selection back to `instance.selection.value`.
*/
import { ref, computed } from 'vue'
import { useInstance } from '../composables/usePplrInstance.js'
import { useInstance } from '@map/composables/usePplrInstance.js'
import { usePplrT } from '../composables/usePplrT.js'
const props = defineProps({

View file

@ -23,7 +23,7 @@ import { ensureGeocoder, GEOCODER } from '../utils/pplrData.js'
import { escHtml } from '../utils/pplrMap.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { ensureEngineScript, engineGlobal } from '@map/composables/useMapInstance.js'
import { useInstance } from '../composables/usePplrInstance.js'
import { useInstance } from '@map/composables/usePplrInstance.js'
const props = defineProps({
/** Pre-flattened searchable items: [{id, label, kind, depth, filter, color?, count?, pop?, ...}] */

View file

@ -16,7 +16,7 @@
* (X button or Esc) clears instance.pinDetail and the legend reappears.
*/
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useInstance } from '../composables/usePplrInstance.js'
import { useInstance } from '@map/composables/usePplrInstance.js'
import { LOCALES, LOCALE_NAMES } from '../utils/pplrMap.js'
import { loadNames } from '../utils/pplrData.js'
import { usePplrT } from '../composables/usePplrT.js'

View file

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

View file

@ -1,93 +0,0 @@
/**
* usePplrInstance.js — per-map "instance store" for PPLR profiles.
*
* Implements the Mediator Pattern (per QA-feedback Round 8 architecture closeout):
* each map cluster (one map + one geocoder + one legend) gets its own scoped store.
* Components never call each other directly — they read/write the store and watch its fields.
*
* Bidirectional sync between geocoder and legend lives here:
* - Selecting a row in the legend → store.selection set → geocoder watches and clears its text
* - Picking a result in the geocoder → store.selection set + store.activeTab synced → legend reacts
* - X on either side → store.selection cleared → both clear
*
* Each profile creates one instance via createPplrInstance() and provides it.
* Children (legend, geocoder) consume it via useInstance().
*/
import { ref, provide, inject } from 'vue'
const KEY = 'pplrInstance'
/**
* Create a new instance store for one map.
* @param {string} instanceId Stable id used for debugging/keying.
* @returns {object} reactive store
*/
export function createPplrInstance(instanceId) {
return {
instanceId,
// ── Selection (drives map filter + legend highlight) ────────────────────
/** Currently selected tree node, or null. Shape: { id, label, kind, depth, filter, color, ...extras } */
selection: ref(null),
/** Active legend tab index (0-based — but Tab 1 = Generation 1 in user-facing labels) */
activeTab: ref(0),
/** Search-mode flag: true when geocoder text ≥ 2 chars */
isSearching: ref(false),
// ── Geocoder text (read by legend so it can dim itself, etc.) ────────────
geocoderText: ref(''),
// ── Pin-detail panel (clicked pin replaces legend with detail) ──────────
/** Currently clicked pin properties, or null */
pinDetail: ref(null),
// ── Chrome theme — driven by the active basemap (light-v11 → 'light',
// anything else → 'dark'). Legend/geocoder/detail-card read this and
// apply [data-theme] CSS overrides so chrome matches the map underneath.
theme: ref('dark'),
}
}
/**
* Provide the instance to descendants.
* Call this in the profile (parent) component.
*/
export function provideInstance(instance) {
provide(KEY, instance)
}
/**
* Inject the instance in a child component.
* Returns null if no instance was provided (component used standalone).
*/
export function useInstance() {
return inject(KEY, null)
}
/**
* Convenience: select a node, sync legend tab to its depth.
*/
export function selectNode(instance, node) {
if (!instance) return
if (!node) {
instance.selection.value = null
return
}
instance.selection.value = node
if (typeof node.depth === 'number') {
instance.activeTab.value = node.depth
}
}
/**
* Convenience: clear all (selection + geocoder text + pin detail).
*/
export function clearAll(instance) {
if (!instance) return
instance.selection.value = null
instance.geocoderText.value = ''
instance.pinDetail.value = null
}

View file

@ -18,11 +18,11 @@ import { resolveNamedStyle } from '@map/styles/registry.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, POPUP_CSS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick, escHtml } from '../utils/pplrMap.js'
import SemanticTreeLegend from '../components/SemanticTreeLegend.vue'
import SemanticTreeLegend from '@map/components/legends/SemanticTreeLegend.vue'
import MapboxGeocoder from '../components/MapboxGeocoder.vue'
import PinDetailCard from '../components/PinDetailCard.vue'
import LoadingGlobe from '../components/LoadingGlobe.vue'
import { createPplrInstance, provideInstance } from '../composables/usePplrInstance.js'
import { createPplrInstance, provideInstance } from '@map/composables/usePplrInstance.js'
const pplrInstance = createPplrInstance('language-families')
provideInstance(pplrInstance)

View file

@ -19,7 +19,7 @@ import MapboxGeocoder from '../components/MapboxGeocoder.vue'
import PinDetailCard from '../components/PinDetailCard.vue'
import FlatLegend from '../components/FlatLegend.vue'
import LoadingGlobe from '../components/LoadingGlobe.vue'
import { createPplrInstance, provideInstance } from '../composables/usePplrInstance.js'
import { createPplrInstance, provideInstance } from '@map/composables/usePplrInstance.js'
const pplrInstance = createPplrInstance('religion')
provideInstance(pplrInstance)

View file

@ -14,11 +14,11 @@ import { resolveNamedStyle } from '@map/styles/registry.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, setCountryPolygonTheme, POPUP_CSS, COUNTRY_FS_URL, COUNTRY_FS_FIELDS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick, escHtml } from '../utils/pplrMap.js'
import SemanticTreeLegend from '../components/SemanticTreeLegend.vue'
import SemanticTreeLegend from '@map/components/legends/SemanticTreeLegend.vue'
import MapboxGeocoder from '../components/MapboxGeocoder.vue'
import PinDetailCard from '../components/PinDetailCard.vue'
import LoadingGlobe from '../components/LoadingGlobe.vue'
import { createPplrInstance, provideInstance } from '../composables/usePplrInstance.js'
import { createPplrInstance, provideInstance } from '@map/composables/usePplrInstance.js'
// ── Per-map instance store (Mediator pattern: ties legend, geocoder, map together) ──
const pplrInstance = createPplrInstance('rop-tree')

View file

@ -14,7 +14,7 @@ import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, POPUP_CSS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
import PinDetailCard from '../components/PinDetailCard.vue'
import LoadingGlobe from '../components/LoadingGlobe.vue'
import { createPplrInstance, provideInstance } from '../composables/usePplrInstance.js'
import { createPplrInstance, provideInstance } from '@map/composables/usePplrInstance.js'
const pplrInstance = createPplrInstance('translations')
provideInstance(pplrInstance)