Kit landing 4b: the rail stops reaching for research colours
The facet rail and the chip bar imported useTreeLegendColors and useTreeLegendLabels — the DOXA palettes and the People code tables. Those are research-specific by this kit's own reckoning and they STAY in plugin-designs, so that import is precisely 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, and rightly. So the dependency inverts rather than the file moving. The panels declare what they need — two functions — and the SHELL supplies them as dashboardConfig.legend. The shell is the right supplier because it is the only thing that already holds the config, the dataStore and the source id those composables require, and because a composable cannot be called at config-module scope. ON dashboardConfig, NOT A NEW INJECT KEY, because section 5.1 of the spec says a panel needing a new capability gets a new CONFIG key and never a new channel. It would have been easy to add a seventh inject key and quietly exempt myself from a rule I wrote; the rule is better than the exception. Merged rather than assigned, so a profile supplying its own legend wins. THE NO-LEGEND STATE IS HONEST, NOT A CRASH. A dashboard that supplies nothing gets uncoloured swatches and raw values — which is what go-int sees before it wires its palette, and a half-configured dashboard should look PLAIN rather than broken. The kit's neutral is its OWN constant and deliberately not the research palette's NEUTRAL, which happens to hold the same value today: importing it to save a literal would re-create the dependency this landing removes, and the research palette must stay free to change its own neutral without reaching in here. Big Picture is unchanged because the fallbacks fire only when legend is ABSENT, and for Big Picture the shell always supplies it. Every hex still comes from the same strategy modules the map's pins paint with, so the legend still cannot drift from the map — it is handed the strategies instead of fetching them. check:legend-mirrors grows arm 3: the research imports are gone, cfg.legend is read, and the two no-legend fallbacks are LIFTED OUT OF THE SFC AND DRIVEN, so "neutral swatch and raw value" is measured rather than asserted. Three mutants, all caught. What it deliberately does NOT do is mount the component — a no-op renderer cannot show a colour, so a mount would prove only "it did not throw" at the cost of a dev server in a gate that runs in milliseconds. The rendered result is rega's. ★ AND MY OWN CHECK FAILED ON MY OWN COMMENT, FOR THE FIFTH TIME. The comment beside the kit's neutral explains the decision by NAMING the research NEUTRAL, so testing raw source reported a coupling that does not exist. I had already fixed that 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. Source only. STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid.
This commit is contained in:
parent
281f9d1def
commit
22dc3ed770
4 changed files with 185 additions and 20 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue