516 lines
39 KiB
JavaScript
516 lines
39 KiB
JavaScript
/**
|
|
* dashboard.config.js — the ONE config object for the doxa-research-dashboard profile.
|
|
*
|
|
* This is the headline DOXA-LIB-2 proof: the FACET_ACC accessor map (formerly copy-pasted
|
|
* into 9 files) is declared ONCE here, under `facets`, and read everywhere via
|
|
* inject('dashboardConfig') + useDashboardShell. Columns / charts / facet-rail sections /
|
|
* KPIs / detail fields are likewise data, not code — the library panels render them
|
|
* generically. Values are copied faithfully from the original research-dashboard so the
|
|
* migrated bundle behaves identically.
|
|
*/
|
|
import { lbl } from './src/composables/useDashboardAggregations.js'
|
|
import { tStatic } from '@map/i18n/index.js'
|
|
import { RELIGION_FAMILIES } from '@map/colors/religion.js'
|
|
import { canonicalFamilyName } from '@map/colors/language-family.js'
|
|
import langFamilyByIso from '@map/data/langFamilyByIso.json'
|
|
import { getPrayerLevel, LABELS as PRAYER_LABELS } from '@map/colors/prayer-progress.js'
|
|
|
|
// ★ EVERY LABEL IN THIS CONFIG WAS ENGLISH IN ALL ELEVEN LOCALES. Measured under ?lang=ru on Big
|
|
// Picture: the facet rail, chart titles, KPI strip and table headers read Region / Bloc / Country /
|
|
// Religion while the map beside them was fully translated.
|
|
// They are declared with GETTER SYNTAX (`get label() {...}`) rather than resolved values, because
|
|
// this module is imported ONCE at load while the locale is only known later (?lang=, the host's
|
|
// profile-config, <html lang>). A getter resolves on every read, so one config object serves
|
|
// whichever locale asks — and note that SPREADING a getter would not work: the spread copies the
|
|
// resolved value, not the accessor, which is the trap this comment exists to stop.
|
|
// tStatic, not useI18n: plain config, no component instance, and mounts that install no i18n.
|
|
|
|
// same numeric coercion the DataTable uses for its num columns
|
|
const num = (v) => { if (typeof v === 'number') return v; const n = parseFloat(String(v ?? '').replace(/[^0-9.\-]/g, '')); return isNaN(n) ? null : n }
|
|
|
|
// affinity fields arrive on the RAW pray-tools row as {value,label} objects
|
|
// (rop1 / imb_reg_of_people_2 / imb_reg_of_people_25 — the same objects DataSourceManager
|
|
// splits into affinityBlock+affinityBlockLabel / cluster+clusterLabel / peopleGroup+peopleGroupLabel).
|
|
// The People facet accessors return the stable CODE (value) for identity/filtering; the human
|
|
// name is resolved in DashboardFacetRail via useAffinityBlockLegendData — the SAME resolver the
|
|
// research-map's semantic tree uses — so bloc/cluster/PG labels can never drift from the map.
|
|
const code = (v) => (v && typeof v === 'object' ? (v.value ?? v.label ?? '') : (v ?? ''))
|
|
|
|
export default {
|
|
// ── identity ──
|
|
id: 'doxa-research-dashboard',
|
|
// "Big Picture Research Dashboard" (the agent 2026-09-20). The share button embeds THIS whole
|
|
// element on a partner site, so the title is what a visitor reads there — it has to name the
|
|
// product, not just the page. "Big Picture" is also the cap-4 composite's name and tab-1 of the
|
|
// cap-5 SPA, so one term now runs consistently from the tab through to an embedded partner view.
|
|
get title() { return tStatic('dashboard.dashboard_title') || 'Big Picture Research Dashboard' },
|
|
element: 'doxa-research-dashboard',
|
|
|
|
// ── data source ── (consumed by useDashboardData; the arg is currently advisory —
|
|
// the store reads @map/config/sources.json activeSource = pray-tools, preserving behavior)
|
|
data: {
|
|
source: 'pray-tools',
|
|
primaryKey: 'slug',
|
|
},
|
|
|
|
// ── facet accessors ── THE single former-duplicated FACET_ACC, declared ONCE.
|
|
facets: {
|
|
// ── PRAYER (card BUILD-ADD-A-PRAYER-FILTER-FIRST-IN-THE-ROW) ──
|
|
// `people_praying` is a LIVE per-row pray-tools field, already read by three shipping
|
|
// consumers in this very config: the KPI sum (kpis 'praying'), the table column
|
|
// ('Praying', num(r.people_praying)) and detail.fields. No derived boolean was invented.
|
|
//
|
|
// It is a COUNT, not a flag, and the app already splits that count THREE ways in
|
|
// library/colors/prayer-progress.js (the prayer map's colour strategy):
|
|
// none (0/null) · partial (0 < n < 144) · full (n >= 144)
|
|
// Three levels => a DROPDOWN. We reuse getPrayerLevel + its LABELS instead of re-deriving
|
|
// the split, so the filter can never disagree with the colour of the pin it filters to —
|
|
// one threshold, one owner.
|
|
//
|
|
// ★ WHY THIS ACCESSOR MUST LIVE IN THE CONFIG AND NOT IN THE ADAPTER. useDashboardShell
|
|
// builds FACET_ACC from `config.facets` ALONE, and filteredRows FAILS OPEN on a key it
|
|
// cannot resolve (`return a ? a(r) === filterSet[k] : true`). A facet defined only in
|
|
// DashboardResearchFilters.vue therefore renders a working dropdown, lands a chip, and
|
|
// filters NOTHING — silently. A filter that filters nothing is worse than no filter,
|
|
// because the user reads the chip as proof the scope narrowed. The adapter keeps a local
|
|
// default and merges config-first, so THIS definition is the one that takes effect.
|
|
//
|
|
// Returns the human LABEL, which is both the display text and the bus value: the same
|
|
// value-is-label contract every non-People axis follows.
|
|
prayer_level: {
|
|
get label() { return tStatic('dashboard.prayer') || 'Prayer' },
|
|
get: (r) => {
|
|
const n = r?.people_praying
|
|
const raw = n && typeof n === 'object' ? n.value : n
|
|
return PRAYER_LABELS[getPrayerLevel({ peoplePraying: raw ?? null })]
|
|
},
|
|
},
|
|
religion: { get label() { return tStatic('dashboard.religion') || 'Religion' }, get: (r) => lbl(r.religion) },
|
|
// ── Religion TIER 1 (card BUILD-STACKED-TREE-LEGEND) ──
|
|
// The Doxa API's religion code is 1-3 chars whose FIRST LETTER is the family
|
|
// ('MSN' → 'M' → Islam), which is precisely how the cap-3 map paints religion pins
|
|
// (library/colors/religion.js:104 slices char 0). Naming the family here turns Religion
|
|
// from a flat list of ~25 codes into the 2-tier tree the other three axes already are.
|
|
//
|
|
// Returns the human family NAME, not the letter: the value an accessor returns is BOTH the
|
|
// display label and the bus filter value, and codes must never reach a user-facing menu
|
|
// (same rule the People axis follows). A separate facet key — not a second accessor on
|
|
// `religion` — because the bus matches on the accessor's exact output, so one key = one
|
|
// projection keeps the byte-match contract intact.
|
|
// Measured live (2,107 rows, 2026-09-20): 10 families, all named in RELIGION_FAMILIES,
|
|
// family totals sum exactly to the leaf-code totals.
|
|
religion_family: {
|
|
get label() { return tStatic('dashboard.religion_family') || 'Religion Family' },
|
|
// ★ NO LONGER dashboard-only (2026-09-23). This facet's value is DERIVED ("Hinduism" from
|
|
// the religion code's first letter), which for months meant no grid row field could match
|
|
// it -- the honest stopgap was a dashboardOnly flag the rail rendered as "updates this
|
|
// dashboard only". The real fix landed instead: people-groups-grid runs the SAME
|
|
// derivation (DERIVED_AXES, importing the same RELIGION_FAMILIES map this file reads), so
|
|
// the family name the bus carries now byte-matches a value the grid computes per row.
|
|
get: (r) => {
|
|
const code = r.religion && typeof r.religion === 'object' ? r.religion.value : r.religion
|
|
const c = String(code || '').charAt(0).toUpperCase()
|
|
return c && RELIGION_FAMILIES[c] ? RELIGION_FAMILIES[c] : ''
|
|
},
|
|
// The family LETTER, used only to look up the map's religion palette. Never displayed,
|
|
// never sent to the bus.
|
|
famKey: (r) => {
|
|
const code = r.religion && typeof r.religion === 'object' ? r.religion.value : r.religion
|
|
const c = String(code || '').charAt(0).toUpperCase()
|
|
return RELIGION_FAMILIES[c] ? c : ''
|
|
},
|
|
},
|
|
wagf_region: { get label() { return tStatic('dashboard.region') || 'Region' }, get: (r) => lbl(r.wagf_region) },
|
|
// "Bloc" (NOT "Block") — a bloc is a SUBSET of a region, per the WAGF place hierarchy
|
|
// region > bloc > country. Display label only; the facet KEY stays `wagf_block` because the
|
|
// API field, the fieldMappings, and every consumer prop (wagfBlock) are spelled that way.
|
|
wagf_block: { get label() { return tStatic('dashboard.bloc') || 'Bloc' }, get: (r) => lbl(r.wagf_block) },
|
|
country_code: { get label() { return tStatic('dashboard.country') || 'Country' }, get: (r) => lbl(r.country_code), geoIso: (r) => r.country_code && r.country_code.value },
|
|
engagement_status: { get label() { return tStatic('dashboard.status') || 'Status' }, get: (r) => lbl(r.engagement_status) },
|
|
// ── THE PHASES OF ENGAGEMENT ──────────────────────────────────────────────────────────
|
|
// `engagement_status` above is a BINARY flag — measured live over 2,108 rows it is 2,081
|
|
// Unengaged / 27 Engaged. imb_gsec is the ordinal ladder underneath it (present 2,106/2,108):
|
|
// 0 No Evangelicals, Churches or Resources ....... 225
|
|
// 1 Less than 2% Evangelical, No Active CP ..... 1,853
|
|
// 2 Less than 2% Evangelical, Concentrated CP ...... 23
|
|
// 4 2% or Greater but Less than 5% Evangelical ...... 4
|
|
// 6 10% or Greater Evangelical ...................... 1
|
|
// Cross-tabbed, ALL 225 groups with no evangelicals, churches or resources are merely
|
|
// "Unengaged" under the flag — indistinguishable from 1,841 groups that have some presence.
|
|
//
|
|
// A REAL row field, so unlike language_family/religion_family this one CAN move the grid: 5
|
|
// distinct {value,label} pairs with no value-vs-label cross-collision ('0' never reads as a
|
|
// label), which is the same safety property that made country_code and cluster wireable.
|
|
gospel_access: { get label() { return tStatic('dashboard.gospel_access') || 'Gospel Access' }, get: (r) => lbl(r.imb_gsec) },
|
|
// ── Language TIER 1 — API value FIRST, ISO lookup as FALLBACK ──
|
|
// MEASURED on the live API (2,107 rows, 2026-09-20): `imb_language_family` is null on
|
|
// 2,073 of them — it is populated for just 34 rows (1.6%). Read alone, the Language tree
|
|
// (one of the agent's four axes) renders as a near-empty stub and looks broken.
|
|
//
|
|
// `primary_language` IS populated on 100% of rows and its `.value` is an ISO-639-3 code
|
|
// ('abf'), and the library already ships @map/data/langFamilyByIso.json (815 entries).
|
|
// Resolving through it lifts coverage from 34 → 2,031 rows (1.6% → 96.4%).
|
|
//
|
|
// The API's own value stays AUTHORITATIVE and is only fallen back on when absent — verified
|
|
// safe: across every row where BOTH exist, the two agree 8/8 with 0 disagreements, so the
|
|
// fallback never contradicts upstream, it only fills gaps.
|
|
//
|
|
// canonicalFamilyName() case-folds onto the palette's spelling (language-family.js:150) so
|
|
// the value this returns is both the display label AND a direct palette key — one projection,
|
|
// byte-match contract intact.
|
|
language_family: {
|
|
get label() { return tStatic('dashboard.language_family') || 'Language Family' },
|
|
// ★ NO LONGER dashboard-only (2026-09-23) — same arc as religion_family above: the value
|
|
// is DERIVED (imb_language_family label, else langFamilyByIso over the ISO code), and the
|
|
// grid now runs the SAME derivation from the SAME @map modules (DERIVED_AXES), so the
|
|
// Language tree's top tier drives the grid like every fetched axis.
|
|
get: (r) => {
|
|
const api = lbl(r.imb_language_family)
|
|
if (api) return canonicalFamilyName(api)
|
|
const iso = r.primary_language && typeof r.primary_language === 'object' ? r.primary_language.value : null
|
|
const hit = iso && langFamilyByIso[iso]
|
|
return hit ? canonicalFamilyName(hit) : ''
|
|
},
|
|
},
|
|
primary_language: { get label() { return tStatic('dashboard.language') || 'Language' }, get: (r) => lbl(r.primary_language) },
|
|
// People tier accessors — key by CODE (DashboardFacetRail resolves code→human name via
|
|
// useAffinityBlockLegendData). Mirrors research-map's affinity-block → cluster → PG hierarchy.
|
|
//
|
|
// ── ONE AXIS, ONE FACET (card BUG-PEOPLE-AND-AFFINITY-BLOC-ARE-THE-SAME-AXIS-TWICE) ──
|
|
// `affinity_bloc` is THE People axis. It used to have a twin: a second facet keyed `rop1`
|
|
// reading lbl(r.rop1) — the LABEL of the very same field this reads the CODE of. Two facet
|
|
// keys over ONE row field meant two controls for one axis (a "People" dropdown beside an
|
|
// "Affinity Bloc" tree tier) that could never recognise each other: the tree stored
|
|
// affinity_bloc='A012' while the dropdown stored rop1='South Asian Peoples', and
|
|
// filteredRows byte-matches each key against its OWN accessor, so neither ever saw the
|
|
// other's selection. the agent, from behaviour: "there are duplicate affinity blocks, one
|
|
// called People, one called Affinity Bloc… the People filter IS by affinity block. So they
|
|
// are the same thing." He is right — the People filter always WAS the affinity-bloc filter.
|
|
//
|
|
// THE RULE THIS RESTORES: one axis = one filterable facet key. The CODE won as canonical
|
|
// because every other People tier (cluster / people_group) already keys by code, and
|
|
// FACET_TO_PROP in dashboard-plus-grid.vue already mapped BOTH keys onto the one grid prop
|
|
// `rop1` — the grid had already been treating them as a single thing.
|
|
//
|
|
// `optionLabel` is how the dropdown keeps showing HUMAN NAMES while STORING codes ("codes
|
|
// must never reach a user-facing menu" — still true). It is a DISPLAY projection of the
|
|
// same row, so option.value=code('A012') byte-matches the accessor while option.label
|
|
// reads 'South Asian Peoples'. Both come off the SAME rop1 object on the SAME record, so
|
|
// they cannot drift the way a cross-source name lookup could. SearchFilterHeader already
|
|
// supports value≠label (it resolves a chip's text via `o.value === f.value`).
|
|
affinity_bloc: {
|
|
get label() { return tStatic('dashboard.people') || 'People' },
|
|
get: (r) => code(r.rop1),
|
|
optionLabel: (r) => lbl(r.rop1),
|
|
},
|
|
cluster: { get label() { return tStatic('dashboard.cluster') || 'Cluster' }, get: (r) => code(r.imb_reg_of_people_2) },
|
|
people_group: { get label() { return tStatic('dashboard.people_group') || 'People Group' }, get: (r) => code(r.imb_reg_of_people_25) },
|
|
// SEARCH-ONLY projection of the People name. NOT an axis: no control list names it
|
|
// (`facetRail.drill`, `charts[].facet` and `researchFilters.dropdowns` all name
|
|
// `affinity_bloc`), and nothing enumerates `facets` to render a control — every consumer
|
|
// looks a key UP. It exists solely so free-text search keeps matching "South Asian" rather
|
|
// than the code 'A012' (buildSearchAccessors resolves searchKeys through facets first).
|
|
// Never add this key to a control list: that would reintroduce the duplicate axis.
|
|
rop1_name: { get label() { return tStatic('dashboard.people_name') || 'People (name)' }, get: (r) => lbl(r.rop1) },
|
|
},
|
|
|
|
// ── facet rail sections ── (DashboardFacetRail) — ONE centralized semantic tree mirroring the
|
|
// research-map's tree, top→bottom: People → Place → Language → Religion → Status.
|
|
// `tree:'affinity'` tells the rail to resolve the People codes to human names via the shared
|
|
// useAffinityBlockLegendData composable (single source of truth with the map's semantic tree).
|
|
// ── THE FOUR STACKED TREES (card BUILD-STACKED-TREE-LEGEND) ──
|
|
// the agent: "if you had a team of people trying to fill the globe with the gospel… they need to be
|
|
// able to search by people, place, language, religion, and find who has the gospel resources and
|
|
// who doesn't." The legend is how a researcher navigates those FOUR axes, in THIS order.
|
|
//
|
|
// `axis` names which cap-3 colour strategy paints the tree (see useTreeLegendColors.js) — the
|
|
// legend reads the map's palettes, so the two can never drift. Status is NOT one of the four
|
|
// axes; it stays below them as a flat utility list (`axis:null` = uncoloured), so the four
|
|
// trees read as the stack the agent asked for rather than as five peers.
|
|
facetRail: [
|
|
{ id: 'people', get title() { return tStatic('dashboard.people') || 'People' }, axis: 'people', drill: ['affinity_bloc', 'cluster', 'people_group'], tree: 'affinity', topN: [30, 40, 60] },
|
|
{ id: 'place', get title() { return tStatic('dashboard.place') || 'Place' }, axis: 'place', drill: ['wagf_region', 'wagf_block', 'country_code'] },
|
|
{ id: 'language', get title() { return tStatic('dashboard.language') || 'Language' }, axis: 'language', drill: ['language_family', 'primary_language'] },
|
|
// Religion was the odd flat axis; it is now the 2-tier tree the data always supported
|
|
// (family → religion). See the `religion_family` facet above.
|
|
{ id: 'religion', get title() { return tStatic('dashboard.religion') || 'Religion' }, axis: 'religion', drill: ['religion_family', 'religion'], topN: [12, 24] },
|
|
// ★ THE 'Status' SECTION IS REMOVED ON DRIVER INSTRUCTION 2026-09-23 ("dont add the engable
|
|
// filter or status filter"). It rendered engagement_status as a rail section — 2 rows, Unengaged
|
|
// 2,081 / Engaged 27 — which is a status filter by another name.
|
|
//
|
|
// NOT DELETED, only unlisted: `engagement_status` survives as a facet accessor, a table column
|
|
// and a FACET_TO_PROP mapping, so the grid can still filter on it and nothing downstream breaks.
|
|
// Re-add one line here to bring the section back.
|
|
//
|
|
// Gospel Access STAYS. It is a different question from engagement status and the two are
|
|
// measurably independent: over 2,108 live rows they DISAGREE on 25 — 12 groups are "Engaged" at
|
|
// gsec rung 1 and 13 are "Unengaged" at rung 2 — so this is not the status filter wearing a new
|
|
// label. Its own section rather than a drill under anything: the ladder is not a sub-division of
|
|
// the engagement flag, and 5 rows do not need a topN cap.
|
|
{ id: 'gospel-access', get title() { return tStatic('dashboard.gospel_access') || 'Gospel Access' }, axis: null, facet: 'gospel_access', topN: 8 },
|
|
],
|
|
|
|
// ── KPI strip ── (computed by useDashboardShell; mirrors the original 6 stat computeds)
|
|
kpis: [
|
|
{ id: 'total', get label() { return tStatic('dashboard.upgs_in_scope') || 'UPGs in scope' }, kind: 'count', showDelta: true },
|
|
{ id: 'pop', get label() { return tStatic('dashboard.sum_population') || 'Σ population' }, kind: 'sum', field: 'population' },
|
|
// ★ NO '%' IN A `kind:'pct'` LABEL. kpiText() returns k.value unchanged for pct, and that
|
|
// value ALREADY carries its percent sign — so a label that also carries one renders
|
|
// "99% % UNREACHED". Found by reading the rendered screenshot, not by reading the code:
|
|
// each half looks correct in isolation and only the composition is wrong, which is why it
|
|
// survived review. The label says WHAT is measured; the value says how much.
|
|
{ id: 'unreached', get label() { return tStatic('dashboard.unreached') || 'unreached' }, kind: 'pct',
|
|
test: (r) => !/engaged/i.test(lbl(r.engagement_status)) || /unengaged/i.test(lbl(r.engagement_status)) },
|
|
{ id: 'countries', get label() { return tStatic('dashboard.countries_touched') || 'countries touched' }, kind: 'distinct', field: 'country_code', via: 'geoIso' },
|
|
{ id: 'praying', get label() { return tStatic('dashboard.sum_praying') || 'Σ praying' }, kind: 'sum', field: 'people_praying' },
|
|
{ id: 'adopted', get label() { return tStatic('dashboard.adopted') || 'adopted' }, kind: 'pct', test: (r) => Number(r.adopted_by_churches) > 0 },
|
|
],
|
|
|
|
// ── table columns ── (DashboardDataTable)
|
|
//
|
|
// `w` = the column's FLEX WEIGHT (default 1). DashboardDataTable renders each cell with
|
|
// `flex: <w> 1 0`, so the row still fills the width at any viewport — the weights only change
|
|
// how the space is SHARED.
|
|
//
|
|
// WHY THIS EXISTS — measured on the live dashboard, not guessed. Every column was `flex:1 1 0`,
|
|
// which means "ignore content, split evenly": with flex-basis 0 the browser never consults
|
|
// intrinsic width, so a 7-character number column claimed exactly as much room as a people-group
|
|
// name. Measured at 1440px every column was 106px wide and "People Group" was ELLIPSISED while
|
|
// "Praying" had room to spare; at 1280px (86px each) THREE headers were cut — People Group,
|
|
// Population and Engagement. The data is worse than the headers: names run to
|
|
// "North America & Non-Spanish Caribbean", far longer than any label.
|
|
//
|
|
// ★ THE WEIGHTS ARE DERIVED FROM MEASUREMENT, AND MY FIRST ATTEMPT WAS WRONG — recording why.
|
|
// I first guessed from DATA width (numbers are short, so w:0.8) and made it WORSE: truncation went
|
|
// 1 -> 2 at 1440px. The binding constraint is the HEADER LABEL, not the values. 'Population' is a
|
|
// 10-character header whose values read '12,500'; the header sets the floor.
|
|
//
|
|
// Measured NEED per column (scrollWidth of the header cell, which includes its padding), then each
|
|
// string column bumped for long DATA (names reach 'North America & Non-Spanish Caribbean',
|
|
// religions 'Ethnoreligion - Animism'), then normalised so the weights average 1.0:
|
|
// People Group 144 · Country/Region/Language/Religion 112 · Engagement 104 · Population 92 · Praying 71
|
|
// Total need 859px against ~878px of column space at 1440 — so it FITS, and the old even split was
|
|
// simply spending the width in the wrong places. No padding or type-size change was needed.
|
|
//
|
|
// Deliberately NOT pixel widths: a fixed px column breaks at a viewport you did not test, while a
|
|
// weight degrades proportionally everywhere.
|
|
columns: [
|
|
{ key: 'name', get label() { return tStatic('dashboard.people_group') || 'People Group' }, get: (r) => r.name || '', type: 'str', w: 1.34 },
|
|
{ key: 'country', get label() { return tStatic('dashboard.country') || 'Country' }, get: (r) => lbl(r.country_code), type: 'str', w: 1.04 },
|
|
{ key: 'region', get label() { return tStatic('dashboard.region') || 'Region' }, get: (r) => lbl(r.wagf_region), type: 'str', w: 1.04 },
|
|
{ key: 'language', get label() { return tStatic('dashboard.language') || 'Language' }, get: (r) => lbl(r.primary_language), type: 'str', w: 1.04 },
|
|
{ key: 'religion', get label() { return tStatic('dashboard.religion') || 'Religion' }, get: (r) => lbl(r.religion), type: 'str', w: 1.04 },
|
|
{ key: 'population', get label() { return tStatic('dashboard.population') || 'Population' }, get: (r) => num(r.population), type: 'num', w: 0.86 },
|
|
{ key: 'people_praying', get label() { return tStatic('dashboard.praying') || 'Praying' }, get: (r) => num(r.people_praying), type: 'num', w: 0.66 },
|
|
{ key: 'engagement', get label() { return tStatic('dashboard.engagement') || 'Engagement' }, get: (r) => lbl(r.engagement_status), type: 'str', w: 0.97 },
|
|
],
|
|
|
|
// ── map ── (DashboardMapPanel)
|
|
// `paper` = "near-white minimal — data reads like print". The style registry
|
|
// (library/styles/registry.js) already listed `research-dashboard` as this style's own
|
|
// `bestFor` target, and already resolves the name on BOTH engines — Mapbox light-v11 and
|
|
// the keyless MapLibre Positron. It was simply never set here, so the panel silently took
|
|
// each engine's default instead. CARTO ships Positron expressly as the basemap "good for
|
|
// POINT data", which is exactly this map's job, so this is an alignment, not a new choice.
|
|
// One line, and it survives the MapLibre swap unchanged.
|
|
map: {
|
|
// ── PROVIDER: MapLibre ───────────────────────────────────────────────────────────────────
|
|
// DRIVER 2026-09-22: "the map on the big picture to be MapLibre"; and 2026-09-23 on the
|
|
// terms — "keep every Mapbox component working so we don't break Mapbox compatibility — we
|
|
// just want interoperability, so we can come back to Mapbox if this breaks things."
|
|
//
|
|
// ★ THIS ONE KEY IS THE WHOLE SWAP, AND THAT IS THE POINT. DashboardMapPanel reads it
|
|
// (`cfg.provider === 'maplibre' ? 'maplibre' : 'mapbox'`), useMapInstance loads the matching
|
|
// engine + CSS, and registry.js resolves the style name on whichever engine is live. Delete
|
|
// this line — or set it to 'mapbox' — and the dashboard is byte-for-byte what it was before,
|
|
// with the Mapbox token still wired and no other file to restore. Reversibility is not a
|
|
// claim here; it is the shape of the change. (Proven by flipping it and rebuilding, not
|
|
// asserted — see the verification note in the commit.)
|
|
//
|
|
// WHY IT COSTS NOTHING TO STAY DUAL: MapLibre ignores accessToken rather than rejecting it,
|
|
// so the Mapbox credential stays threaded through untouched while MapLibre is live.
|
|
// ★ MAPBOX FOR THIS MAP, BY THE DRIVER'S OWN DESCRIPTION (2026-09-23). Every wording of the
|
|
// ask pointed at one thing: "like the Mapbox map", "a mirror or clone of Mapbox's globe space
|
|
// style with the stars", "water like Mapbox style". Mapbox Standard's globe NATIVELY renders
|
|
// the starfield, the atmosphere and that water -- no clone can beat the original, and we hold
|
|
// a working baked token (the agent's explicit ruling accepted it as public). MapLibre support
|
|
// stays fully built and reversible: flip this one key back and the keyless VersaTiles path
|
|
// returns. Interoperability was the requirement; it is intact.
|
|
provider: 'mapbox',
|
|
|
|
// 'galaxy' = "deep-space ground, blue oceans" (library/styles/registry.js).
|
|
// DRIVER 2026-09-23: "the map style needs to have the BLUE WATER with like the BLACK GALAXY
|
|
// background". It resolves on BOTH engines — MapLibre's keyless OpenFreeMap dark and Mapbox
|
|
// dark-v11 — each patched to the same blue water at style.load, so flipping `provider` above
|
|
// does not change how the map LOOKS, only which engine draws it. That is what makes the
|
|
// reversibility test above meaningful rather than cosmetic.
|
|
// Was 'paper' (near-white minimal) until this change.
|
|
mapStyle: 'galaxy',
|
|
|
|
// ── HEARTBEAT ZONES ──────────────────────────────────────────────────────────────────────
|
|
// A HEARTBEAT ZONE is a country holding the most people groups — where the concentration of
|
|
// unreached peoples is densest. It is the TOP bin of the choropleth ramp, and the whole point
|
|
// of the map: "the heartbeat zones are the most people groups in them."
|
|
//
|
|
// ★ WHY THIS IS A VARIABLE AND NOT A CONSTANT. 150 is not a property of choropleths; it is a
|
|
// property of THIS DATASET — ~2000 people groups in focus across ~120 countries. Ported to a
|
|
// dataset of 200 groups, a 150 cutoff puts EVERY country in the bottom bin and the map renders
|
|
// uniformly pale: not an error, not a crash, just a dashboard that says nothing. That failure
|
|
// is invisible to every existing gate, because the code is perfectly correct — the ASSUMPTION
|
|
// is what moved. the agent: "that filter of 150 ... should be a variable. It shouldn't be a
|
|
// static ... I need to be able to reuse this dashboard for other maps, other data sets."
|
|
//
|
|
// So the thresholds are declared HERE, in the dashboard's own config, where every other
|
|
// per-dataset fact already lives (facets, columns, kpis). Re-pointing this dashboard at a new
|
|
// dataset is a config edit, not a source edit.
|
|
//
|
|
// WHAT EACH KEY DOES
|
|
// heartbeatThreshold — the count at which a country BECOMES a heartbeat zone (top bin).
|
|
// Change this alone and the other four bin edges rescale with it,
|
|
// keeping the ramp's shape. This is the one number to turn.
|
|
// binThresholds — OPTIONAL full control: the four lower edges, ascending. Supply this
|
|
// only to override the derived rescale; the top bin is always
|
|
// heartbeatThreshold..Infinity.
|
|
// heartbeatMode — 'fixed' (default) uses heartbeatThreshold as an ABSOLUTE count.
|
|
// 'quantile' DERIVES the edges from the data actually loaded (see
|
|
// heartbeatQuantile). Opt-in: it changes the ramp per dataset, which is
|
|
// the right behaviour for an unknown dataset and the WRONG behaviour
|
|
// for this one, where 150 is a deliberate, the agent-chosen number.
|
|
// heartbeatQuantile — with mode 'quantile', the share of non-empty countries ABOVE the
|
|
// heartbeat edge. 0.1 = "the top 10% of countries are heartbeat zones".
|
|
//
|
|
// The DEFAULTS below reproduce the historical ramp EXACTLY (5/20/60/150/Infinity) — verified
|
|
// by internal/check-heartbeat-threshold.mjs, which replays the bin assignment and fails if a
|
|
// default edit moves it.
|
|
heartbeatThreshold: 150,
|
|
binThresholds: [5, 20, 60],
|
|
heartbeatMode: 'fixed',
|
|
heartbeatQuantile: 0.1,
|
|
|
|
// ── COLOUR RAMP — a NAME, resolved in DashboardMapPanel.vue's RAMP_PRESETS ──────────────
|
|
// Same pattern as `mapStyle` above: a name in config, not a URL/hex-array a host has to
|
|
// construct. 'heartbeat-red' is the shipped default (unchanged if this key is omitted).
|
|
// 'greyscale' is the agent's requested swappable alternative ("grey scale as a backdrop
|
|
// from grey to black, just in case I need to swap from red to black scale") — set this to
|
|
// 'greyscale' to swap the whole choropleth, legend included, with no other edit required.
|
|
// ★ SWITCHED TO GREYSCALE 2026-09-23 on the agent's ruling: "instead of the red, we need to
|
|
// use the grey scale from grey to black". He had asked for this preset to exist earlier as a
|
|
// swappable alternative; this is him taking the swap. One key, and the choropleth AND its
|
|
// legend both move — which is why it was built as config rather than scattered hex.
|
|
// The ramp is ['#e8e8e8','#a9a9a9','#6f6f6f','#3a3a3a','#060606'] (DashboardMapPanel.vue:467),
|
|
// measured worst-case pin visibility 1.96 vs red's 1.84 — see
|
|
// internal-tracker/01-ideation-research/RESEARCH-map-colour-system-2026-09-23.md, which also notes
|
|
// the real fix for pin contrast is the lightness-conditional STROKE, independent of the ramp.
|
|
colorRamp: 'greyscale',
|
|
},
|
|
|
|
// ── charts ── (DashboardChartsRail)
|
|
charts: [
|
|
// ★ HIGH-LEVEL RELIGIONS ONLY (the agent 2026-09-23: "it should not mention Ethnoreligion and
|
|
// Islam multiple times, just once -- just the high-level religions, not broken down into
|
|
// smaller categories"). The rollup ALREADY EXISTED as the religion_family facet ("Islam",
|
|
// "Hinduism", "Ethnoreligion" -- one row each); the chart was simply pointed at the
|
|
// fine-grained `religion` facet ("Islam - Sunni", "Islam - Folk", ...). One word moves it.
|
|
// religion_family is dashboardOnly by design (a derived value no row field carries), so a
|
|
// slice click narrows the DASHBOARD but not the grid below -- documented behaviour, and for
|
|
// a glanceable donut that is the right trade. The full breakdown still lives in the facet
|
|
// rail's Religion tree for anyone who needs Sunni vs Folk.
|
|
{ id: 'religion', type: 'donut', facet: 'religion_family', get title() { return tStatic('dashboard.religion') || 'Religion' }, topN: 8 },
|
|
{ id: 'region', type: 'donut', facet: 'wagf_region', get title() { return tStatic('dashboard.region') || 'Region' }, topN: 9 },
|
|
{ id: 'country', type: 'bar', facet: 'country_code', title: 'Top countries', topN: 12 },
|
|
],
|
|
|
|
// ── detail drawer ── fields surfaced for a focused record
|
|
detail: {
|
|
photo: (r) => r.has_photo && r.image_url,
|
|
fields: ['religion', 'primary_language', 'people_praying', 'adopted_by_churches', 'engagement_status'],
|
|
},
|
|
|
|
// ── research filters (DX-life clone) ── header-1 search bar + header-2 expand-filters
|
|
// dropdown SET, baked via DashboardResearchFilters → the shared @map SearchFilterHeader.
|
|
// `dropdowns` = the facet keys rendered as dropdowns (each returns a LABEL the bus matches on,
|
|
// so a pick byte-matches filteredRows); `searchKeys` = the facet keys the free-text search
|
|
// matches across (mirrors DX-life's Fuse keys: name/place/region/language/religion — here the
|
|
// name comes from the `columns` string set and these facet labels). One horizontal row,
|
|
// horizontal-scroll on overflow (card BUILD-DROPDOWNS-HORIZONTAL-SCROLL-ONE-ROW).
|
|
// ── AXIS ORDER: People · Place · Language · Religion ──
|
|
// Place expands to its three tiers region > bloc > country, so the row reads
|
|
// [People] [Region] [Bloc] [Country] [Language] [Religion].
|
|
// People (`rop1`) was MISSING entirely (card BUILD-FILTER-PEOPLE-AXIS-MISSING) — the data was
|
|
// always there (it is a defaultField on the pray-tools source and DX-life filtered on it), only
|
|
// the dropdown was dropped when this list was first written.
|
|
researchFilters: {
|
|
// People = `affinity_bloc`, the SAME key the tree's top tier filters on, so a pick here and
|
|
// a click there are one selection (card BUG-PEOPLE-AND-AFFINITY-BLOC-ARE-THE-SAME-AXIS-TWICE).
|
|
// It stores the code and displays the name via the facet's `optionLabel`.
|
|
// `prayer_level` listed FIRST so the config owns the order rather than the adapter forcing
|
|
// it. DashboardResearchFilters de-duplicates on that key, so naming it here cannot render
|
|
// it twice — and if the adapter's forcing is ever removed, the order still holds.
|
|
// ★ EIGHT dropdowns. The agent named them: "people, region, block, country, language, religion,
|
|
// prayer, engagement" — and separately asked for prayer FIRST. engagement_status was the one
|
|
// missing: it was already fully built (label + getter at :112, the Status tree section at :201,
|
|
// the Engagement table column at :256) and simply never registered here, so the rail rendered
|
|
// SEVEN. Measured live before this change: 7 filter buttons (Prayer, People, Region, Bloc,
|
|
// Country, Language, Religion).
|
|
//
|
|
// ★ CORRECTED 2026-09-23 — this note used to say "GSEC is the underlying field the agent named
|
|
// for 'the phases of engagement' — engagement_status is the label derived from it." IT IS NOT
|
|
// DERIVED FROM IT, and believing so is why the phases stayed unbuilt: the dropdown looked like it
|
|
// already covered the ask. Measured on 2,108 live rows, the two fields DISAGREE — 12 groups are
|
|
// "Engaged" while sitting at gsec rung 1, and 13 are "Unengaged" at rung 2. They are independent
|
|
// fields, and engagement_status is binary (2,081 / 27) where imb_gsec has five rungs.
|
|
//
|
|
// The phases now have their own facet (`gospel_access`, reading imb_gsec) plus a Gospel Access
|
|
// tree section, and they reach the grid through FACET_TO_PROP.
|
|
//
|
|
// NOT a ninth dropdown, and the reason is the agent's list, NOT layout. Measured at both 1440
|
|
// and 1280: dropdowns are 168px on a 178px pitch, seven fit the first row (right edge 1250) and
|
|
// the eighth wraps, with NO horizontal scrollbar at either width. A ninth would simply join the
|
|
// second row — so the layout would tolerate it. The list stays at the eight the agent named;
|
|
// the ladder is reachable through the tree rail, which is where a five-rung ordinal reads better
|
|
// than a dropdown anyway.
|
|
//
|
|
// ★ NO HORIZONTAL SCROLLBAR is the constraint that goes with the count. RE-MEASURED 2026-09-23 at
|
|
// both 1440 and 1280 on the built composite: each dropdown is 168px on a 178px pitch (this note
|
|
// previously said 200px — the pitch was never measured, only assumed). Seven fit the first row,
|
|
// ending at x=1250; the eighth wraps to a second row. document.scrollWidth does not exceed the
|
|
// viewport at either width, so the promise holds today.
|
|
// Verify after any change to this list: the filter row must not produce a horizontal scrollbar at
|
|
// 1440 or 1280.
|
|
// ★ ENGAGEMENT / STATUS REMOVED ON DRIVER INSTRUCTION 2026-09-23: "dont add the engable filter or
|
|
// status filter". `engagement_status` was the eighth dropdown; it is gone from this row. The
|
|
// earlier note above about "the agent named eight ... engagement was the one missing" describes a
|
|
// SUPERSEDED instruction — kept because it records why the eighth was added, not because it is
|
|
// still the ask. SEVEN dropdowns now, which all fit the first row (seven fit, ending at x=1250 on
|
|
// the measured 178px pitch), so the no-horizontal-scrollbar promise gets easier, not harder.
|
|
//
|
|
// The facet itself is NOT deleted: `engagement_status` remains a facet accessor and keeps its
|
|
// Status tree section, because the grid still filters on it and removing the accessor would break
|
|
// FACET_TO_PROP. What changed is only whether it is offered as a DROPDOWN in the filter row.
|
|
dropdowns: ['prayer_level', 'affinity_bloc', 'wagf_region', 'wagf_block', 'country_code', 'primary_language', 'religion'],
|
|
get searchPlaceholder() { return tStatic('dashboard.search_placeholder') || 'Search people groups, place, language, religion…' },
|
|
},
|
|
// `rop1_name` (not `affinity_bloc`) so free-text search matches the People NAME — DX-life's
|
|
// Fuse key set includes 'rop1.label' (UupgsList.vue:299), and searching the axis's canonical
|
|
// CODE would mean typing 'A012' to find South Asian Peoples. This is the one place the label
|
|
// projection is still needed, and it is search-only — see the `rop1_name` facet note above.
|
|
searchKeys: ['name', 'rop1_name', 'wagf_region', 'wagf_block', 'country_code', 'primary_language', 'religion', 'engagement_status'],
|
|
|
|
// ── search header ── (DOXA-LIB-3) the 4 facet search bars (DashboardSearchHeader).
|
|
// Each bar = one standalone GeocoderComponent consuming useGeocoderSearch via a
|
|
// busAdapter sink. `context` scopes the geocoder's result ordering; `kinds` is the
|
|
// supportedKinds subset the composable accepts for that bar; `label` is the placeholder.
|
|
// The bar's busAdapter maps the RESOLVED kind → a dashboard facet key (people-group
|
|
// results go to selectionSet; the rest to filterSet) — the kind-switch itself stays in
|
|
// the composable. See src/components/dashboards/README.md.
|
|
search: [
|
|
{ id: 'people', get label() { return tStatic('dashboard.search_people') || 'Search people' }, context: 'people-groups',
|
|
kinds: ['affinity-bloc', 'cluster', 'people-group'] },
|
|
{ id: 'place', get label() { return tStatic('dashboard.search_place') || 'Search place' }, context: 'regions',
|
|
kinds: ['region', 'country'] },
|
|
{ id: 'language', get label() { return tStatic('dashboard.search_language') || 'Search language' }, context: 'language',
|
|
kinds: ['language-family', 'language', 'dialect'] },
|
|
{ id: 'religion', get label() { return tStatic('dashboard.search_religion') || 'Search religion' }, context: 'religion',
|
|
kinds: ['religion'] },
|
|
],
|
|
}
|