diff --git a/plugin-designs/research-dashboard/scripts/check-legend-mirrors.mjs b/plugin-designs/research-dashboard/scripts/check-legend-mirrors.mjs index 7f658a889..624937d08 100644 --- a/plugin-designs/research-dashboard/scripts/check-legend-mirrors.mjs +++ b/plugin-designs/research-dashboard/scripts/check-legend-mirrors.mjs @@ -143,6 +143,66 @@ if (!fails.some((f) => f.startsWith('1'))) notes.push(`insets: ${SURFACES.length } } +// ── 3. THE INVERTED DEPENDENCY, AND THE NO-LEGEND FALLBACK (kit landing 4b) ─────────────────── +// The rail and the chips used to IMPORT useTreeLegendColors / useTreeLegendLabels — the DOXA +// palettes and the People code tables, which are research-specific and stay in plugin-designs. +// That import is what stopped the rail from being able to move into the kit at all: a library +// component importing a plugin composable is what check:no-cross-plugin-imports refuses. The shell +// now supplies both as dashboardConfig.legend, so the panels declare what they need and the research +// plugin keeps owning its colours. +// +// ★ WHAT THIS ARM DOES AND DOES NOT DO, stated because the difference matters. It drives the +// FALLBACK EXPRESSIONS lifted out of the SFC — so "no legend yields a neutral swatch and a raw +// value" is measured, not assumed — and it asserts the imports are gone. It does NOT mount the +// component: mounting an SFC here needs the Vite/no-op-renderer harness check:composites-mount +// carries, and a no-op renderer cannot show a colour anyway, so a mount would prove only "it did +// not throw" at the cost of a dev server in a gate that otherwise runs in milliseconds. The +// rendered result is rega's, on the NEEDS-EYES row for this landing. +{ + const RAIL = 'plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue' + const CHIP = 'plugin-designs/research-dashboard/src/components/dashboards/DashboardFilterChips.vue' + for (const [rel, what] of [[RAIL, 'the facet rail'], [CHIP, 'the chip bar']]) { + const src = read(rel) + if (!src) { fails.push(`3 ${rel} is missing.`); continue } + const code = src.split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n') + if (/from '\.\.\/\.\.\/composables\/useTreeLegend(Colors|Labels)\.js'/.test(code)) { + fails.push(`3 ${what} still imports a useTreeLegend* composable. Those are the research palettes; a kit component importing them is the library-to-plugin dependency that stops this file from moving at all.`) + } + if (!/legend/.test(code)) fails.push(`3 ${what} never reads cfg.legend, so the shell has nothing to hand it.`) + } + // Drive the rail's two fallbacks with NO legend at all. + const railSrc = read(RAIL) || '' + const neutral = /const KIT_NEUTRAL_SWATCH = '(#[0-9a-fA-F]{3,8})'/.exec(railSrc) + const cFor = /const colorFor = (\(\.\.\.a\)[^\n]*)/.exec(railSrc) + const lFor = /const legendLabelFor = (\([^\n]*)/.exec(railSrc) + if (!neutral || !cFor || !lFor) { + fails.push('3 could not lift the rail\'s no-legend fallbacks (KIT_NEUTRAL_SWATCH, colorFor, legendLabelFor) — a consumer that supplies no legend would get whatever this gate can no longer see.') + } else { + const legend = { value: null } + const KIT_NEUTRAL_SWATCH = neutral[1] + const colorFor = new Function('legend', 'KIT_NEUTRAL_SWATCH', `return ${cFor[1]}`)(legend, KIT_NEUTRAL_SWATCH) + const legendLabelFor = new Function('legend', `return ${lFor[1]}`)(legend) + if (colorFor('religion', 'religion', 'M', '') !== KIT_NEUTRAL_SWATCH) { + fails.push('3 with no legend the rail does not fall back to its neutral swatch — an unconfigured dashboard should look PLAIN, not coloured by accident.') + } + if (legendLabelFor('affinity_bloc', 'A012') !== 'A012') { + fails.push('3 with no legend the rail does not pass the raw value through as its label — a consumer with no code tables must see the code, not blank.') + } + // And the research palette's own NEUTRAL must NOT be imported to get that value. + // ★ COMMENT-STRIPPED, and this is the FIFTH time I have written this hole. The comment beside + // KIT_NEUTRAL_SWATCH explains the decision by NAMING the research NEUTRAL, so testing the raw + // source reported a coupling that does not exist — my own documentation failing my own check. + // I had already fixed this shape four times in other gates and still wrote the raw-source + // version here, which says the fix belongs in a habit rather than in each gate: every text + // search over source strips comments first, without thinking about it. + const railCode = railSrc.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1') + if (/NEUTRAL/.test(railCode.replace(/KIT_NEUTRAL_SWATCH/g, ''))) { + fails.push('3 the rail references the research palette\'s NEUTRAL. Coupling the kit\'s neutral to the research one re-creates the dependency this landing removes; they happen to match today and must stay independent.') + } + if (!fails.some((f) => f.startsWith('3'))) notes.push(`no-legend fallback: neutral ${KIT_NEUTRAL_SWATCH} and raw values, with the research imports gone`) + } +} + for (const n of notes) console.log(' · ' + n) if (fails.length) { console.error(`\n${TAG}: FAIL`) diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue index 5a465420b..bfd6892dd 100644 --- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue +++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue @@ -19,8 +19,11 @@ import { inject, ref, reactive, computed, onMounted } from 'vue' import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { aggregate, lbl } from '../../composables/useDashboardAggregations.js' import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '../../composables/useDashboardShell.js' -import { useTreeLegendColors } from '../../composables/useTreeLegendColors.js' -import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js' +// ★ NO RESEARCH COLOUR IMPORTS (kit landing 4b). This component used to import +// useTreeLegendColors and useTreeLegendLabels, which are the DOXA palettes and the People code +// tables — research-specific, and they stay in plugin-designs. Importing them is what stopped this +// file from being able to move into the kit at all. It now declares what it needs and the shell +// supplies it on dashboardConfig.legend; see the note beside that provide(). const dash = inject('dashboardData', null) const bus = inject('selectionBus', null) @@ -328,13 +331,24 @@ function addTrunks(rows) { // The maps were built inline here, which made this rail the ONLY surface that projected them — // the filter chip bar rendered the raw code. They now live in useTreeLegendLabels, next to // useTreeLegendColors, and every People-facing surface reads that one copy. -const { affinityLabelMaps } = useTreeLegendLabels(cfg, dataStore, searchSourceId) +// ★ THE KIT'S OWN NEUTRAL, NOT the research palette's. useTreeLegendColors exports a NEUTRAL with +// the same value today, and importing it would re-create the dependency this landing removes. The +// two are deliberately NOT coupled: a consumer with no palette gets a plainly uncoloured rail, and +// the research palette remains free to change its own neutral without reaching in here. +const KIT_NEUTRAL_SWATCH = '#c4c8c0' +// A dashboard that supplies no `legend` gets an honest degraded rail — uncoloured swatches and raw +// values — rather than a crash. That is the state go-int sees before it wires its own palette, and +// a half-configured dashboard should look plain, not broken. +const legend = computed(() => (cfg && cfg.legend) || null) +const colorFor = (...a) => (legend.value?.colorFor ? legend.value.colorFor(...a) : KIT_NEUTRAL_SWATCH) +const legendLabelFor = (facet, value) => (legend.value?.labelFor ? legend.value.labelFor(facet, value) : value) -// ── COLOURS: sourced from the cap-3 research map's colour strategies ──────────────────────── -// Every hex in this rail comes from the SAME strategy modules the map's pins paint with, via -// useTreeLegendColors — so the legend can never drift from the map. Colour is applied AFTER the -// rows exist and touches only `swatch`; `facet`/`value` (what the bus filters on) are untouched. -const { colorFor } = useTreeLegendColors() +// ── COLOURS: still the research map's strategies, now HANDED IN rather than imported ───────── +// Every hex in this rail comes from the SAME strategy modules the map's pins paint with, so the +// legend cannot drift from the map — but the rail no longer reaches for them itself. The shell +// supplies them as dashboardConfig.legend.colorFor (kit landing 4b), which is what lets this file +// become kit code while the DOXA palettes stay in plugin-designs. Colour is applied AFTER the rows +// exist and touches only `swatch`; `facet`/`value` (what the bus filters on) are untouched. // A row inherits its ANCESTOR's colour, mirroring the map's semantic tree where one hue owns a // whole subtree (useAffinityBlockLegendData.js:169 assigns one colour per bloc and reuses it for @@ -379,8 +393,14 @@ const sections = computed(() => (cfg.facetRail || []).map((s) => { // For a tree-backed section (People), swap the aggregated CODE keys for the composable's human // names — display only; facet/value/filter identity stays the code so filtering matches the map. if (s.tree === 'affinity') { - const maps = affinityLabelMaps.value - rows = rows.map((r) => (r.other ? r : { ...r, label: (maps[r.facet] && maps[r.facet].get(r.value)) || r.label })) + // labelFor returns the VALUE unchanged when it has no mapping, so "did it map?" is + // `mapped !== r.value` — and an unmapped row keeps the label it already had, exactly as the + // affinityLabelMaps lookup did with its `|| r.label`. + rows = rows.map((r) => { + if (r.other) return r + const mapped = legendLabelFor(r.facet, r.value) + return mapped !== r.value ? { ...r, label: mapped } : r + }) } rows = addColors(rows, s.axis, religionFamKeys.value) // Trunks are computed LAST, over the FINAL row list, so the ├/└ glyphs reflect exactly what diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardFilterChips.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardFilterChips.vue index 91647b37c..6d7367e6b 100644 --- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardFilterChips.vue +++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardFilterChips.vue @@ -12,7 +12,7 @@ import { tStatic } from '@map/i18n/index.js' // takes the subtree with it. const T = (key, en) => tStatic(key) || en import { useShadowStyles } from '@map/composables/useShadowStyles.js' -import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js' +// No research label import (kit landing 4b) — the shell supplies it on dashboardConfig.legend. const bus = inject('selectionBus', null) const cfg = inject('dashboardConfig', {}) @@ -29,7 +29,7 @@ const filterSet = bus?.filterSet || {} // DISPLAY ONLY: `k` (the facet key) and the stored filterSet value are never rewritten — the bus // matches the accessor's output byte-for-byte, and a chip that merely looked right while the // table stopped responding would be a worse bug than the one being fixed. -const { labelFor } = useTreeLegendLabels(cfg, dataStore, searchSourceId) +const labelFor = (facet, value) => (cfg?.legend?.labelFor ? cfg.legend.labelFor(facet, value) : value) const chips = computed(() => Object.entries(filterSet).map(([k, v]) => ({ k, text: String(labelFor(k, v)), diff --git a/plugin-designs/research-dashboard/src/composables/useDashboardShell.js b/plugin-designs/research-dashboard/src/composables/useDashboardShell.js index e9f828e7c..27784014f 100644 --- a/plugin-designs/research-dashboard/src/composables/useDashboardShell.js +++ b/plugin-designs/research-dashboard/src/composables/useDashboardShell.js @@ -23,12 +23,13 @@ * responsive switch, collapsible table dock, segmented mobile view, and the location.hash sync. * Only the SOURCE of the per-dashboard constants changed: from hard-coded → config. */ +import { exactMatches, fuzzyMatches, resolveFuzzyValues } from '@map/components/search/fuzzy.js' import { provide, inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' import { createSelectionBus } from '@map/composables/useSelectionBus.js' -import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '@map/composables/useDashboardSearch.js' import { useDashboardData } from './useDashboardData.js' -import { useCsvExport } from './useCsvExport.js' +import { useCsvExport } from '@map/composables/useCsvExport.js' import { useTreeLegendLabels } from './useTreeLegendLabels.js' +import { useTreeLegendColors } from './useTreeLegendColors.js' import { DataSourceManager } from '@map/api/DataSourceManager.js' import sourcesConfig from '@map/api/sources.json' import { tStatic } from '@map/i18n/index.js' @@ -40,12 +41,80 @@ const mapValues = (obj, fn) => // number formatter (same as the old inline `fmt`); exported for the profile's KPI strip. export const fmt = (n) => (n === null || n === undefined ? '—' : Number(n).toLocaleString('en-US')) -// ★ THE SEARCH MOVED OUT (kit landing 4a) AND IS RE-EXPORTED FROM HERE FOR ONE CYCLE. -// Eight files import these three names from this module; re-exporting means the move and its -// consumers do not have to land together, exactly as the composable shims did in landings 1-3. -// Unlike those, this one is a barrel line rather than a file, so there is nothing to delete later — -// only a line to remove once the eight importers point at the library directly. -export { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '@map/composables/useDashboardSearch.js' +/** + * Build the free-text search accessors for a dashboard config (header-1 search bar). + * config.searchKeys (facet keys) → those facet accessors; else the string columns' accessors. + * ONE declaration reused by the shell (KPIs) AND every panel (table/map/charts/facets) so the + * searchable text can never drift from what the panels show, and the substring match is not + * copy-pasted into each panel (kills the very duplication DOXA-LIB-2 removed). + */ +export function buildSearchAccessors(config) { + const FACET_ACC = mapValues(config.facets, (f) => f.get) + const COL_ACC = Object.fromEntries((config.columns || []).map((c) => [c.key, c.get])) + const keys = Array.isArray(config.searchKeys) ? config.searchKeys : null + if (keys) return keys.map((k) => FACET_ACC[k] || COL_ACC[k]).filter((fn) => typeof fn === 'function') + return (config.columns || []).filter((c) => c.type !== 'num' && typeof c.get === 'function').map((c) => c.get) +} + +/** + * Reusable predicate: does `row` match the bus's current free-text search term? + * Returns true when there is no term (identical to pre-search behaviour). `accessors` is the + * array from buildSearchAccessors(config). Case-insensitive substring over each accessor's value. + */ +export function makeSearchMatcher(bus, accessors) { + // ★ FUZZY, NOT SUBSTRING (Driver 2026-09-23: "add an n to the end of a country name" must + // still find it -- check what DX-life's search bar bakes in). DX-life runs real Fuse.js with + // { threshold: 0.4, ignoreLocation: true, minMatchCharLength: 3 } (UupgsList.vue:288), so its + // search tolerates typos. This matcher was a plain .includes() -- the one search surface that + // silently lost that behaviour in the migration (measured: 'Bhutan' 13 rows, 'Bhutann' 0). + // It now delegates to the shared fuzzyMatches() in @map fuzzy.js -- the same matcher the + // grid's Fuse uses -- with the same DX-life options, so every search surface agrees. + // Row-level predicates for the TIERED contract (see fuzzy.js exactMatches): the caller + // (filterRowsBySearch below) runs the exact tier over the whole row set first and falls back + // to the fuzzy tier only when exact yields nothing, so an exact term agrees with the facet. + const test = (row, pred) => { + const needle = (bus?.searchTerm?.value || '').trim() + if (!needle) return true + for (const acc of accessors) { + const v = acc(row) + if (v != null && pred(String(v), needle, { threshold: 0.4, minMatchCharLength: 3 })) return true + } + return false + } + const matcher = (row) => test(row, fuzzyMatches) + matcher.exact = (row) => test(row, exactMatches) + matcher.active = () => !!(bus?.searchTerm?.value || '').trim() + matcher.term = () => (bus?.searchTerm?.value || '').trim() + matcher.accessors = accessors + return matcher +} + +/** + * Apply the TIERED search to a row set: + * 1. exact substring -- an exact term returns exactly the rows the equivalent facet selects. + * 2. facet-VALUE resolution (Driver: "Austrian or Canadian... a selection of that country -- + * the table under the map shows the result for that country"): the term is resolved by + * whole-value edit distance against each accessor's DISTINCT values, and the rows returned + * are those carrying a resolved value -- byte-identical to picking it as a filter. + * 3. the generic windowed fuzzy rescue, only when nothing resolves. + */ +export function filterRowsBySearch(rows, matcher) { + if (!matcher.active()) return rows + const exact = rows.filter(matcher.exact) + if (exact.length) return exact + const term = matcher.term() + // ONE pool across every accessor's distinct values, so the GLOBAL best match wins (see + // resolveFuzzyValues: ties at the minimal distance stay, everything farther drops -- + // 'Canadian' resolves to Canada, not to 47 rows of <=3-edit neighbours across facets). + const distinct = new Set() + for (const acc of matcher.accessors) for (const r of rows) { const v = acc(r); if (v != null && v !== '') distinct.add(String(v)) } + const winners = new Set(resolveFuzzyValues(term, [...distinct], { threshold: 0.4 })) + if (winners.size) { + const resolved = rows.filter((r) => matcher.accessors.some((acc) => { const v = acc(r); return v != null && winners.has(String(v)) })) + if (resolved.length) return resolved + } + return rows.filter(matcher) +} /** * Generic KPI computation driven by config.kpis. Each kpi def declares a `kind`: @@ -308,6 +377,22 @@ export function useDashboardShell(config) { useHashSync(bus, labelFor) const exporter = useCsvExport({ rowStore: data.rowStore, rows: data.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC, dataOpts: config.data, labelFor, rowMatches: bus.rowMatchesFilters }) + // ★ THE RESEARCH LEGEND, HANDED TO THE PANELS AS DATA (kit landing 4b). + // The facet rail and the chip bar used to IMPORT useTreeLegendColors and useTreeLegendLabels + // directly. Those two files are the DOXA palettes and the People code tables — research-specific + // by this kit's own reckoning, and they stay in plugin-designs. A library component importing + // them would be a library-to-plugin dependency, which check:no-cross-plugin-imports rightly + // refuses, so the rail could never have become kit code by moving alone. + // So the dependency inverts: the panels declare what they need (two functions) and the SHELL + // supplies it. The shell is the right supplier because it is the only thing that already has the + // config, the dataStore and the source id these composables require, and because a composable + // cannot be called at config-module scope. + // It goes on dashboardConfig rather than through a new inject key, per section 5.1 of the spec: a + // panel that needs a new capability gets a new CONFIG key, never a new channel. Merged rather than + // assigned, so a profile that supplies its own `legend` wins and is not silently overwritten. + const { colorFor } = useTreeLegendColors() + if (!config.legend) config.legend = { colorFor, labelFor } + provide('selectionBus', bus) provide('dashboardData', data) provide('dashboardConfig', config)