82 lines, one importer (useDashboardShell), and every import already @map/ — so it moves as-is with
no adapter and no shim. Its inject('profileConfig') stays inside its try/catch: it can be called from
outside setup, and that is the property the move had to preserve.
Source only. research-dashboard and dashboard-plus-grid need a rebuild.
338 lines
20 KiB
JavaScript
338 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, inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
import { createSelectionBus } from '@map/composables/useSelectionBus.js'
|
|
import { useDashboardData } from '@map/composables/useDashboardData.js'
|
|
import { useCsvExport } from '@map/composables/useCsvExport.js'
|
|
import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '@map/composables/useDashboardSearch.js'
|
|
import { formatCount } from '@map/utils/formatCount.js'
|
|
import { useTreeLegendLabels } from './useTreeLegendLabels.js'
|
|
import { useTreeLegendColors } from './useTreeLegendColors.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 ? '—' : formatCount(n))
|
|
|
|
// ── THE SHARED SEARCH LIVES IN library/composables/useDashboardSearch.js ─────────────────────
|
|
// It was extracted there by kit landing 4a and these three functions were left DEFINED here as
|
|
// well — code-identical copies, about 3KB, including the Fuse.js matcher. Nothing had diverged yet,
|
|
// but the comment that used to sit here said the whole point of ONE declaration is that "the
|
|
// searchable text can never drift from what the panels show", and two copies is how that drift
|
|
// starts. This file now imports them like every other consumer; the definitions live in one place.
|
|
/**
|
|
* 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.
|
|
// `,` joins the values of ONE key (D1: OR-within-key). It is reserved here for the same reason
|
|
// `;` `:` and `*` already are — a delimiter that can also occur inside a value is not a
|
|
// delimiter — so a literal comma in a facet value is percent-encoded before the join.
|
|
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]) => {
|
|
// A MULTI-VALUE KEY KEEPS THE CODE CANONICAL AND DROPS THE SLUG. The `*slug` is
|
|
// display-only and singular by construction — one human name for one code — so there is no
|
|
// honest slug for "these twenty-two codes". Writing one would put a label in a URL that
|
|
// does not describe what the URL selects, and the slug is already stripped on parse.
|
|
if (Array.isArray(v)) return k + ':' + v.map(escapeValue).join(',')
|
|
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 raw = pair.slice(i + 1).split('*')[0]
|
|
// Split BEFORE unescaping: a literal comma inside a value is percent-encoded by
|
|
// escapeValue, so every `,` still present here is a separator. Unescaping first would
|
|
// resurrect the content commas and split the value in half.
|
|
const parts = raw.split(',').map(unescapeValue).filter(Boolean)
|
|
if (key && parts.length) bus.setFilter(key, parts.length > 1 ? parts : parts[0])
|
|
}
|
|
} 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
|
|
// ONE predicate, owned by the bus (SPEC-DASHBOARD-KIT D1). This line was the seventh copy
|
|
// of `a(r) === filterSet[k]`; with multi-value filters the copies would have disagreed.
|
|
? rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC))
|
|
: 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] || {}
|
|
// It only normalizes rows already narrowed to the page's list (useDashboardData), but it carries the
|
|
// config like every other manager, so nothing it ever loads can skip the list.
|
|
let _pc = null
|
|
try { _pc = inject('profileConfig', null) } catch (_) { /* outside setup */ }
|
|
const dsm = new DataSourceManager({ profileConfig: _pc })
|
|
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)
|
|
// The exporter selects rows with the BUS's predicate, not a copy of it (D1).
|
|
|
|
// 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, rowMatches: bus.rowMatchesFilters })
|
|
|
|
// ★ THE RESEARCH LEGEND, HANDED TO THE PANELS AS DATA (kit landing 4b).
|
|
// The facet rail and the chip bar used to IMPORT useTreeLegendColors and useTreeLegendLabels
|
|
// directly. Those two files are the DOXA palettes and the People code tables — research-specific
|
|
// by this kit's own reckoning, and they stay in plugin-designs. A library component importing
|
|
// them would be a library-to-plugin dependency, which check:no-cross-plugin-imports rightly
|
|
// refuses, so the rail could never have become kit code by moving alone.
|
|
// So the dependency inverts: the panels declare what they need (two functions) and the SHELL
|
|
// supplies it. The shell is the right supplier because it is the only thing that already has the
|
|
// config, the dataStore and the source id these composables require, and because a composable
|
|
// cannot be called at config-module scope.
|
|
// It goes on dashboardConfig rather than through a new inject key, per section 5.1 of the spec: a
|
|
// panel that needs a new capability gets a new CONFIG key, never a new channel. Merged rather than
|
|
// assigned, so a profile that supplies its own `legend` wins and is not silently overwritten.
|
|
const { colorFor } = useTreeLegendColors()
|
|
if (!config.legend) config.legend = { colorFor, 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 }
|
|
}
|