doxa-maps/library/composables/legends/useAffinityBlockLegendData.js
Doxa Maps 6263c250ec C1a: the affinity-block and doxa-region palettes move to library/colors/
First palette migration from SPEC-LIBRARY-CURATION.md. Source only; studio rebuilds
(rule 5B — library/ plus four other owners' plugins).

WHY THIS ONE FIRST: the duplication was not idle. doxa-research-map's
affinity-block.js carried A018 'Tibetan Peoples' and A019 'Himalayan Peoples' —
live on the pray-tools API — with colours measured to WCAG 1.4.11's 3.0:1 non-text
floor (A013 5.28 unchanged, A018 7.69, A019 3.53; sibling separations 2.61/1.46/1.79).
country-posters' copy covered only A001–A017, so 134 rows (6.4% of 2,107) fell to the
grey fallback #95a5a6 — the "bunch of grey colours" report. exp-shepherd confirmed
that code path headlessly. So the correct copy wins with no judgement call, and the
move FIXES country-posters rather than changing it.

FOUR CONSUMERS, NOT THE TWO THE SPEC COUNTED, and finding that is the sharpest
argument for the migration: research-dashboard/index.js and dashboard-plus-grid/index.js
each imported `../doxa-research-map/src/colors/affinity-block.js` — a cross-plugin
RELATIVE import into another plugin's PRIVATE folder. Both now import @map/colors/.
Only the build found this; no gate names cross-plugin reach.

THE GLOB STAYS in both plugins' index.js, deliberately, and is documented there:
resource.js is still plugin-local pending C1b, so deleting the glob would unregister
it and colour those pins by the soft fallback — the same silent wrong-colour class
being fixed here. Deleting the two STALE FILES is what stops the stale copy winning.

THE REJECTED-CANDIDATE NOTE MOVED ABOVE `export const PALETTE`, and this is a real
constraint rather than tidying: check:one-palette slices from that export to END OF
FILE and matches every hex, despite its own comment claiming it reads "only the
PALETTE export". #AF7AC5 was rejected for A019 at 2.95 — under the accessibility
floor — and mentioned below the export it would have been DEMANDED of every consumer
bundle. The note keeps the reasoning without creating the obligation; the gate bug
itself is reported separately since internal/ is not mine to edit.

The legend needed no change and that is by design: useAffinityBlockLegendData resolves
via getColorStrategy(COLOR_MODES.AFFINITY_BLOCK) — the same strategy the pins use — and
reads labels from the data, so map and legend gain A018/A019 together. Its comments
claiming the strategy lives in a plugin folder were corrected; they now describe where
it actually is.

GATES: check:one-palette PASS — 8 strategies, 16 bundle/palette pairs (baselined at 6
and 12 before the move, so a red would have been mine). check:library-index PASS after
regenerating the derived menu. check:no-free-identifiers PASS. All four consumer
bundles build; their artifacts reverted for studio.
2026-09-26 08:39:53 +00:00

250 lines
12 KiB
JavaScript

/**
* useAffinityBlockLegendData — 4-tier affinity-block legend data for SemanticTreeLegend.
*
* Mirrors the public surface of useLanguageFamilyLegendData (rows, langTree-equiv,
* highlight) so research-map.vue can swap data sources by activeLegendType while
* reusing the SAME <SemanticTreeLegend> component (no duplicate legend).
*
* Tree shape: 4 tiers — Affinity Block → Cluster → People Group → People Group in Country.
*
* { id: 'bloc:A008', ← rop1
* label: 'Malay Peoples', color, count, pop, filter,
* children: [{
* id: 'cluster:A008__C0059', ← imb_reg_of_people_2
* label: 'Borneo-Kalimantan', ...,
* children: [{
* id: 'pg:A008__C0059__300005', ← imb_reg_of_people_25 (ROP 2.5)
* label: 'Abai Sungai', ...,
* children: [{
* id: 'pgic:abai-sungai', ← unique row (slug)
* label: 'Abai Sungai, Malaysia', ...,
* filter: ['==', ['get', 'uniqueId'], 'abai-sungai'],
* children: []
* }]
* }]
* }]
* }
*
* For most pgs, tier-3 → tier-4 fan-out is 1:1 (the people group exists in only
* one country). For the small number of pgs that span multiple countries
* (e.g. "Bantu Swahili" in 3 countries, JPID 15145), tier 3 has multiple tier-4
* children — researchers see "the same people group, multiple country instances"
* at a glance.
*
* Pin properties used in filter expressions (after DataSourceManager normalization):
* - properties.affinityBlock = rop1 value (e.g. 'A008')
* - properties.cluster = imb_reg_of_people_2 value (e.g. 'C0059')
* - properties.peopleGroup = imb_reg_of_people_25 value (e.g. '300005')
* - properties.uniqueId = slug (per-row identity)
*/
import { computed } from 'vue'
// Single source of truth — pins paint with this same palette, keyed by ROP1 code.
// Resolving the SAME strategy the pins use keeps legend ↔ map in lock-step (was a
// separate name-keyed palette causing pin/legend drift). affinity-block is a per-map
// strategy, which NOW LIVES IN library/colors/affinity-block.js (C1): it was private to
// plugin-designs/doxa-research-map/ and duplicated in country-posters, where the stale copy
// covered only A001-A017 and sent 134 Tibetan/Himalayan rows to the grey fallback.
// It is resolved via the registry, which now discovers it from library/colors/ for EVERY
// bundle rather than from one plugin's local glob -- that is the whole point of the move.
// Read LAZILY (at call time
// inside blocColor — this composable is only invoked by research surfaces) so
// registration has already run, and GUARDED on propertyKey — getColorStrategy
// silently falls back to language-family for unregistered modes, and that palette
// must not masquerade as bloc colors.
import { getColorStrategy, COLOR_MODES } from '../../colors/_registry.js'
const FALLBACK_COLOR = '#6b7280'
function pinPalette() {
const strat = getColorStrategy(COLOR_MODES.AFFINITY_BLOCK)
return (strat && strat.propertyKey === 'affinityBlock' && strat.palette) ? strat.palette : {}
}
function blocColor(blocCode, blocLabel) {
// Code-keyed lookup matches the pin paint expression exactly.
const palette = pinPalette()
if (blocCode && palette[blocCode]) return palette[blocCode]
return FALLBACK_COLOR
}
function readProps(item) { return item?.properties || item || {} }
function unwrap(v) {
if (v && typeof v === 'object' && 'label' in v) return v.label || v.value || ''
return v
}
function readBlocCode(p) { return String(p.affinityBlock || unwrap(p._raw?.rop1) || '').trim() }
// typeof null === 'object' — the JS trap that took down four research-map tabs
// (2026-09-16): an API record with rop1:null passed the typeof check and read
// null.label. Every extractor below now null-guards first.
function readBlocLabel(p) { return String(p.affinityBlockLabel || (p._raw?.rop1 && typeof p._raw.rop1 === 'object' ? p._raw.rop1.label : '') || '').trim() }
function readClusterCode(p) { return String(p.cluster || unwrap(p._raw?.imb_reg_of_people_2) || '').trim() }
function readClusterLabel(p) { return String(p.clusterLabel || (p._raw?.imb_reg_of_people_2 && typeof p._raw.imb_reg_of_people_2 === 'object' ? p._raw.imb_reg_of_people_2.label : '') || '').trim() }
function readPgCode(p) { return String(p.peopleGroup || unwrap(p._raw?.imb_reg_of_people_25) || '').trim() }
function readPgLabel(p) { return String(p.peopleGroupLabel || (p._raw?.imb_reg_of_people_25 && typeof p._raw.imb_reg_of_people_25 === 'object' ? p._raw.imb_reg_of_people_25.label : '') || p.name || '').trim() }
function readPgicId(p) { return String(p.uniqueId || p.slug || p._raw?.slug || '').trim() }
function readPgicName(p) { return String(p.name || p.displayName || p._raw?.name || '').trim() }
function readPop(p) {
const n = Number(p.population ?? p.populationNum ?? p._raw?.population ?? 0)
return Number.isFinite(n) ? n : 0
}
function readCountry(p) {
return String(p.countryName || p.country || p.countryIsoLabel || p.countryIso || '').trim()
}
export function useAffinityBlockLegendData(peopleGroupsRef) {
// 4-tier aggregate: bloc → cluster → people-group (rop25) → people-group-in-country (slug rows)
const aggregated = computed(() => {
const list = (peopleGroupsRef && peopleGroupsRef.value) || []
const blocs = new Map()
for (const item of list) {
const p = readProps(item)
const blocCode = readBlocCode(p)
const blocLabel = readBlocLabel(p) || blocCode || '— Unknown —'
const clCode = readClusterCode(p)
const clLabel = readClusterLabel(p) || clCode || '— Unknown —'
const pgCode = readPgCode(p)
const pgLabel = readPgLabel(p) || pgCode || '— Unknown —'
const pgicId = readPgicId(p)
const pgicName = readPgicName(p) || pgicId
const pop = readPop(p)
const country = readCountry(p)
if (!pgicId) continue
// Tier 1: bloc
let bloc = blocs.get(blocCode || '__unknown__')
if (!bloc) {
bloc = { code: blocCode, label: blocLabel, count: 0, pop: 0, memberIds: [], clusters: new Map() }
blocs.set(blocCode || '__unknown__', bloc)
}
bloc.count += 1
bloc.pop += pop
bloc.memberIds.push(pgicId)
// Tier 2: cluster
let cluster = bloc.clusters.get(clCode || '__unknown__')
if (!cluster) {
cluster = { code: clCode, label: clLabel, count: 0, pop: 0, memberIds: [], peopleGroups: new Map() }
bloc.clusters.set(clCode || '__unknown__', cluster)
}
cluster.count += 1
cluster.pop += pop
cluster.memberIds.push(pgicId)
// Tier 3: people group (rop25)
let pg = cluster.peopleGroups.get(pgCode || '__unknown__')
if (!pg) {
pg = { code: pgCode, label: pgLabel, count: 0, pop: 0, memberIds: [], instances: [] }
cluster.peopleGroups.set(pgCode || '__unknown__', pg)
}
pg.count += 1
pg.pop += pop
pg.memberIds.push(pgicId)
// Tier 4: people group in country (one entry per row)
pg.instances.push({ id: pgicId, name: pgicName, country, pop })
}
return blocs
})
// ── Row filter by MEMBER IDS (robust to code/label drift) ─────────────────
// The bloc/cluster/PG rows used to filter by CODE
// (['==',['get','affinityBlock'|'cluster'|'peopleGroup'], code]). That silently
// matched nothing on the map — the affinity tab DIMS via ['case', node.filter, 1, 0],
// so a non-matching filter drove EVERY pin to opacity 0 ("counts right, pins don't
// show"). The leaf (PGIC) rows already filter by uniqueId and worked, proving the
// per-pin uniqueId is the reliable key. So every tier now filters by the exact
// member uniqueIds we already aggregated — no dependency on which code/label form
// the pin properties happen to carry. ()
const memberFilter = (ids) =>
(ids && ids.length)
? ['in', ['get', 'uniqueId'], ['literal', ids]]
: ['==', ['get', 'uniqueId'], '__none__'] // matches nothing (never blanks all pins)
// ── affinityTree — generic semantic-tree shape for SemanticTreeLegend ─────
// Same shape contract as useLanguageFamilyLegendData.langTree:
// { id, label, color, count, pop, filter, children }
const affinityTree = computed(() => {
const blocs = aggregated.value
const out = []
for (const bloc of blocs.values()) {
const color = blocColor(bloc.code, bloc.label)
const blocNode = {
id: `bloc:${bloc.code || bloc.label}`,
label: bloc.label,
color,
count: bloc.count,
pop: bloc.pop,
filter: memberFilter(bloc.memberIds),
children: [],
}
for (const cluster of bloc.clusters.values()) {
const clusterNode = {
id: `cluster:${bloc.code}__${cluster.code || cluster.label}`,
label: `${cluster.label} (Cluster)`,
color,
count: cluster.count,
pop: cluster.pop,
filter: memberFilter(cluster.memberIds),
children: [],
}
for (const pg of cluster.peopleGroups.values()) {
const pgNode = {
id: `pg:${bloc.code}__${cluster.code}__${pg.code || pg.label}`,
label: `${pg.label} (PG)`,
color,
count: pg.count,
pop: pg.pop,
filter: memberFilter(pg.memberIds),
children: [],
}
// Tier 4: each row = one people-group-in-country instance.
// Sort by population desc so most-populous country instance leads.
pg.instances.sort((a, b) => (b.pop - a.pop) || a.country.localeCompare(b.country))
for (const inst of pg.instances) {
pgNode.children.push({
id: `pgic:${inst.id}`,
label: `${inst.country ? `${inst.name}, ${inst.country}` : inst.name} (PGIC)`,
color,
count: 1,
pop: inst.pop,
filter: ['==', ['get', 'uniqueId'], inst.id],
children: [],
})
}
clusterNode.children.push(pgNode)
}
// Sort people groups by count desc, then pop desc
clusterNode.children.sort((a, b) => (b.count - a.count) || (b.pop - a.pop))
blocNode.children.push(clusterNode)
}
// Sort clusters by count desc, then pop desc
blocNode.children.sort((a, b) => (b.count - a.count) || (b.pop - a.pop))
out.push(blocNode)
}
// Sort blocs by count desc, then pop desc
out.sort((a, b) => (b.count - a.count) || (b.pop - a.pop))
return out
})
// ── highlight — fires legend:highlight window event (parity with useLanguageFamilyLegendData) ──
function highlight(node) {
if (typeof window === 'undefined' || typeof window.CustomEvent !== 'function') return
if (!node) {
window.dispatchEvent(new CustomEvent('legend:highlight', { detail: { kind: null } }))
return
}
const id = String(node.id || '')
const detail = { kind: null, label: node.label, filterExpr: node.filter }
if (id.startsWith('bloc:')) detail.kind = 'bloc'
else if (id.startsWith('cluster:')) detail.kind = 'cluster'
else if (id.startsWith('pg:')) detail.kind = 'people-group'
else if (id.startsWith('pgic:')) detail.kind = 'people-group-in-country'
window.dispatchEvent(new CustomEvent('legend:highlight', { detail }))
}
return { aggregated, affinityTree, highlight }
}
export default useAffinityBlockLegendData