doxa-maps/plugin-designs/research-dashboard/src/composables/useDashboardShell.js
doxa-maps-88655c70 b4a0387495 Translate the chrome that was English in all eleven locales
Measured under ?lang=ru on both composites: every Latin-script string left on the
page was ours. The map above was fully translated; underneath it the grid, the
map's own side menu and filter panel, and the whole research dashboard were
English in Arabic, Chinese, Hindi, Russian and everything else.

Three surfaces, three reasons, none of them a missing translation:

  the grid          read labels from a host-supplied `t` object that nothing ever
                    filled, with an English literal at each call site as fallback
  side menu +
  filter panel      plain string literals
  dashboard         labels declared as literals in dashboard.config.js — facet
                    rail, chart titles, KPI strip, table headers, segmented
                    control, search placeholder

New catalogues for all 11 locales (grid, controls, dashboard), REUSING the
catalogue's existing wording wherever the word already existed — Регион, Блок
родства, Население, Вовлечён, Графики — so two panels never name one thing two
ways, and nothing is coined that the project already has a word for.

Read through a new plugin-free tStatic() rather than useI18n, because these
components are also mounted by entries that install no i18n plugin. An unguarded
useI18n() throws and a thrown setup() removes the subtree with no build error —
the defect that had just blanked people-groups-grid everywhere (6603dc17).

★ THE CONFIG LABELS ARE GETTERS, AND A SPREAD WOULD HAVE LOOKED IDENTICAL.
dashboard.config.js is imported once at load; the locale is only known later. My
first version spread a getter object, and a spread COPIES THE RESOLVED VALUE, not
the accessor — every label would have frozen at whichever locale loaded first,
and it would have looked perfect in English. `get label() {…}` resolves on each
read.

MEASURED after, served built tree, identity checked first: Big Picture ru/ar and
Simple Research ru/zh have ZERO Latin-script chrome strings. What remains is
Mapbox's licence attribution (never translate), country and people-group names
(data), and 'UUPGs' — which every locale file deliberately keeps as the acronym.

Three gates went red on the way, two of them mine and worth recording:
  check:locale-reachable  slices this i18n module between two markers and EVALS
                          the slice; I had put tStatic between them, so the gate
                          hit an `export`. Moved to the end of the file.
  check:library-index     my import+helper above a component's docblock became
                          that component's DERIVED description. Moved below it.
  check:kpi-unit          its parser assumed single-brace entries, so getters made
                          it find ZERO pct KPIs — and it failed rather than
                          printing a green PASS about nothing, which is the gate
                          working. Parse now understands getters AND checks the
                          translated labels, so a '%' reintroduced in any of the
                          11 catalogues fails by name. Both controls fired.
2026-09-25 06:10:27 +00:00

369 lines
20 KiB
JavaScript

/**
* useDashboardShell.js — the config-driven dashboard assembler (DOXA-LIB-2 foundation).
*
* Does generically what research-dashboard.vue's <script setup> did by hand: create one
* brushing-and-linking selection bus, load the single in-memory row store, derive the
* (formerly copy-pasted) FACET_ACC accessor map from config.facets, compute the linked
* filteredRows + KPI strip + CSV exporter + shareable hash URL + responsive layout flags,
* and provide() the bus/data/config so every panel injects instead of hard-coding.
*
* The next dashboard becomes a thin app-profile that passes a config object — no pasted
* filter loops, no pasted accessor maps, no pasted KPI math. Declare the config once; the
* shell wires the whole CMV (coordinated-multiple-views) surface.
*
* const { bus, data, FACET_ACC, filteredRows, kpis, exporter, responsive } = useDashboardShell(config)
*
* Provides (inject keys for panels):
* - 'selectionBus' → the pub/sub bus (filterSet + selectionSet + on/emit/setFilter/toggleSelect)
* - 'dashboardData' → the row store ({ rows, rowStore, total, loading, error, stats, load })
* - 'dashboardConfig'→ the profile config (facets, facetRail, kpis, columns, charts, detail, search, title)
* (mapboxToken is provided upstream by ProfileLoader; panels inject it directly.)
*
* Logic moved VERBATIM from research-dashboard.vue — filter predicate, KPI math, ResizeObserver
* responsive switch, collapsible table dock, segmented mobile view, and the location.hash sync.
* Only the SOURCE of the per-dashboard constants changed: from hard-coded → config.
*/
import { exactMatches, fuzzyMatches, resolveFuzzyValues } from '@map/components/search/fuzzy.js'
import { provide, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { createSelectionBus } from './useSelectionBus.js'
import { useDashboardData } from './useDashboardData.js'
import { useCsvExport } from './useCsvExport.js'
import { useTreeLegendLabels } from './useTreeLegendLabels.js'
import { DataSourceManager } from '@map/api/DataSourceManager.js'
import sourcesConfig from '@map/api/sources.json'
import { tStatic } from '@map/i18n/index.js'
// tiny lodash-free mapValues: { k: v } -> { k: fn(v, k) }
const mapValues = (obj, fn) =>
Object.fromEntries(Object.entries(obj || {}).map(([k, v]) => [k, fn(v, k)]))
// number formatter (same as the old inline `fmt`); exported for the profile's KPI strip.
export const fmt = (n) => (n === null || n === undefined ? '—' : Number(n).toLocaleString('en-US'))
/**
* Build the free-text search accessors for a dashboard config (header-1 search bar).
* config.searchKeys (facet keys) → those facet accessors; else the string columns' accessors.
* ONE declaration reused by the shell (KPIs) AND every panel (table/map/charts/facets) so the
* searchable text can never drift from what the panels show, and the substring match is not
* copy-pasted into each panel (kills the very duplication DOXA-LIB-2 removed).
*/
export function buildSearchAccessors(config) {
const FACET_ACC = mapValues(config.facets, (f) => f.get)
const COL_ACC = Object.fromEntries((config.columns || []).map((c) => [c.key, c.get]))
const keys = Array.isArray(config.searchKeys) ? config.searchKeys : null
if (keys) return keys.map((k) => FACET_ACC[k] || COL_ACC[k]).filter((fn) => typeof fn === 'function')
return (config.columns || []).filter((c) => c.type !== 'num' && typeof c.get === 'function').map((c) => c.get)
}
/**
* Reusable predicate: does `row` match the bus's current free-text search term?
* Returns true when there is no term (identical to pre-search behaviour). `accessors` is the
* array from buildSearchAccessors(config). Case-insensitive substring over each accessor's value.
*/
export function makeSearchMatcher(bus, accessors) {
// ★ FUZZY, NOT SUBSTRING (Driver 2026-09-23: "add an n to the end of a country name" must
// still find it -- check what DX-life's search bar bakes in). DX-life runs real Fuse.js with
// { threshold: 0.4, ignoreLocation: true, minMatchCharLength: 3 } (UupgsList.vue:288), so its
// search tolerates typos. This matcher was a plain .includes() -- the one search surface that
// silently lost that behaviour in the migration (measured: 'Bhutan' 13 rows, 'Bhutann' 0).
// It now delegates to the shared fuzzyMatches() in @map fuzzy.js -- the same matcher the
// grid's Fuse uses -- with the same DX-life options, so every search surface agrees.
// Row-level predicates for the TIERED contract (see fuzzy.js exactMatches): the caller
// (filterRowsBySearch below) runs the exact tier over the whole row set first and falls back
// to the fuzzy tier only when exact yields nothing, so an exact term agrees with the facet.
const test = (row, pred) => {
const needle = (bus?.searchTerm?.value || '').trim()
if (!needle) return true
for (const acc of accessors) {
const v = acc(row)
if (v != null && pred(String(v), needle, { threshold: 0.4, minMatchCharLength: 3 })) return true
}
return false
}
const matcher = (row) => test(row, fuzzyMatches)
matcher.exact = (row) => test(row, exactMatches)
matcher.active = () => !!(bus?.searchTerm?.value || '').trim()
matcher.term = () => (bus?.searchTerm?.value || '').trim()
matcher.accessors = accessors
return matcher
}
/**
* Apply the TIERED search to a row set:
* 1. exact substring -- an exact term returns exactly the rows the equivalent facet selects.
* 2. facet-VALUE resolution (Driver: "Austrian or Canadian... a selection of that country --
* the table under the map shows the result for that country"): the term is resolved by
* whole-value edit distance against each accessor's DISTINCT values, and the rows returned
* are those carrying a resolved value -- byte-identical to picking it as a filter.
* 3. the generic windowed fuzzy rescue, only when nothing resolves.
*/
export function filterRowsBySearch(rows, matcher) {
if (!matcher.active()) return rows
const exact = rows.filter(matcher.exact)
if (exact.length) return exact
const term = matcher.term()
// ONE pool across every accessor's distinct values, so the GLOBAL best match wins (see
// resolveFuzzyValues: ties at the minimal distance stay, everything farther drops --
// 'Canadian' resolves to Canada, not to 47 rows of <=3-edit neighbours across facets).
const distinct = new Set()
for (const acc of matcher.accessors) for (const r of rows) { const v = acc(r); if (v != null && v !== '') distinct.add(String(v)) }
const winners = new Set(resolveFuzzyValues(term, [...distinct], { threshold: 0.4 }))
if (winners.size) {
const resolved = rows.filter((r) => matcher.accessors.some((acc) => { const v = acc(r); return v != null && winners.has(String(v)) }))
if (resolved.length) return resolved
}
return rows.filter(matcher)
}
/**
* Generic KPI computation driven by config.kpis. Each kpi def declares a `kind`:
* count → row count of the filtered scope (+ optional delta = hidden-by-filter count)
* sum → Σ Number(row[field])
* pct → round( rows.filter(test).length / rows.length * 100 )% ('—' when empty)
* distinct → size of the set produced by an accessor (via config.facets[field][via], e.g. geoIso)
* Mirrors the old statTotal/statPop/statPctUnreached/statCountries/statPraying/statAdoptedPct
* computeds — only the definitions now come from config instead of being hand-written.
*/
function computeKpis(defs, filtered, all, FACET_ACC, config) {
return (defs || []).map((k) => {
let value
let delta
if (k.kind === 'count') {
value = filtered.length
if (k.showDelta) delta = Math.max(0, all.length - filtered.length)
} else if (k.kind === 'sum') {
value = filtered.reduce((s, r) => s + (Number(r[k.field]) || 0), 0)
} else if (k.kind === 'pct') {
value = filtered.length
? Math.round((filtered.filter(k.test).length / filtered.length) * 100) + '%'
: '—'
} else if (k.kind === 'distinct') {
const acc = k.via ? config?.facets?.[k.field]?.[k.via] : FACET_ACC[k.field]
const set = new Set()
filtered.forEach((r) => { const v = acc ? acc(r) : r[k.field]; if (v) set.add(v) })
value = set.size
}
return { id: k.id, label: k.label, kind: k.kind, value, delta }
})
}
/**
* Responsive layout flags via a ResizeObserver (shadow-DOM-reliable, unlike @container which
* targets descendants not the container itself). Three steps so the map stays the hero:
* <760 single-column + segmented control; <980 slim facet rail; <1180 slim charts/detail rail.
* Also owns the collapsible table dock (DataTable chevron emits 'toggleTable') and the
* segmented mobile view switch. Extracted VERBATIM from research-dashboard.vue.
*/
function useResponsiveShell(bus) {
const shell = ref(null)
const isNarrow = ref(false)
const slimRail = ref(false)
const slimFacet = ref(false)
const mobileView = ref('map')
// Getters, for the same reason dashboard.config.js uses them: this array is built before the
// locale is necessarily known, and a resolved string would freeze the first locale seen.
const SEG = [
{ k: 'map', get l() { return tStatic('dashboard.map') || 'Map' } },
{ k: 'facets', get l() { return tStatic('dashboard.filters') || 'Filters' } },
{ k: 'table', get l() { return tStatic('dashboard.table') || 'Table' } },
{ k: 'charts', get l() { return tStatic('dashboard.charts') || 'Charts' } },
]
let ro = null
const tableCollapsed = ref(false)
const facetCollapsed = ref(false) // left filters rail collapsed → map grows
const railCollapsed = ref(false) // right charts rail collapsed → map grows
onMounted(() => {
if (shell.value) {
ro = new ResizeObserver(() => {
const w = shell.value.clientWidth
isNarrow.value = w < 760
slimRail.value = w >= 760 && w < 1180
slimFacet.value = w >= 760 && w < 980
})
ro.observe(shell.value)
}
// collapsible table dock (P5): DataTable's chevron emits 'toggleTable'
if (bus) bus.on('toggleTable', (v) => { tableCollapsed.value = !!v })
if (bus) bus.on('toggleFacet', (v) => { facetCollapsed.value = !!v })
if (bus) bus.on('toggleRail', (v) => { railCollapsed.value = !!v })
})
onBeforeUnmount(() => ro && ro.disconnect())
function setView(e) { const el = e.target.closest('[data-view]'); if (el) mobileView.value = el.getAttribute('data-view') }
const showFacets = computed(() => !isNarrow.value || mobileView.value === 'facets')
const showMap = computed(() => !isNarrow.value || mobileView.value === 'map')
const showTable = computed(() => !isNarrow.value || mobileView.value === 'table')
const showCharts = computed(() => !isNarrow.value || mobileView.value === 'charts')
return {
shell, isNarrow, slimRail, slimFacet, mobileView, SEG, tableCollapsed, facetCollapsed, railCollapsed,
setView, showFacets, showMap, showTable, showCharts,
}
}
/**
* Shareable view-state hash URL: serialize filterSet to location.hash; restore on mount.
*
* ★ READABLE, CODE-CANONICAL FORMAT (card BUG-SHARE-URL-LEAKS-RAW-CODES). The old hash was
* URL-encoded JSON -- #f=%7B%22affinity_bloc%22%3A%22A008%22%7D -- unreadable twice over: JSON
* punctuation percent-escaped, and the value a bare API code. A URL is the one part of the
* product a user COPIES, PASTES and READS, and for sensitive population data a link you cannot
* sanity-check before clicking is a real cost. The format is now
*
* #f=affinity_bloc:A008*malay-peoples;religion_family:Hinduism
*
* -- the Stack Overflow /questions/123/slug pattern: the CODE is canonical and the *slug is
* DISPLAY-ONLY, appended from labelFor() at share time and STRIPPED on parse. Restoring never
* reads the slug, so links stay locale-independent and survive label renames (the card's
* explicit constraint: keep the code canonical, do not swap in localized labels).
*
* `*` is safe as the slug separator because escapeValue() percent-encodes any literal `*` in
* the VALUE first -- the separator cannot collide with content. `;` and `:` are legal fragment
* chars, so the whole thing stays legible in the address bar. Legacy JSON hashes (old shared
* links) are detected by their leading `{` / %7B and still restore.
*/
const slugify = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
// Minimal escaping, maximal legibility: reserve our three delimiters + %, keep spaces as `+`
// (the x-www-form-urlencoded convention), leave everything else -- including unicode -- alone.
const escapeValue = (s) => String(s).replace(/[%;:*+]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/ /g, '+')
const unescapeValue = (s) => { try { return decodeURIComponent(String(s).replace(/\+/g, '%20')) } catch (_) { return String(s).replace(/\+/g, ' ') } }
function useHashSync(bus, labelFor) {
function syncHash() {
try {
const pairs = Object.entries(bus.filterSet).map(([k, v]) => {
const label = typeof labelFor === 'function' ? labelFor(k, v) : null
const slug = label && String(label) !== String(v) ? slugify(label) : ''
return k + ':' + escapeValue(v) + (slug ? '*' + slug : '')
})
if (typeof location !== 'undefined') history.replaceState(null, '', '#f=' + pairs.join(';'))
} catch (_) { /* sandboxed */ }
}
watch(() => ({ ...bus.filterSet }), syncHash, { deep: true })
onMounted(() => {
try {
const m = typeof location !== 'undefined' && /[#&]f=([^&]+)/.exec(location.hash)
if (!m) return
const raw = m[1]
if (raw.startsWith('{') || raw.startsWith('%7B') || raw.startsWith('%7b')) {
// legacy JSON hash from an old shared link
const obj = JSON.parse(decodeURIComponent(raw))
Object.entries(obj).forEach(([k, v]) => bus.setFilter(k, v))
return
}
for (const pair of raw.split(';')) {
const i = pair.indexOf(':')
if (i < 1) continue
const key = pair.slice(0, i)
const value = unescapeValue(pair.slice(i + 1).split('*')[0])
if (key && value) bus.setFilter(key, value)
}
} catch (_) { /* ignore bad hash */ }
})
}
export function useDashboardShell(config) {
// One brushing-and-linking bus, provided to every panel (publisher + subscriber).
const bus = createSelectionBus()
// Teach the bus this profile's facet hierarchy, derived from config.facetRail[].drill, so that
// clearing a parent facet cascade-clears its descendants on EVERY clear path (tree rail /
// dropdowns / chip ✕ / Clear All all route through bus.setFilter). Without this, a child
// predicate outlives its parent and silently ANDs an empty result — see the HIERARCHY CONTRACT
// in useSelectionBus.js. Configured before any panel can emit a filter.
bus.configureHierarchy(config)
// The single in-memory row store — one fetch on mount. (useDashboardData reads the shared
// @map/config/sources.json activeSource + getApiBaseUrl; config.data may carry overrides.)
const data = useDashboardData(config.data)
// THE single former-duplicated FACET_ACC, derived ONCE from config.facets (facetKey -> get).
const FACET_ACC = mapValues(config.facets, (f) => f.get)
// Free-text search (header-1 search bar) — ONE shared matcher (buildSearchAccessors +
// makeSearchMatcher), reused by every panel so the searchable text never drifts.
const matchSearch = makeSearchMatcher(bus, buildSearchAccessors(config))
// linked scope: rows surviving every active facet predicate AND the text search (panels fade the rest).
const filteredRows = computed(() => {
const rows = data.rows.value || []
const keys = Object.keys(bus.filterSet)
const hasText = !!(bus.searchTerm?.value || '').trim()
if (!keys.length && !hasText) return rows
const faceted = keys.length
? rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === bus.filterSet[k] : true }))
: rows
// Tiered search over the faceted scope: exact first, fuzzy only if exact finds nothing.
return filterRowsBySearch(faceted, matchSearch)
})
// linked KPI strip — recomputes on every brush.
const kpis = computed(() => computeKpis(config.kpis, filteredRows.value, data.rows.value || [], FACET_ACC, config))
// responsive flags + collapsible table dock + segmented mobile view.
const responsive = useResponsiveShell(bus)
// ── dataStore adapter (GAP B) — DataSourceManager-normalized (DASH-FIX-2) ──
// The header's GeocoderComponent bars (DOXA-LIB-3) run useEntitySearch over an
// injected `dataStore` to surface local people-group / country / language /
// religion / affinity-bloc / cluster / region suggestions. The MAP profiles get
// that dataStore from useMapData, which normalizes each raw API row through
// DataSourceManager — producing the rich feature shape useEntitySearch.buildIndex
// reads (countryName, affinityBlock[+Label], cluster[+Label], peopleGroup[+Label],
// wagfRegion/wagfBlock[+Label], religion[+Name], language, _raw).
//
// The previous hand-rolled adapter mapped only a few facet accessors, so its
// features were MISSING the affinity/cluster/PG/region label fields → the
// geocoder's blocs / clusters / people-groups / families / dialects aggregates
// never populated and the dashboard dropdown read WORSE than research-map's.
// FIX: normalize the dashboard's already-loaded raw rows through the SAME
// DataSourceManager the map uses (reusing the rows we already fetched — no second
// network request), so useEntitySearch builds an IDENTICAL index and the dropdown
// results CLONE research-map's. Keyed under the active source id + provided as
// `searchSourceId` so the bars pass :data-source-id (mirrors research-map's
// geocoder wiring, GeocoderComponent :data-source-id).
// Use the SAME source the rows were loaded from (data.sourceId — honors
// config.data.source, defaults to sourcesConfig.activeSource) so the geocoder's
// normalization config matches the loaded rows and the bars pass the matching
// :data-source-id. Never desyncs the row source from the search source.
const searchSourceId = data.sourceId || sourcesConfig.activeSource
const searchSourceCfg = sourcesConfig.sources?.[searchSourceId] || {}
const dsm = new DataSourceManager()
const geoFeatures = computed(() => {
const rows = data.rows.value || []
if (!rows.length) return []
// normalizeData() consumes AND frees its `rawData` (sets it to null when done);
// hand it a shallow copy so the dashboard's own row store is never mutated/cleared.
dsm.rawData = rows.slice()
dsm.normalizeData(searchSourceCfg)
return dsm.normalizedData
})
// Shape matches what useEntitySearch expects: dataStore.sources[id].features.
// `features` is a getter over the computed so useEntitySearch's reactive index
// rebuilds when the rows load.
const dataStore = { sources: { [searchSourceId]: { get features() { return geoFeatures.value } } } }
// selection > filter > all CSV exporter (clipboard-primary, sandbox-safe). `dataOpts` carries the
// profile's source so the exporter's lazy ALL-FIELDS fetch hits the SAME source the rows came from.
// `labelFor` resolves People CODES to names in the provenance COMMENT line only — constructed
// here, below `dataStore`, because the resolver reads the normalized features it exposes.
// Data columns are unaffected (see useCsvExport.buildCsv).
const { labelFor } = useTreeLegendLabels(config, dataStore, searchSourceId)
// shareable view-state hash URL -- below labelFor on purpose: the hash's display-only slugs
// resolve through the SAME code->name resolver the chips and the CSV provenance line use.
useHashSync(bus, labelFor)
const exporter = useCsvExport({ rowStore: data.rowStore, rows: data.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC, dataOpts: config.data, labelFor })
provide('selectionBus', bus)
provide('dashboardData', data)
provide('dashboardConfig', config)
provide('dataStore', dataStore)
provide('searchSourceId', searchSourceId)
onMounted(() => data.load())
return { bus, data, FACET_ACC, filteredRows, kpis, exporter, responsive }
}