388 lines
22 KiB
JavaScript
388 lines
22 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 './useDashboardData.js'
|
|
import { useCsvExport } from '@map/composables/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 (the agent 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 (the agent: "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.
|
|
// `,` 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 })
|
|
|
|
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 }
|
|
}
|