Update from source 20de48354
This commit is contained in:
parent
b94ba25c27
commit
774af31d2d
13 changed files with 535 additions and 1121 deletions
|
|
@ -19,7 +19,7 @@
|
||||||
* and writes selection back to `instance.selection.value`.
|
* and writes selection back to `instance.selection.value`.
|
||||||
*/
|
*/
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useInstance } from '../composables/usePplrInstance.js'
|
import { useInstance } from '@map/composables/usePplrInstance.js'
|
||||||
import { usePplrT } from '../composables/usePplrT.js'
|
import { usePplrT } from '../composables/usePplrT.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import { ensureGeocoder, GEOCODER } from '../utils/pplrData.js'
|
||||||
import { escHtml } from '../utils/pplrMap.js'
|
import { escHtml } from '../utils/pplrMap.js'
|
||||||
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
||||||
import { ensureEngineScript, engineGlobal } from '@map/composables/useMapInstance.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({
|
const props = defineProps({
|
||||||
/** Pre-flattened searchable items: [{id, label, kind, depth, filter, color?, count?, pop?, ...}] */
|
/** Pre-flattened searchable items: [{id, label, kind, depth, filter, color?, count?, pop?, ...}] */
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
* (X button or Esc) clears instance.pinDetail and the legend reappears.
|
* (X button or Esc) clears instance.pinDetail and the legend reappears.
|
||||||
*/
|
*/
|
||||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
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 { LOCALES, LOCALE_NAMES } from '../utils/pplrMap.js'
|
||||||
import { loadNames } from '../utils/pplrData.js'
|
import { loadNames } from '../utils/pplrData.js'
|
||||||
import { usePplrT } from '../composables/usePplrT.js'
|
import { usePplrT } from '../composables/usePplrT.js'
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -18,11 +18,11 @@ import { resolveNamedStyle } from '@map/styles/registry.js'
|
||||||
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
||||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||||
import { addCountryPolygons, POPUP_CSS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick, escHtml } from '../utils/pplrMap.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 MapboxGeocoder from '../components/MapboxGeocoder.vue'
|
||||||
import PinDetailCard from '../components/PinDetailCard.vue'
|
import PinDetailCard from '../components/PinDetailCard.vue'
|
||||||
import LoadingGlobe from '../components/LoadingGlobe.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')
|
const pplrInstance = createPplrInstance('language-families')
|
||||||
provideInstance(pplrInstance)
|
provideInstance(pplrInstance)
|
||||||
|
|
@ -120,11 +120,11 @@ const hasPop = ref(false)
|
||||||
const famLangGroups = ref({}) // famKey → { count, pop, langs: LangGroup[] }
|
const famLangGroups = ref({}) // famKey → { count, pop, langs: LangGroup[] }
|
||||||
|
|
||||||
const LANG_TABS = [
|
const LANG_TABS = [
|
||||||
{ id: 'family', label: 'Lang Family',
|
{ id: 'family', label: tStatic('tabs.family'),
|
||||||
info: 'PPLR\'s ethnolinguistic family classification (per assets/data/pplr-geoid-family.json). Note: PPLR groups Arabic under "Arab (Arabic)" rather than the broader linguistic family "Afro-Asiatic", and splits other Afro-Asiatic peoples into "Hausa-Chadic", "Cushitic", "Sudanic", etc.' },
|
info: 'PPLR\'s ethnolinguistic family classification (per assets/data/pplr-geoid-family.json). Note: PPLR groups Arabic under "Arab (Arabic)" rather than the broader linguistic family "Afro-Asiatic", and splits other Afro-Asiatic peoples into "Hausa-Chadic", "Cushitic", "Sudanic", etc.' },
|
||||||
{ id: 'language', label: 'Language',
|
{ id: 'language', label: tStatic('tabs.language'),
|
||||||
info: 'A distinct language (e.g. Arabic, Hindi, Punjabi). Identified by 3-character ROL code (ISO 639-3).' },
|
info: 'A distinct language (e.g. Arabic, Hindi, Punjabi). Identified by 3-character ROL code (ISO 639-3).' },
|
||||||
{ id: 'variant', label: 'Variant / Dialect',
|
{ id: 'variant', label: tStatic('tabs.dialect'),
|
||||||
info: 'A regional or social variety of a language (e.g. "Punjabi, Western", "Arabic, Egyptian"). Comma-separated names group as variants under a root language.' },
|
info: 'A regional or social variety of a language (e.g. "Punjabi, Western", "Arabic, Egyptian"). Comma-separated names group as variants under a root language.' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import MapboxGeocoder from '../components/MapboxGeocoder.vue'
|
||||||
import PinDetailCard from '../components/PinDetailCard.vue'
|
import PinDetailCard from '../components/PinDetailCard.vue'
|
||||||
import FlatLegend from '../components/FlatLegend.vue'
|
import FlatLegend from '../components/FlatLegend.vue'
|
||||||
import LoadingGlobe from '../components/LoadingGlobe.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')
|
const pplrInstance = createPplrInstance('religion')
|
||||||
provideInstance(pplrInstance)
|
provideInstance(pplrInstance)
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@ import { resolveNamedStyle } from '@map/styles/registry.js'
|
||||||
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
||||||
import { useMapInstance } from '@map/composables/useMapInstance.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 { 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 MapboxGeocoder from '../components/MapboxGeocoder.vue'
|
||||||
import PinDetailCard from '../components/PinDetailCard.vue'
|
import PinDetailCard from '../components/PinDetailCard.vue'
|
||||||
import LoadingGlobe from '../components/LoadingGlobe.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) ──
|
// ── Per-map instance store (Mediator pattern: ties legend, geocoder, map together) ──
|
||||||
const pplrInstance = createPplrInstance('rop-tree')
|
const pplrInstance = createPplrInstance('rop-tree')
|
||||||
|
|
@ -96,13 +96,13 @@ const BASEMAPS = [
|
||||||
// is handled by a separate floating panel (too many nodes for the inline tree).
|
// is handled by a separate floating panel (too many nodes for the inline tree).
|
||||||
// Short tab labels keep all 4 chips on a single row. The "i" tooltip carries the full meaning.
|
// Short tab labels keep all 4 chips on a single row. The "i" tooltip carries the full meaning.
|
||||||
const ROP_TABS = [
|
const ROP_TABS = [
|
||||||
{ id: 'affinity', label: 'Bloc',
|
{ id: 'affinity', label: tStatic('tabs.bloc'),
|
||||||
info: 'Affinity Bloc — top-level grouping of related people groups (e.g. Arab Peoples). 17 blocs total.' },
|
info: 'Affinity Bloc — top-level grouping of related people groups (e.g. Arab Peoples). 17 blocs total.' },
|
||||||
{ id: 'cluster', label: 'Cluster',
|
{ id: 'cluster', label: tStatic('tabs.cluster'),
|
||||||
info: 'People Cluster — subdivision within an Affinity Bloc (e.g. Maghrebi Arab inside Arab Peoples). 292 clusters.' },
|
info: 'People Cluster — subdivision within an Affinity Bloc (e.g. Maghrebi Arab inside Arab Peoples). 292 clusters.' },
|
||||||
{ id: 'rop25', label: 'ROP25',
|
{ id: 'rop25', label: 'ROP25',
|
||||||
info: 'People Group level — a distinct ethnolinguistic identity (e.g. Algerian Arab). ~13k groups.' },
|
info: 'People Group level — a distinct ethnolinguistic identity (e.g. Algerian Arab). ~13k groups.' },
|
||||||
{ id: 'rop3xc', label: 'PGIC',
|
{ id: 'rop3xc', label: tStatic('tabs.pgic'),
|
||||||
topLimit: 200,
|
topLimit: 200,
|
||||||
info: 'ROP3 × Country (PGIC) — same ethnolinguistic group in a specific country (e.g. Algerian Arab in France). 21k records. Showing top 200 by population — use the geocoder to find the rest.' },
|
info: 'ROP3 × Country (PGIC) — same ethnolinguistic group in a specific country (e.g. Algerian Arab in France). 21k records. Showing top 200 by population — use the geocoder to find the rest.' },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||||
import { addCountryPolygons, POPUP_CSS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
|
import { addCountryPolygons, POPUP_CSS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
|
||||||
import PinDetailCard from '../components/PinDetailCard.vue'
|
import PinDetailCard from '../components/PinDetailCard.vue'
|
||||||
import LoadingGlobe from '../components/LoadingGlobe.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')
|
const pplrInstance = createPplrInstance('translations')
|
||||||
provideInstance(pplrInstance)
|
provideInstance(pplrInstance)
|
||||||
|
|
|
||||||
|
|
@ -1014,7 +1014,7 @@
|
||||||
"bundle": "pplr-data-maps",
|
"bundle": "pplr-data-maps",
|
||||||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||||
"output": "dx-maps/pplr-data-maps/pplr-data-maps.js",
|
"output": "dx-maps/pplr-data-maps/pplr-data-maps.js",
|
||||||
"integrity": "sha384-rubpRmSBkm6v202d1whkPHrDHqpD+3+OKsqAYaaYcFHxjH4McsdsZUypXrcHgUVz",
|
"integrity": "sha384-McJy8k7v2O8yIa21+LLiW4lpkdS+38y+Dsb0jHnvMz2mNP6WEUdVosLJi2B+OoqR",
|
||||||
"elements": [
|
"elements": [
|
||||||
"pplr-data-maps"
|
"pplr-data-maps"
|
||||||
],
|
],
|
||||||
|
|
@ -1033,7 +1033,7 @@
|
||||||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||||
"embed": {
|
"embed": {
|
||||||
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-rubpRmSBkm6v202d1whkPHrDHqpD+3+OKsqAYaaYcFHxjH4McsdsZUypXrcHgUVz\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
|
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-McJy8k7v2O8yIa21+LLiW4lpkdS+38y+Dsb0jHnvMz2mNP6WEUdVosLJi2B+OoqR\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
|
||||||
},
|
},
|
||||||
"bundle": "pplr-data-maps"
|
"bundle": "pplr-data-maps"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1014,7 +1014,7 @@
|
||||||
"bundle": "pplr-data-maps",
|
"bundle": "pplr-data-maps",
|
||||||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||||
"output": "dx-maps/pplr-data-maps/pplr-data-maps.js",
|
"output": "dx-maps/pplr-data-maps/pplr-data-maps.js",
|
||||||
"integrity": "sha384-rubpRmSBkm6v202d1whkPHrDHqpD+3+OKsqAYaaYcFHxjH4McsdsZUypXrcHgUVz",
|
"integrity": "sha384-McJy8k7v2O8yIa21+LLiW4lpkdS+38y+Dsb0jHnvMz2mNP6WEUdVosLJi2B+OoqR",
|
||||||
"elements": [
|
"elements": [
|
||||||
"pplr-data-maps"
|
"pplr-data-maps"
|
||||||
],
|
],
|
||||||
|
|
@ -1033,7 +1033,7 @@
|
||||||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||||
"embed": {
|
"embed": {
|
||||||
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-rubpRmSBkm6v202d1whkPHrDHqpD+3+OKsqAYaaYcFHxjH4McsdsZUypXrcHgUVz\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
|
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-McJy8k7v2O8yIa21+LLiW4lpkdS+38y+Dsb0jHnvMz2mNP6WEUdVosLJi2B+OoqR\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
|
||||||
},
|
},
|
||||||
"bundle": "pplr-data-maps"
|
"bundle": "pplr-data-maps"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -36,7 +36,7 @@
|
||||||
|
|
||||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||||
<!-- Bundle IIFE — registers the <pplr-data-maps> custom element. -->
|
<!-- Bundle IIFE — registers the <pplr-data-maps> custom element. -->
|
||||||
<script src="../pplr-data-maps.js" integrity="sha384-rubpRmSBkm6v202d1whkPHrDHqpD+3+OKsqAYaaYcFHxjH4McsdsZUypXrcHgUVz" crossorigin="anonymous"></script>
|
<script src="../pplr-data-maps.js" integrity="sha384-McJy8k7v2O8yIa21+LLiW4lpkdS+38y+Dsb0jHnvMz2mNP6WEUdVosLJi2B+OoqR" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue