doxa-maps/library/composables/useFacetAggregation.js
Doxa Maps 25c102f5cd Kit 4c-1: the counting half, and the bucket that said "Other" in every locale
useDashboardAggregations has a split personality, and only half of it could ever
move. `lbl` and `aggregate` are pure counting — eleven lines, a Map, a sort, a
topN rollup, no facet name and no colour. Everything else in that file is
research: `colorFor` with its hardcoded religion / wagf_region / affinity_bloc
branches, the religion palettes, the donut and bar builders. Those stay, and
DashboardChartsRail keeps importing them.

The generic pair was what blocked kit landing 4c: the facet rail needs only that
half, and while it lived beside the palettes the rail could not follow it into the
library. Same split, same one-cycle re-export as 4a. Bodies verbatim.

★ AND THE ROLLUP BUCKET SAID "Other" IN EVERY LOCALE. aggregate() pushes
`{ key: 'Other', other: true }` for the tail, and that English word was rendered
directly by the facet rail and the charts rail. check:i18n-keys-exist could not
see it, because there was no T() call to inspect — the string was never a lookup,
it was a KEY being used as a LABEL. I found it while ASSESSING this landing rather
than while doing it, and it is fixed here rather than later for one reason: the
moment this module becomes kit code, an unfixed bug in it is exported to every
dashboard built from the kit.

THE TWO HALVES PULL IN OPPOSITE DIRECTIONS, which is the whole difficulty. The KEY
must stay English: the rail tests it to make the row inert, and the charts rail
refuses to filter on it, so a localized key would make the bucket CLICKABLE in
Arabic and filter on a value no row carries — an empty board, reading as a data
problem. The LABEL must not stay English. So OTHER_KEY is the sentinel,
otherLabel() reads dashboard.other, and they live next to the function that
produces the bucket so they cannot drift. Eleven locales, catalogue first.

This is the value/label trap for the third time — religion families, the language
tiers, and now a bucket in generic code. The third is the worst place for it,
which is why the rule now travels WITH the code that creates the value rather than
being remembered at each render site.

check:legend-mirrors grows arm 4, asserting both directions: translate the
sentinel and it fails; render the key instead of the label and it fails. Both
mutants run.

check-one-palette now scans the new module as well. Its subject — private palette
declarations — stayed in the plugin half, so nothing moved; adding the library
path means a palette smuggled into kit code later is caught by the same rule
instead of escaping into the library.

Library index regenerated in-commit. Source only. STUDIO: needs a rebuild of
research-dashboard and dashboard-plus-grid. The internal/ hunk is four lines in
check-one-palette.mjs.
2026-09-27 04:14:13 +00:00

58 lines
3.1 KiB
JavaScript

/**
* useFacetAggregation.js — count rows by a facet accessor, and roll the tail into one bucket.
*
* ★ EXTRACTED FROM useDashboardAggregations (dashboard-kit landing 4c-1). That module has a split
* personality: these two functions are pure counting, while the rest of it — colorFor with its
* hardcoded religion / wagf_region / affinity_bloc branches, the religion palettes, the donut and
* bar builders — is research-specific and STAYS in plugin-designs. Only the generic half could
* move, and the facet rail needs only the generic half, which is what made this the piece blocking
* kit landing 4c.
*
* Bodies are verbatim. Nothing here knows a facet name: the caller supplies the accessor.
*/
import { tStatic } from '@map/i18n/index.js'
/**
* A deliberately NEUTRAL tone for a value this facet has no palette for — matching the tree
* legend's NEUTRAL exactly. An uncoloured slice must read as "no colour strategy for this key",
* never as a confident member of some other family. Critically it is STABLE: the same key gets
* the same neutral every time, so nothing changes colour when a filter reorders the list.
*/
export const NEUTRAL = '#c4c8c0'
export const lbl = (v) => (v === null || v === undefined ? '' : (typeof v === 'object' ? (v.label ?? v.value ?? '') : v))
/** Count rows by a key accessor; collapse the long tail into 'Other'. */
export function aggregate(rows, keyFn, topN) {
const m = new Map()
for (const r of rows) { const k = keyFn(r); if (k === null || k === undefined || k === '') continue; m.set(k, (m.get(k) || 0) + 1) }
let arr = [...m.entries()].map(([key, value]) => ({ key, value })).sort((a, b) => b.value - a.value)
if (topN && arr.length > topN) {
const top = arr.slice(0, topN)
const other = arr.slice(topN).reduce((s, x) => s + x.value, 0)
if (other > 0) top.push({ key: 'Other', value: other, other: true })
arr = top
}
return arr
}
/**
* ★ THE ROLLUP BUCKET'S KEY IS A SENTINEL; ITS LABEL IS A TRANSLATION. Keep them apart.
*
* aggregate() pushes `{ key: OTHER_KEY, other: true }` for the tail, and 'Other' was rendered
* directly — so every locale showed the English word, in the facet rail and the charts rail both.
* check:i18n-keys-exist could not see it, because there is no T() call to inspect: the string was
* never a lookup, it was a key being used as a label.
*
* The KEY must not be translated. The rail tests `key === 'Other'` to make the row inert and
* unfilterable, and the charts rail refuses to filter on it — a localized key would make the bucket
* clickable in Arabic and silently filter on a value no row carries. That is the same value/label
* trap as the religion families, arriving in generic code this time, which is worse: unfixed it
* would have been exported to every future dashboard the moment this module became kit code.
*
* So the sentinel stays English and OTHER_LABEL() projects it for a reader, next to the function
* that produces it, so the two cannot drift apart.
*/
export const OTHER_KEY = 'Other'
export const otherLabel = () => tStatic('dashboard.other') || OTHER_KEY