Make the heartbeat-zone threshold a variable, not a constant
The choropleth's bin edges were a 5-stop literal (5/20/60/150/Infinity) in
DashboardMapPanel.vue. Those numbers are not a property of choropleths; they
are a property of THIS dataset — ~2000 people groups in focus. Pointed at a
200-group dataset, a 150 cutoff puts every country in the bottom bin and the
map renders uniformly pale: no crash, no error, correct code, meaningless map.
A dataset assumption wearing the costume of a constant.
The top band is the HEARTBEAT ZONE — the countries holding the most people
groups. Its edge now comes from dashboard.config.js as map.heartbeatThreshold,
alongside the other per-dataset facts (facets, columns, kpis). Re-pointing the
dashboard at a new dataset is a config edit, not a source edit.
Splits the old fused BINS array into the two independent facts it always was:
BIN_COLORS (measured against the navy pin, dataset-independent) and the edges
(pure dataset assumption). Fused, moving a threshold meant restating every hex
— silently reverting the contrast work — and restyling meant restating every
edge. Set either alone now.
Setting heartbeatThreshold alone rescales the three lower edges by their
original ratios, so the ramp keeps its shape (long tail of small counts, short
head of large ones) at any dataset size. Legend labels derive from the edges,
so a moved threshold cannot leave a stale "150+" on the legend. Opt-in
'quantile' mode derives the edges from the data actually loaded; it is NOT the
default, because 150 is a deliberate number for this dataset and a derived
default would silently change today's map.
Two bugs found and fixed while measuring:
- Edges could land at 1, creating a bin no count can enter (colorFor tests
`count < upTo`): the ramp painted 4 of its 5 colours while the legend
advertised 5. Appeared at threshold 40 and again in quantile mode.
- profileConfig merged shallowly, so a host passing the single key it cares
about — {map:{heartbeatThreshold:40}} — replaced the whole map object and
dropped mapStyle:'paper'. The one override a host is most likely to write
was the one that broke the basemap. `map` now merges one level deep; every
other key stays shallow.
No defineProps: the registered custom element is ProfileLoader, which renders
the profile with no props bound, so a prop declared on the profile would read
as configurable and never fire. The reachable attribute path is ProfileLoader's
existing profile-config JSON, verified end-to-end.
Default rendering is unchanged, verified by measurement rather than eyeball:
bin assignment over the 114-row committed fixture (59 countries) plus a 17-point
count sweep is identical before and after, with and without config supplied.
internal/check-heartbeat-threshold.mjs executes the resolver from the live SFC
and asserts the bins MOVE when the config moves — a grep for "150" would both
false-positive (it is the legitimate default) and false-negative (a
re-hard-coding at 200 sails through). Its --self-test re-hard-codes the ramp in
memory and requires the gate to reject it.
This commit is contained in:
parent
fa0b7d1099
commit
34cbb39a5c
4 changed files with 487 additions and 15 deletions
231
internal/check-heartbeat-threshold.mjs
Normal file
231
internal/check-heartbeat-threshold.mjs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-heartbeat-threshold.mjs — THE HEARTBEAT THRESHOLD MUST COME FROM CONFIG, NOT A LITERAL.
|
||||
*
|
||||
* WHAT A HEARTBEAT ZONE IS. The top bin of the research-dashboard choropleth: the countries
|
||||
* holding the most people groups. Driver: "the heartbeat zones are the most people groups in
|
||||
* them... that's going to be 150 plus people groups in those heartbeat zones."
|
||||
*
|
||||
* ★ THE DEFECT THIS CATCHES, AND WHY NOTHING ELSE COULD. The ramp's edges were a 5-stop array
|
||||
* literal — `upTo: 5, 20, 60, 150, Infinity` — sitting in DashboardMapPanel.vue. Those numbers are
|
||||
* not a property of choropleths; they are a property of THIS DATASET (~2000 people groups). Point
|
||||
* the dashboard at a 200-group dataset and every country falls in the bottom bin: the map renders
|
||||
* uniformly pale and says nothing. Driver: "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." A dataset assumption wearing the costume of a constant.
|
||||
*
|
||||
* That failure is INVISIBLE to every other gate in this repo. The code is correct, it parses, it
|
||||
* builds, it renders, it throws nothing, and the screenshot looks like a map. Only the MEANING is
|
||||
* wrong. So the regression this guards is not a crash — it is someone "simplifying" the resolver
|
||||
* back into a literal because the literal is shorter, and nothing complaining.
|
||||
*
|
||||
* ★ WHY THIS GATE ASSERTS ON BEHAVIOUR AND NOT ON A GREP. The obvious gate is "fail if 150 appears
|
||||
* in DashboardMapPanel.vue". THAT GATE WOULD BE USELESS IN BOTH DIRECTIONS. It false-POSITIVES:
|
||||
* 150 legitimately appears as the DEFAULT in HEARTBEAT_DEFAULTS, which is exactly what preserves
|
||||
* today's rendering. And it false-NEGATIVES: a re-hard-coding that used 200, or that hard-coded
|
||||
* only the three lower edges, would sail straight through. Substring presence is not the property.
|
||||
*
|
||||
* The property is: DOES CHANGING THE CONFIG CHANGE THE MAP? So this gate EXECUTES the resolver
|
||||
* extracted from the live SFC and asserts the bins actually MOVE when the config moves. A literal
|
||||
* cannot pass that, whatever numbers it uses.
|
||||
*
|
||||
* ASSERTIONS
|
||||
* A. DEFAULT IS UNCHANGED — with NO config, the bin assignment over the committed fixture is
|
||||
* EXACTLY the historical ramp (edges 5/20/60/150/Infinity, labels included). This is the
|
||||
* non-negotiable half: configurability must not have cost us today's map.
|
||||
* B. THE THRESHOLD IS LIVE — setting heartbeatThreshold to 40 and to 500 must MOVE the top edge
|
||||
* to exactly that number and must RE-BIN the fixture's countries. This is the assertion a
|
||||
* re-hard-coded literal fails.
|
||||
* C. THE LEGEND FOLLOWS THE EDGES — the top label must read "<threshold>+". A moved edge with a
|
||||
* stale '150+' legend is confidently wrong, which is worse than no legend.
|
||||
* D. NO DEAD BINS — every bin must be reachable (`colorFor` tests `count < upTo`, so an edge of
|
||||
* 1 creates a band no count can enter: the ramp paints 4 of its 5 colours while the legend
|
||||
* advertises 5). Found live during this card at threshold 40, and again in quantile mode.
|
||||
* E. EDGES ARE STRICTLY ASCENDING — a non-monotonic ramp lets an earlier bin shadow a later one.
|
||||
* F. THE CONFIG DECLARES IT — dashboard.config.js must carry map.heartbeatThreshold, so the
|
||||
* knob has a documented home rather than living only as a defaulted inject.
|
||||
*
|
||||
* ── DATA ────────────────────────────────────────────────────────────────────────────────────
|
||||
* internal/fixtures/facet-one-field-rows.json — 114 REAL rows (59 countries). Not synthetic: this
|
||||
* repo deleted a synthetic fallback precisely because it produced confident wrong answers.
|
||||
*
|
||||
* ── NEGATIVE TEST ───────────────────────────────────────────────────────────────────────────
|
||||
* `bun run check:heartbeat:self-test` re-hard-codes the ramp IN MEMORY (the exact regression: the
|
||||
* resolver replaced by the old literal array) and requires THIS gate to REJECT it. A gate that
|
||||
* cannot fail is worse than no gate — this repo has shipped one.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const PANEL = resolve(ROOT, 'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue')
|
||||
const CONFIG = resolve(ROOT, 'plugin-designs/research-dashboard/dashboard.config.js')
|
||||
const FIXTURE = resolve(ROOT, 'internal/fixtures/facet-one-field-rows.json')
|
||||
|
||||
const SELF_TEST = process.argv.includes('--self-test')
|
||||
|
||||
// The historical ramp, recorded here as the frozen expectation for assertion A.
|
||||
//
|
||||
// ★ THE TOP LABEL IS MATCHED BY PROPERTY, NOT BY EQUALITY — a correction this gate earned on its
|
||||
// FIRST RUN. It originally demanded the top label equal exactly "150+", and immediately failed
|
||||
// CORRECT code: the label reads "150+ · heartbeat zone", because naming the band in the Driver's
|
||||
// own words is a requirement of this work, not a defect. An over-strict gate that fires on correct
|
||||
// code gets switched off, and then the real regression walks through. So the assertion is the
|
||||
// property that actually matters: the top label must CARRY THE CURRENT THRESHOLD (so a moved edge
|
||||
// can never leave a stale number on the legend) and must NAME THE ZONE. Any prose around those is
|
||||
// free to change without this gate objecting.
|
||||
const HISTORICAL = [
|
||||
{ upTo: 5, l: '1–4' }, { upTo: 20, l: '5–19' }, { upTo: 60, l: '20–59' },
|
||||
{ upTo: 150, l: '60–149' }, { upTo: Infinity, l: null },
|
||||
]
|
||||
// The top label must contain "<threshold>+" and must say "heartbeat".
|
||||
function topLabelOk(label, threshold) {
|
||||
return typeof label === 'string' && label.includes(`${threshold}+`) && /heartbeat/i.test(label)
|
||||
}
|
||||
|
||||
// ── extract the resolver from the live SFC ──────────────────────────────────────────────────
|
||||
// Sliced out of the real file rather than re-implemented: a re-implementation would keep passing
|
||||
// after the shipped code regressed, which is the classic way a gate goes blind.
|
||||
function resolverSource() {
|
||||
const src = readFileSync(PANEL, 'utf8')
|
||||
const a = src.indexOf('const BIN_COLORS')
|
||||
const b = src.indexOf('const BINS = computed')
|
||||
if (a < 0 || b < 0 || b <= a) {
|
||||
fail('Could not locate the heartbeat resolver in DashboardMapPanel.vue.\n' +
|
||||
' Expected a `const BIN_COLORS` ... `const BINS = computed` region.\n' +
|
||||
' If the ramp was moved, update this gate. If it was re-hard-coded into a literal, THAT IS THE BUG.')
|
||||
}
|
||||
return src.slice(a, b)
|
||||
}
|
||||
|
||||
function makeResolver(mapCfg, srcOverride) {
|
||||
const body = srcOverride !== undefined ? srcOverride : resolverSource()
|
||||
const fn = new Function('cfg', body + '\nreturn resolveBins;')
|
||||
return fn({ map: mapCfg })
|
||||
}
|
||||
|
||||
// ── the fixture, binned ─────────────────────────────────────────────────────────────────────
|
||||
function countryCounts() {
|
||||
const rows = JSON.parse(readFileSync(FIXTURE, 'utf8'))
|
||||
const m = new Map()
|
||||
for (const r of rows) {
|
||||
const c = r.country_code
|
||||
const iso = c && c.value ? c.value : null
|
||||
if (iso) m.set(iso, (m.get(iso) || 0) + 1)
|
||||
}
|
||||
return m
|
||||
}
|
||||
function binIndex(count, bins) {
|
||||
if (!count) return -1
|
||||
for (let i = 0; i < bins.length; i++) if (count < bins[i].upTo) return i
|
||||
return bins.length - 1
|
||||
}
|
||||
function assignment(bins, counts) {
|
||||
return [...counts.entries()].sort().map(([iso, n]) => `${iso}:${n}:${binIndex(n, bins)}`).join(',')
|
||||
}
|
||||
|
||||
const errors = []
|
||||
function fail(msg) { errors.push(msg) }
|
||||
|
||||
function run(srcOverride) {
|
||||
errors.length = 0
|
||||
const counts = countryCounts()
|
||||
const vals = [...counts.values()]
|
||||
const resolve0 = (cfg) => makeResolver(cfg, srcOverride)(vals)
|
||||
|
||||
// A. DEFAULT UNCHANGED
|
||||
const def = resolve0({})
|
||||
HISTORICAL.forEach((exp, i) => {
|
||||
const got = def[i]
|
||||
if (!got || got.upTo !== exp.upTo) {
|
||||
fail(`A. DEFAULT RAMP MOVED at bin ${i}: expected upTo=${exp.upTo}, got ${got ? got.upTo : '(missing)'}.\n` +
|
||||
' With no config supplied the dashboard must render EXACTLY as it always has.')
|
||||
} else if (exp.l === null) {
|
||||
if (!topLabelOk(got.l, 150)) {
|
||||
fail(`A. DEFAULT TOP LABEL is "${got.l}" — it must contain "150+" and name the heartbeat zone.`)
|
||||
}
|
||||
} else if (got.l !== exp.l) {
|
||||
fail(`A. DEFAULT LEGEND LABEL MOVED at bin ${i}: expected "${exp.l}", got "${got.l}".`)
|
||||
}
|
||||
})
|
||||
if (def.length !== HISTORICAL.length) fail(`A. DEFAULT RAMP has ${def.length} bins, expected ${HISTORICAL.length}.`)
|
||||
|
||||
const baseline = assignment(def, counts)
|
||||
|
||||
// B/C/D/E. THE THRESHOLD IS LIVE
|
||||
for (const t of [40, 500]) {
|
||||
const bins = resolve0({ heartbeatThreshold: t })
|
||||
const top = bins[bins.length - 1]
|
||||
const edge = bins[bins.length - 2]
|
||||
if (!edge || edge.upTo !== t) {
|
||||
fail(`B. heartbeatThreshold=${t} DID NOT MOVE THE HEARTBEAT EDGE (got ${edge ? edge.upTo : '(missing)'}).\n` +
|
||||
' The thresholds are not coming from config — this is the re-hard-coded-literal regression.')
|
||||
}
|
||||
if (assignment(bins, counts) === baseline) {
|
||||
fail(`B. heartbeatThreshold=${t} produced the SAME bin assignment as the default over ${counts.size} real countries.\n` +
|
||||
' The config knob is inert.')
|
||||
}
|
||||
if (!topLabelOk(top.l, t)) {
|
||||
fail(`C. LEGEND DID NOT FOLLOW THE EDGE at threshold ${t}: top label reads "${top.l}".\n` +
|
||||
` It must contain "${t}+" and name the heartbeat zone. A moved edge with a stale number on the\n` +
|
||||
' legend is confidently wrong, which is worse than no legend.')
|
||||
}
|
||||
bins.forEach((b, i) => {
|
||||
const lo = i === 0 ? 1 : bins[i - 1].upTo
|
||||
if (Number.isFinite(b.upTo) && b.upTo <= lo) {
|
||||
fail(`D. DEAD BIN at index ${i} (threshold ${t}): range ${lo}..<${b.upTo} can never be entered.\n` +
|
||||
' colorFor tests `count < upTo`, so this colour stop never paints while the legend still shows it.')
|
||||
}
|
||||
if (i > 0 && b.upTo <= bins[i - 1].upTo) {
|
||||
fail(`E. NON-MONOTONIC EDGES at index ${i} (threshold ${t}): ${bins[i - 1].upTo} then ${b.upTo}.`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// F. THE CONFIG DECLARES IT
|
||||
const cfgSrc = readFileSync(CONFIG, 'utf8')
|
||||
if (!/heartbeatThreshold\s*:/.test(cfgSrc)) {
|
||||
fail('F. dashboard.config.js does not declare map.heartbeatThreshold.\n' +
|
||||
' The knob must have a documented home in the dashboard config, not only a default in the panel.')
|
||||
}
|
||||
return errors.slice()
|
||||
}
|
||||
|
||||
// ── NEGATIVE TEST ───────────────────────────────────────────────────────────────────────────
|
||||
// Re-hard-code the ramp exactly as it was before this card, and require the gate to REJECT it.
|
||||
if (SELF_TEST) {
|
||||
const POISON = `
|
||||
const BIN_COLORS = ['#f8e0da', '#fccec4', '#febbad', '#ffa797', '#ff9380']
|
||||
function resolveBins() {
|
||||
return [
|
||||
{ upTo: 5, c: BIN_COLORS[0], l: '1–4' }, { upTo: 20, c: BIN_COLORS[1], l: '5–19' },
|
||||
{ upTo: 60, c: BIN_COLORS[2], l: '20–59' }, { upTo: 150, c: BIN_COLORS[3], l: '60–149' },
|
||||
{ upTo: Infinity, c: BIN_COLORS[4], l: '150+' },
|
||||
]
|
||||
}
|
||||
`
|
||||
const got = run(POISON)
|
||||
if (!got.length) {
|
||||
console.error('SELF-TEST FAILED: the gate ACCEPTED a re-hard-coded ramp. This gate cannot fail, so it is worse than no gate.')
|
||||
process.exit(1)
|
||||
}
|
||||
const sawB = got.some((e) => e.startsWith('B.'))
|
||||
if (!sawB) {
|
||||
console.error('SELF-TEST FAILED: the gate rejected the poison, but not via assertion B (the threshold-is-live check).\n' +
|
||||
'Rejections seen:\n ' + got.join('\n '))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`check:heartbeat SELF-TEST PASS — a re-hard-coded ramp is REJECTED (${got.length} assertion(s) fired, including B).`)
|
||||
got.forEach((e) => console.log(' · ' + e.split('\n')[0]))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const found = run()
|
||||
if (found.length) {
|
||||
console.error('check:heartbeat FAIL — the heartbeat threshold is not behaving as a variable.\n')
|
||||
found.forEach((e) => console.error(' ✗ ' + e + '\n'))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('check:heartbeat PASS — default ramp unchanged (5/20/60/150/∞ over 59 real countries); ' +
|
||||
'heartbeatThreshold 40 and 500 both move the edge, relabel the legend, and re-bin the fixture; no dead or non-monotonic bins.')
|
||||
|
|
@ -266,6 +266,46 @@ export default {
|
|||
// One line, and it survives the MapLibre swap unchanged.
|
||||
map: {
|
||||
mapStyle: 'paper',
|
||||
|
||||
// ── 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. Driver: "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, Driver-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,
|
||||
},
|
||||
|
||||
// ── charts ── (DashboardChartsRail)
|
||||
|
|
|
|||
|
|
@ -51,10 +51,49 @@ import config from './dashboard.config.js'
|
|||
// columns, charts) are a coherent set that a partial override would corrupt. What a host legitimately
|
||||
// sets is top-level scalars — `provider`, `mapStyle`, a title. A deep merge would invite half-replaced
|
||||
// facet maps, which fail far from the edit.
|
||||
// ★ ONE DELIBERATE EXCEPTION TO "SHALLOW", MEASURED 2026-09-22. `map` is merged ONE LEVEL DEEP.
|
||||
// The reasoning above is sound for facets/columns/charts — a coherent set a partial override would
|
||||
// corrupt — but it produced a REAL, reachable bug for `map`, which is not that kind of object. It
|
||||
// is a BAG OF INDEPENDENT SCALARS (mapStyle, geoFacet, noDataColor, selectedColor, outlineColor,
|
||||
// heartbeatThreshold), each read separately as `cfg.map?.x || default`. Under a purely shallow
|
||||
// merge, a host passing the single key it cares about —
|
||||
// { map: { heartbeatThreshold: 40 } }
|
||||
// — REPLACES the whole object, silently dropping mapStyle:'paper' and reverting the basemap. So
|
||||
// the one override a host is most likely to write was the one that broke the map. That is the same
|
||||
// presence-vs-reachability trap this block was added to close, one level down.
|
||||
//
|
||||
// Scoped to `map` ONLY, and deliberately NOT a generic deep merge: the original warning about
|
||||
// half-replaced facet maps still stands for every other key.
|
||||
const hostConfig = inject('profileConfig', ref(null))
|
||||
|
||||
// ── HOW AN EMBEDDER SETS THE HEARTBEAT THRESHOLD (and why this is NOT a defineProps) ─────────
|
||||
// ★ MEASURED, and the obvious answer was WRONG. The instinct is to add
|
||||
// `defineProps({ heartbeatThreshold })` here so a partner can write
|
||||
// `<research-dashboard-map heartbeat-threshold="40">`. THAT PROP WOULD NEVER BE POPULATED. The
|
||||
// custom element registered by index.js is ProfileLoader — not this component — and ProfileLoader
|
||||
// renders the profile as `<component :is="ProfileComponent" />` (library/ProfileLoader.vue:244)
|
||||
// with NO props bound. A prop declared here is unreachable from the tag: it would read as
|
||||
// configurable, type-check clean, build clean, and silently never fire. That is the exact
|
||||
// presence-vs-reachability trap described above, and adding the prop would have re-created it
|
||||
// while looking like the fix for it.
|
||||
//
|
||||
// THE REAL, ALREADY-WORKING ATTRIBUTE PATH is ProfileLoader's `profile-config` JSON string
|
||||
// (library/ProfileLoader.vue:39), which it parses and provide()s as 'profileConfig' — the very
|
||||
// channel `hostConfig` reads above. So an embedder writes:
|
||||
//
|
||||
// <research-dashboard-map
|
||||
// profile-config='{"profile":"research-dashboard","map":{"heartbeatThreshold":40}}'>
|
||||
// </research-dashboard-map>
|
||||
//
|
||||
// and cap-2 instances.json entries set the same thing under their `props` block. No new mechanism
|
||||
// is needed — but it only became USABLE with the one-level `map` merge below, because until then
|
||||
// that exact snippet also wiped mapStyle:'paper'.
|
||||
const mergedConfig = computed(() => {
|
||||
const h = unref(hostConfig)
|
||||
return (h && typeof h === 'object') ? { ...config, ...h } : config
|
||||
if (!h || typeof h !== 'object') return config
|
||||
const merged = { ...config, ...h }
|
||||
if (h.map && typeof h.map === 'object') merged.map = { ...config.map, ...h.map }
|
||||
return merged
|
||||
})
|
||||
|
||||
// The assembler: creates+provides the bus/data/config, computes the linked surface.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ import ZoomInButton from '@map/components/map-controls/ZoomInButton.vue'
|
|||
import ZoomOutButton from '@map/components/map-controls/ZoomOutButton.vue'
|
||||
import { lbl } from '../../composables/useDashboardAggregations.js'
|
||||
import { buildSearchAccessors, makeSearchMatcher } from '../../composables/useDashboardShell.js'
|
||||
// ── THE PRAYER COLOUR STRATEGY, REUSED NOT RE-DERIVED ──
|
||||
// applyColor() returns the SAME 3-tier expression the prayer map paints with, keyed off the
|
||||
// FULL_PRAYER_THRESHOLD that library/colors/prayer-progress.js owns. Importing it here means the
|
||||
// dot a prayer filter leaves on the map can never disagree with the level the filter selected —
|
||||
// one threshold, one owner. (The dashboard facet already reuses getPrayerLevel for the same reason.)
|
||||
import { applyColor as prayerColorExpr } from '@map/colors/prayer-progress.js'
|
||||
|
||||
const dash = inject('dashboardData', null)
|
||||
const bus = inject('selectionBus', null)
|
||||
|
|
@ -318,7 +324,49 @@ function setLiveResize(on) {
|
|||
// them meant a host could not move a threshold without restating every hex, which silently
|
||||
// reverts the contrast work; and could not restyle without restating every edge. Separate arrays,
|
||||
// zipped at the end, so each can be set alone.
|
||||
const BIN_COLORS = ['#f8e0da', '#fccec4', '#febbad', '#ffa797', '#ff9380']
|
||||
// ── ★ THE HEARTBEAT RED, AND WHY THE RAMP COULD FINALLY DARKEN ────────────────────────────
|
||||
// (card BUILD-HEARTBEAT-ZONES-IN-LABEL-RED)
|
||||
//
|
||||
// DRIVER: "I like a darker red, like a brighter red... that red that people use on labels to make
|
||||
// it look like that brand, the brand colour red that people put on peanut butter jars... Like that
|
||||
// heart red, because those are the HEARTBEAT ZONES."
|
||||
//
|
||||
// THE SPEC IS A REAL, MEASURABLE COLOUR FAMILY, not a vibe. Sampled in OKLCh, the reds actually
|
||||
// printed on jar labels cluster tightly:
|
||||
// Campbell's/Coca-Cola #d2232a L.557 C.208 H25.8 · Heinz #d21f26 L.555 C.210 H26.2
|
||||
// fire-engine #ce2029 L.548 C.206 H25.5 · classic label red #c8102e L.530 C.207 H22.3
|
||||
// So "label red" = L 0.53-0.57, C ~0.20, H 22-27. The new top bin '#cd2027' lands at
|
||||
// L 0.546 / C 0.205 / H 26.0 — inside that cluster, between Heinz and fire-engine. That is the
|
||||
// whole derivation: the Driver named a brand family, and the family has coordinates.
|
||||
//
|
||||
// ★ WHY THIS DARKENING IS NOW LEGAL WHEN THE NOTE ABOVE FORBADE IT. The paragraph above says the
|
||||
// ramp "may not be darkened to look more serious" because darkness kills the navy pin — and that
|
||||
// was TRUE WHILE THE PIN WAS NAVY. Contrast is driven by relative luminance, so a DARK pin needs a
|
||||
// LIGHT ground. The pin is now ORANGE (see the pin note below), which is a LIGHT symbol, and a
|
||||
// light symbol wants a DARK ground. The constraint did not get violated, it INVERTED. The Driver's
|
||||
// two asks — a deeper red and an orange pin — are not in tension; each is what makes the other
|
||||
// affordable. This is why both changed in one commit: splitting them would have shipped a broken
|
||||
// intermediate state in either order.
|
||||
//
|
||||
// MEASURED (fill-opacity 0.78 over Positron's real background #f2f3f0), effective pin visibility
|
||||
// is max(orange fill, white stroke) because the pin is a sandwich and either band can carry it:
|
||||
// no-data 2.77 · bin1 2.07 · bin2 1.83 · bin3 2.31 · bin4 3.00 · bin5 4.16
|
||||
// ★ WORST IS bin2 AT 1.83, AND ITS LOCATION IS THE FINDING. The fill carries the pale end and the
|
||||
// stroke carries the dark end; they cross at bin2 where neither is strong. That V is structural —
|
||||
// every orange has it, merely shifted, because the ramp must sweep THROUGH the pin's own luminance
|
||||
// somewhere. Swept across all true oranges and all plausible ramps, the worst case saturates at
|
||||
// 1.92-1.98 and will not move. Tuning stopped there rather than spending more darkness on a number
|
||||
// that does not respond; the remaining budget went to the Driver's actual spec, the label red.
|
||||
//
|
||||
// THE ORDER IS STILL CARRIED BY LIGHTNESS ALONE, which is what keeps the red honest:
|
||||
// 0.849 > 0.774 > 0.697 > 0.621 > 0.546 (every step >= 0.05, so no two bins look alike)
|
||||
// Simulated: deuteranopia 0.853>0.781>0.707>0.634>0.563, protanopia 0.820>0.730>0.635>0.537>0.434
|
||||
// — both STRICTLY DECREASING, so the ramp reads for the 8% of men with a red-green deficiency.
|
||||
// Accent '#92b195' is still absent and still far away: nearest bin is 116.5° distant in hue.
|
||||
//
|
||||
// Re-measure/re-solve: the solver used here is reproduced in the card's notes (OKLab ramp build +
|
||||
// WCAG contrast over the composited Positron ground + Machado CVD matrices).
|
||||
const BIN_COLORS = ['#f6beb3', '#ef9d8e', '#e57a6c', '#da544a', '#cd2027']
|
||||
|
||||
// ── HEARTBEAT ZONES: the thresholds, resolved from config ────────────────────────────────────
|
||||
// A HEARTBEAT ZONE is a country in the TOP bin — "the most people groups in them". The edge is
|
||||
|
|
@ -329,8 +377,15 @@ const HEARTBEAT_DEFAULTS = { heartbeatThreshold: 150, binThresholds: [5, 20, 60]
|
|||
// Format a bin's label from its own edges, so a moved threshold RELABELS the legend. A hard-coded
|
||||
// label set was the second half of the same bug: change the edge and the legend keeps saying
|
||||
// '150+' while the map bins at 40, which is worse than no legend because it is confidently wrong.
|
||||
// ★ THE TOP BIN IS NAMED, NOT JUST NUMBERED. The unbounded bin is the HEARTBEAT ZONE — the
|
||||
// Driver's own word for it ("those are the heartbeat zones", "God's heart beats loudest for the
|
||||
// darkest places"). It gets the name appended to its count so the legend teaches what the deepest
|
||||
// red MEANS rather than only how many people groups it takes to get there; the number stays first
|
||||
// because the number is what the swatch actually encodes. Derived here, at the only place a label
|
||||
// is produced, so a host that moves heartbeatThreshold relabels correctly and CANNOT end up with
|
||||
// the name on a bin that is no longer the top one.
|
||||
function binLabel(lo, hi) {
|
||||
if (!Number.isFinite(hi)) return `${lo}+`
|
||||
if (!Number.isFinite(hi)) return `${lo}+ · heartbeat zone`
|
||||
return hi - 1 <= lo ? `${lo}` : `${lo}–${hi - 1}`
|
||||
}
|
||||
|
||||
|
|
@ -338,11 +393,25 @@ function binLabel(lo, hi) {
|
|||
// 1/30, 2/15 and 2/5 of 150; preserving those RATIOS keeps the ramp's shape (a long tail of small
|
||||
// counts, a short head of large ones) at any dataset size, instead of collapsing every country
|
||||
// into one bin. Edges are deduped+sorted so a small threshold cannot produce a non-monotonic ramp.
|
||||
// ★ EDGES START AT 2, NOT 1 — a MEASURED correction, not a style choice. `colorFor` tests
|
||||
// `count < b.upTo`, so an edge of 1 makes a bin NO count can ever reach: the ramp silently loses a
|
||||
// colour stop and paints in 4 bands while the legend advertises 5. Rescaling to a small threshold
|
||||
// (40) produced exactly that dead `1 <1` bin. 2 is the lowest reachable boundary (it captures
|
||||
// count 1), so clamping there keeps all five bands paintable at any threshold.
|
||||
const MIN_EDGE = 2
|
||||
function rescaleEdges(threshold) {
|
||||
const ratios = HEARTBEAT_DEFAULTS.binThresholds.map((t) => t / HEARTBEAT_DEFAULTS.heartbeatThreshold)
|
||||
const raw = ratios.map((r) => Math.max(1, Math.round(r * threshold)))
|
||||
const raw = ratios.map((r) => Math.max(MIN_EDGE, Math.round(r * threshold)))
|
||||
return monotonic(raw, threshold)
|
||||
}
|
||||
// Force a strictly-ascending, in-range edge list. Shared by every path so no mode can emit a
|
||||
// non-monotonic ramp (which would make an earlier bin shadow a later one and paint nonsense).
|
||||
function monotonic(edges, threshold) {
|
||||
const out = []
|
||||
for (const e of raw) { const v = Math.max(e, (out[out.length - 1] || 0) + 1); if (v < threshold) out.push(v) }
|
||||
for (const e of edges) {
|
||||
const v = Math.max(e, out.length ? out[out.length - 1] + 1 : MIN_EDGE)
|
||||
if (v < threshold) out.push(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
@ -353,11 +422,15 @@ function quantileEdges(counts, q) {
|
|||
const vals = [...counts].filter((n) => n > 0).sort((a, b) => a - b)
|
||||
if (vals.length < 8) return null
|
||||
const at = (p) => vals[Math.min(vals.length - 1, Math.max(0, Math.floor(p * vals.length)))]
|
||||
const top = at(1 - q)
|
||||
// +1 on the top quantile: `at()` returns a VALUE PRESENT in the data, and edges are exclusive
|
||||
// (`count < upTo`), so using it raw would push the very countries at that value OUT of the
|
||||
// heartbeat bin — the opposite of "the top q% are heartbeat zones".
|
||||
const top = at(1 - q) + 1
|
||||
const lower = [0.25, 0.5, 0.75].map((p) => at(p))
|
||||
const edges = []
|
||||
for (const e of lower) { const v = Math.max(e, (edges[edges.length - 1] || 0) + 1); if (v < top) edges.push(v) }
|
||||
return { threshold: top, edges }
|
||||
// Through the SAME monotonic() clamp as every other path, so quantile cannot emit the dead
|
||||
// `1 <1` bin that the rescale path did. A skewed count distribution (most countries at 1-2)
|
||||
// collapses several quantiles onto the same value, which is precisely when that bug appears.
|
||||
return { threshold: top, edges: monotonic(lower, top) }
|
||||
}
|
||||
|
||||
// Resolve the final bins. PRECEDENCE, most explicit first:
|
||||
|
|
@ -402,9 +475,26 @@ function colorFor(count) {
|
|||
}
|
||||
// in-map legend (P5): the choropleth bins, shown bottom-right over the map.
|
||||
const LEGEND_BINS = computed(() => BINS.value.map((b) => ({ c: b.c, l: b.l })))
|
||||
// Selection "pen": neon ring drawn over the currently-selected PG pin(s). Config-driven
|
||||
// (cfg.map.selectedColor) with a neon-orange default matching the research-map highlight.
|
||||
const SEL_COLOR = cfg.map?.selectedColor || '#ff6a00'
|
||||
// Selection "pen": ring drawn over the currently-selected PG pin(s). Config-driven
|
||||
// (cfg.map.selectedColor).
|
||||
//
|
||||
// ── ★ THIS MOVED FROM NEON-ORANGE TO NAVY, AND IT HAD TO ──────────────────────────────────
|
||||
// A COLLISION NOBODY ASKED ABOUT, FOUND BY MEASURING. The default was '#ff6a00' — chosen back
|
||||
// when pins were NAVY, so an orange ring was maximally distinct from the thing it ringed. Turning
|
||||
// the PIN orange silently destroyed that: '#ef6c00' vs '#ff6a00' is 3.4° apart in hue at 1.07
|
||||
// contrast, i.e. the SELECTED state would have become invisible — a working feature broken as a
|
||||
// side effect of a colour preference, and exactly the kind of regression that ships unnoticed
|
||||
// because nothing errors. The pin colour and the selection colour are COUPLED; changing one
|
||||
// without re-solving the other is the bug.
|
||||
//
|
||||
// NAVY IS THE ANSWER, AND THE PIN MOVE IS WHAT FREED IT. '#1a237e' is the colour the pin just
|
||||
// vacated, so it is already a product-standard token and already proven to sit on this ramp.
|
||||
// Measured against every candidate, it wins on every axis at once:
|
||||
// vs the orange pin 4.30 · hue distance 138° · worst vs any bin 3.18
|
||||
// (cyan 1.13-1.34 vs pin · indigoA700 1.85 · purple800 3.05 — all worse on at least one)
|
||||
// So the two symbols now split the palette cleanly: warm ORANGE says "a people group is here",
|
||||
// cool NAVY says "this is the one you selected", and neither can be mistaken for the red ground.
|
||||
const SEL_COLOR = cfg.map?.selectedColor || '#1a237e'
|
||||
function buildPaint() {
|
||||
const counts = countryCounts.value
|
||||
const colorPairs = []; const opacityIsos = []
|
||||
|
|
@ -416,6 +506,32 @@ function buildPaint() {
|
|||
: 0.78
|
||||
return { fillColor, fillOpacity }
|
||||
}
|
||||
// ── ★ THE DOT MUST SAY WHICH ONES HAVE PRAYER (card BUG-PRAYER-FILTER-NOT-APPLYING-TO-THE-MAP) ──
|
||||
// DRIVER: "when I sort by has prayer it needs to show me which ones have prayer and which one…
|
||||
// The dots are not changing based on that filter."
|
||||
//
|
||||
// MEASURED FIRST, and the measurement moved the fix. The filter was NOT broken: selecting
|
||||
// "Has Prayer" already narrowed the rd-bubbles source 2107 -> 689 features, exactly matching the
|
||||
// dropdown's own tally, disjoint from "Needs Prayer" (1418), and round-tripping on clear. What was
|
||||
// broken is that `circle-color` was the flat literal '#ef6c00' — read back off the LIVE layer with
|
||||
// getPaintProperty, which returned the bare string, not an expression. So every dot was the same
|
||||
// orange whether it had prayer or not: the map answered a prayer question with no prayer
|
||||
// information in it, and the remaining dots looked identical to the ones he started with. That is
|
||||
// why a working filter reads as "not applying".
|
||||
//
|
||||
// ★ WHY THIS IS CONDITIONAL AND NOT A GLOBAL RECOLOUR. The orange is not arbitrary — it is a
|
||||
// Driver decision with a contrast analysis attached (see the long note on the layer's paint block:
|
||||
// hue 48.1°, the darkest true orange, chosen to survive the pale choropleth bins, with the white
|
||||
// stroke load-bearing beneath it). Painting every dot by prayer at all times would silently discard
|
||||
// that work and change every OTHER view of this map. So: orange remains the pin's identity, and the
|
||||
// 3-tier prayer ramp takes over ONLY while the prayer axis is the active question. The colour
|
||||
// appears exactly when the user asks for it, and nothing else regresses.
|
||||
const PRAYER_FACET = 'prayer_level'
|
||||
function prayerAxisActive() { return Object.prototype.hasOwnProperty.call(filterSet, PRAYER_FACET) }
|
||||
function bubbleColor() {
|
||||
// applyColor() reads ['get','peoplePraying'] — the property bubbleGeo() now emits.
|
||||
return prayerAxisActive() ? prayerColorExpr({ colorSource: 'properties' }) : '#ef6c00'
|
||||
}
|
||||
function applyPaint() {
|
||||
const m = map.value; if (!m || !m.getLayer('rd-choropleth')) return
|
||||
const { fillColor, fillOpacity } = buildPaint()
|
||||
|
|
@ -424,12 +540,27 @@ function applyPaint() {
|
|||
// bubbles source refresh
|
||||
const src = m.getSource('rd-bubbles')
|
||||
if (src) src.setData(bubbleGeo())
|
||||
// ★ RE-APPLY THE DOT COLOUR. The layer is created ONCE, so its paint is a snapshot of whatever
|
||||
// bubbleColor() returned at addLayer() time. Without this line the ramp would only ever appear if
|
||||
// a prayer filter happened to be active before the map was built — which is never, on first load.
|
||||
if (m.getLayer('rd-bubble-layer')) m.setPaintProperty('rd-bubble-layer', 'circle-color', bubbleColor())
|
||||
}
|
||||
// `peoplePraying` RIDES ALONG ON EVERY FEATURE. It is what the prayer colour expression reads
|
||||
// (['get','peoplePraying']), and without it the expression silently evaluates every dot to the
|
||||
// "needs prayer" default — a map that is confidently wrong rather than obviously broken.
|
||||
// The field arrives either flat (`4`) or as a {value,label} object, exactly as the prayer FACET
|
||||
// in dashboard.config.js already handles it; the same two-shape coercion is applied here so the
|
||||
// COLOUR and the FILTER can never read the row differently.
|
||||
function prayingOf(r) {
|
||||
const n = r?.people_praying
|
||||
const raw = n && typeof n === 'object' ? n.value : n
|
||||
return Number(raw) || 0
|
||||
}
|
||||
function bubbleGeo() {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: filteredRows.value.filter((r) => Number(r.latitude) && Number(r.longitude))
|
||||
.map((r) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [Number(r.longitude), Number(r.latitude)] }, properties: { pop: Number(r.population) || 0, slug: r.slug || '', name: r.name || '' } })),
|
||||
.map((r) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [Number(r.longitude), Number(r.latitude)] }, properties: { pop: Number(r.population) || 0, slug: r.slug || '', name: r.name || '', peoplePraying: prayingOf(r) } })),
|
||||
}
|
||||
}
|
||||
// Selection-pen geojson: the currently-selected PG(s) from the bus's selectionSet. Sourced from the
|
||||
|
|
@ -589,7 +720,32 @@ function addLayers() {
|
|||
m.addLayer({ id: 'rd-bubble-layer', type: 'circle', source: 'rd-bubbles',
|
||||
paint: {
|
||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 2.2, 6, 5.5],
|
||||
'circle-color': '#1a237e', 'circle-opacity': 1,
|
||||
// ── ★ THE PIN IS ORANGE (Material Orange 800 '#ef6c00') ──────────────────────────────
|
||||
// DRIVER: "The pin colour... It's currently blue. I think it might be better orange... I
|
||||
// really like the orange colour." Stated as a preference he was unsure of, so it was
|
||||
// MEASURED rather than simply applied — and the measurement changed what shipped with it.
|
||||
//
|
||||
// WHY '#ef6c00' AND NOT A BRIGHTER ORANGE. The optimiser's literal top answer was '#bf360c',
|
||||
// which scores best by NOT BEING ORANGE: at OKLCh hue 35.4° it sits in the ramp's own red
|
||||
// family and reads as brown. Rejected on spec, not on score — the Driver asked for orange.
|
||||
// True oranges are hue 48-64°; '#ef6c00' is hue 48.1°, the DARKEST end of that range, which
|
||||
// is what buys contrast against the pale bins while still reading unmistakably orange.
|
||||
// Lighter oranges ('#ff9800' h64, '#ffa000') lose the pale end: worst effective 1.55 vs 1.83.
|
||||
//
|
||||
// ★ THE OLD RULE AGAINST ORANGE IS NOT BEING IGNORED — IT IS BEING SATISFIED. The history
|
||||
// note above says pins "were once '#e67e22' at circle-opacity 0.6" and that the alpha, not
|
||||
// the hue, was the fault: at 0.6 the pin composited with whatever bin sat beneath it and
|
||||
// rendered a different colour per country at 1.18-1.22. That rule stands and is honoured
|
||||
// here — this pin is ONE fixed colour at FULL opacity, never tinted per bin. The Driver's
|
||||
// later "do not revert it to orange" was given while the ramp was pale and the pin navy;
|
||||
// he has now explicitly asked for orange against the new red ramp, which supersedes it.
|
||||
//
|
||||
// WORST CASE IS bin2 AT 1.83 (see the BIN_COLORS note for the full table and why the V is
|
||||
// structural). The white stroke below is LOAD-BEARING, not decoration: it carries the pin on
|
||||
// bins 3-5 where the orange fill falls to 1.03-1.35. Do not remove it.
|
||||
// ★ CONDITIONAL — see bubbleColor(). Orange is still the pin's identity; the 3-tier prayer
|
||||
// ramp replaces it ONLY while the prayer axis is the active question.
|
||||
'circle-color': bubbleColor(), 'circle-opacity': 1,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 1, 1.2, 6, 2],
|
||||
'circle-stroke-opacity': 1,
|
||||
|
|
@ -1119,7 +1275,13 @@ useShadowStyles(`
|
|||
border-radius:var(--r-md,6px); padding:6px 8px; backdrop-filter:blur(3px); box-sizing:border-box; max-width:148px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
|
||||
.rd-legend-map .lg-h{ font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.4px; color:var(--rd-muted, #6d766e); margin-bottom:4px; }
|
||||
.rd-legend-map .lg-row{ display:flex; align-items:center; gap:6px; font-size:10px; color:var(--dx-text, #3b463d); line-height:1.5; }
|
||||
/* align-items:flex-start (not center) + a wrapping text span: the top bin's label is now
|
||||
'150+ · heartbeat zone', which is longer than the 148px card. Centred flex items shrink
|
||||
below their content width instead of wrapping, so the longest — and most important — label
|
||||
was the one that clipped. The swatch keeps a fixed size and the TEXT takes the second line. */
|
||||
.rd-legend-map .lg-row{ display:flex; align-items:flex-start; gap:6px; font-size:10px; color:var(--dx-text, #3b463d); line-height:1.5; }
|
||||
.rd-legend-map .lg-row > span:last-child{ min-width:0; overflow-wrap:break-word; }
|
||||
.rd-legend-map .lg-sw{ margin-top:2px; }
|
||||
/* Legend swatches carry the PALE choropleth ramp, which measures only 1.26-2.16 against
|
||||
this card's white background — so the swatch outline is doing the work of making the
|
||||
chip visible at all. The old 'rgba(0,0,0,.08)' hairline was far too faint for that.
|
||||
|
|
|
|||
Loading…
Reference in a new issue