D1: a filter may be several values, and one function decides what matches
SPEC-DASHBOARD-KIT D1. filterSet[key] may hold an array meaning "any of these";
keys still AND together. The array support is four lines. The reason this is a
landing rather than a patch is the other half.
`a(r) === filterSet[k]` WAS WRITTEN OUT SEVEN TIMES — the shell's filteredRows,
the facet rail twice, the map panel, the table, the charts rail and the CSV
exporter. Seven copies of a one-character rule are survivable while the rule
stays one character. The moment it grows an array case, six of the seven keep
comparing an array with ===, every one of them quietly matches NOTHING, and the
panels disagree with each other about what is on screen. That is not abstract:
the facet rail computes the COUNTS a reader acts on before clicking, so a rail
still using === would print counts for a scope the map and table no longer show.
So the predicate now lives in the bus and every panel calls it. The
de-duplication is what makes the four lines safe.
WHAT A CLICK DOES IS UNCHANGED, deliberately. A plain click still REPLACES. D1
gives the bus the capability; making a rail row additive on an unmodified click
would change what every existing click does to the flagship, which is a product
decision and not this one. The array path is reached programmatically today.
AND THAT PATH IS THE PAYOFF: "Arabic" now narrows the grid. The language tier
covers 22 ISO codes, so no single value expressed it, and the composite had to
send nothing and leave the axis unfiltered — honest, and visibly incomplete,
because the map narrowed and the grid did not. The whole code set now crosses and
the grid ORs it. Verified per tier on the committed fixture rather than on one
example: for all 805 tiers the joined set selects exactly the rows the map
selected.
★ THE SEPARATOR IS `|`, AND MY FIRST DRAFT GOT THAT WRONG IN A WAY WORTH
RECORDING. I joined on `,` and broke label matching outright: 120 of the 864 live
language labels are comma-inverted ("Arabic, Mesopotamian"), so a selection made
BY LABEL was chopped in half and matched nothing. A separator that can also occur
inside a value is not a separator — the rule the URL hash states one file over,
which I had just written and then violated. check:filter-multivalue caught it
before it left this machine, and it caught it because it asserts a behaviour I
was KEEPING (label matching) and not only the one I was adding. Asserting only
the new behaviour would have shipped it.
EMPTY IS NOT A FILTER. An empty array clears the key rather than matching
nothing — a UI deselecting its last chip produces exactly that array, and
treating it as "narrow to zero" is the silent-empty-screen failure the hierarchy
contract already exists to prevent. A one-element array collapses to the scalar
it means, so the hash, the chips and every adapter keep their existing shape
until somebody actually picks two.
THE SURFACES THAT CANNOT SHOW A LIST SAY SO HONESTLY. labelFor resolves each
value and joins them, at the one owner, so the chip bar, the research-filter
chips and the CSV provenance line cannot drift apart. A dropdown shows NO
selection when the key holds several: ticking the first of three would state
something false about the board, which is worse than stating nothing, and the
chip still names every value so the selection is never invisible. The URL hash
carries a list and drops the display slug for it — the slug is one human name for
one code, and there is no honest slug for twenty-two.
check:filter-multivalue runs the real bus, greps the panels for surviving copies
of the predicate, round-trips the hash including a value containing a comma, and
lifts the grid's valueMatches to prove both ends OR the same way. No browser.
Green: check:filter-multivalue, check:language-code-link, check:facet-cascade and
its self-test, check:kpi-unit, check:csv-shape, check:no-free-identifiers,
check:sfc-parse, check:mjs-parse, check:descriptions, check:no-orphan-gates,
check:doc-scripts, check:script-flags, check:quoted-scripts.
SOURCE ONLY. Stacked on kit landing 1, which moved the bus into library/.
STUDIO: needs a rebuild of research-dashboard, dashboard-plus-grid,
doxa-research-map, research-map-plus-grid and people-groups-grid. package.json is
main's copy plus two lines beside check:fits-screen.
This commit is contained in:
parent
5d82899d18
commit
f79d8f48e2
13 changed files with 328 additions and 25 deletions
|
|
@ -134,6 +134,15 @@ export function createSelectionBus() {
|
|||
* per cleared tier (same batching discipline as commitSelection).
|
||||
*/
|
||||
function setFilter(key, value) {
|
||||
// ★ AN ARRAY MEANS OR-WITHIN-KEY (SPEC-DASHBOARD-KIT D1). An EMPTY array is not a filter that
|
||||
// matches nothing — it is no filter, and it clears like null. Treating [] as "narrow to zero"
|
||||
// is the same silent-empty-screen failure the hierarchy contract above exists to prevent, and
|
||||
// a UI that deselects its last chip produces exactly that array.
|
||||
if (Array.isArray(value) && value.length === 0) value = null
|
||||
// A one-element array is stored as the SCALAR it means. The hash, the chips and every adapter
|
||||
// then keep their existing single-value shape for the overwhelmingly common case, so D1 costs
|
||||
// nothing to read until somebody actually picks two.
|
||||
if (Array.isArray(value) && value.length === 1) value = value[0]
|
||||
if (value === null || value === undefined) {
|
||||
delete filterSet[key]
|
||||
// transitive: childFacets[key] already holds the full descendant closure
|
||||
|
|
@ -216,5 +225,55 @@ export function createSelectionBus() {
|
|||
filterSet, selectionSet, selectionVersion, searchTerm, on, emit,
|
||||
setFilter, clearFilters, setSearch, configureHierarchy,
|
||||
toggleSelect, clearSelection, selectMany, deselectMany, setSelection,
|
||||
toggleFilterValue, facetMatches, isFilterActive, rowMatchesFilters,
|
||||
}
|
||||
|
||||
// ── THE PREDICATE, AND WHY IT LIVES HERE ───────────────────────────────────────────────────
|
||||
// `a(r) === filterSet[k]` was written out SEVEN times: the shell's filteredRows, the facet
|
||||
// rail twice, the map panel, the table, the charts rail and the CSV exporter. Seven copies of
|
||||
// one rule is survivable while the rule is one character long. It stops being survivable the
|
||||
// moment the rule grows an array case — six of the seven would keep comparing an array with
|
||||
// `===`, every one of them would quietly match nothing, and the six panels would disagree with
|
||||
// each other about what is on screen. That is not a hypothetical: the facet rail computes the
|
||||
// COUNTS the user reads before clicking, so a rail that still used `===` would print counts for
|
||||
// a scope the map and table no longer show.
|
||||
//
|
||||
// So D1 is not only "filterSet may hold an array". It is "there is one function that answers
|
||||
// does this row match, and every panel calls it". The array support is four lines; the
|
||||
// de-duplication is the part that makes it safe.
|
||||
function facetMatches(rowValue, selected) {
|
||||
if (selected === null || selected === undefined) return true
|
||||
// OR within the key. Compared with ===, exactly as the scalar path always has been: the bus
|
||||
// stores whatever the facet accessor produced, and a looser comparison here would silently
|
||||
// widen every existing filter.
|
||||
if (Array.isArray(selected)) return selected.some((v) => rowValue === v)
|
||||
return rowValue === selected
|
||||
}
|
||||
/** Does `row` survive every active facet? `ACC` is facetKey -> accessor (the shell's FACET_ACC).
|
||||
* A key with no accessor is skipped, not failed — a facet declared for the rail but not for the
|
||||
* data must not empty the board. */
|
||||
function rowMatchesFilters(row, ACC) {
|
||||
for (const k of Object.keys(filterSet)) {
|
||||
const a = ACC && ACC[k]
|
||||
if (a && !facetMatches(a(row), filterSet[k])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
/** Is `value` part of the current selection for `key`? The UI's "is this row lit" question,
|
||||
* which was `filterSet[facet] === value` in four places and has the same array problem. */
|
||||
function isFilterActive(key, value) {
|
||||
const cur = filterSet[key]
|
||||
if (cur === null || cur === undefined) return false
|
||||
return Array.isArray(cur) ? cur.includes(value) : cur === value
|
||||
}
|
||||
/** Add or remove ONE value from a key's selection — the multi-select primitive a rail row or a
|
||||
* chart slice needs. Removing the last value clears the key (and cascades), so there is no way
|
||||
* to arrive at an empty array through the UI. */
|
||||
function toggleFilterValue(key, value) {
|
||||
const cur = filterSet[key]
|
||||
if (cur === null || cur === undefined) return setFilter(key, value)
|
||||
const list = Array.isArray(cur) ? cur : [cur]
|
||||
const next = list.includes(value) ? list.filter((v) => v !== value) : list.concat(value)
|
||||
setFilter(key, next.length ? next : null)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@
|
|||
"check:facet-one-field": "node internal/check-facet-one-field.mjs",
|
||||
"check:facet-one-field:self-test": "node internal/check-facet-one-field.mjs --self-test",
|
||||
"check:filter-width": "node internal/check-filter-width-stable.mjs",
|
||||
"check:filter-multivalue": "node plugin-designs/research-map-plus-grid/scripts/check-filter-multivalue.mjs",
|
||||
"check:fits-screen": "node internal/check-fits-the-screen.mjs",
|
||||
"check:fits-screen:self-test": "node internal/check-fits-the-screen.mjs --self-test",
|
||||
"check:fits-screen:self-test:b": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=b",
|
||||
|
|
@ -304,6 +305,7 @@
|
|||
"check:facet-changes-map:built": "the SAME gate against the BUILT tree instead of the dev server. Measured 2026-09-24: the dev default is 1 PASS / 3 FAIL in four runs on a busy box (88 unbundled module requests vs the built leaf's 3), while the built tree passes deterministically. Use this when you need a trustworthy answer; use the dev default when checking uncommitted source.",
|
||||
"check:facet-changes-map:self-test": "reverts the fix IN THE LIVE PAGE and requires the gate to FAIL. SELF_TEST_MODE=colour (default, isolates the colour assertion — the reported bug) | props | both | frozen (blocks the source update, isolating the set assertion). All four modes verified caught.",
|
||||
"check:facet-one-field": "ONE AXIS = ONE CONTROL-REACHABLE FACET. Asserts on the PARTITION each facet induces over the rows, NOT on which r.<field> it reads: three fields (religion, primary_language, rop1) are legitimately read by two facets each as tier-1/tier-2 pairs, so a gate on duplicate field READS would fail correct code. Scoped to facets a user can actually select (rail + dropdowns + charts), because a search-only projection sharing a partition is fine until it becomes a control. run check:facet-one-field:self-test for the NEGATIVE test.",
|
||||
"check:filter-multivalue": "SPEC-DASHBOARD-KIT D1: filterSet[key] may hold an array meaning OR-within-key, keys still AND together — and exactly ONE function answers 'does this row match'. The rule `a(r) === filterSet[k]` was written out SEVEN times (shell, facet rail x2, map panel, table, charts rail, CSV exporter); the moment it grew an array case, six copies would have kept comparing an array with === , matched nothing, and left the panels disagreeing about what is on screen — the facet rail printing counts for a scope the map and table no longer render. Runs the real bus (OR within, AND across, one-element array collapses to a scalar, EMPTY array clears rather than matching nothing, toggleFilterValue adds/removes/clears, cascade intact), greps the panels for surviving copies, round-trips the URL hash including a value containing a comma, and lifts the grid's valueMatches to prove both ends OR the same way. The list separator is `|` and not `,` because 120 of the 864 live language labels contain a comma. No browser.",
|
||||
"check:fits-screen": "THE FITNESS-FOR-USE GATE (LL-the-user-found-three-bugs-my-gates-could-not-see): a green STATE suite (mounts/fetches/counts/paints) said nothing about whether the product is USABLE, and the Driver found five real defects in one sitting that no gate saw. Asserts 5 clauses on the built dashboard-plus-grid composite + research-dashboard leaf, real browser, live data: (A) the dashboard band fits ONE 1440x900 viewport and is shorter than its own tree-legend's scrollHeight; (B) the people-groups grid is still reachable by scrolling underneath it (a fix for A must not re-break this — AP-fixing-reachability-by-making-the-thing-taller); (C) a wheel over the map changes ZOOM and does not scroll the pane, a wheel below it scrolls and does not zoom — instrumented on the DISPATCHED EVENT (composedPath), not geometry, per LL-four-wrong-diagnoses-instrument-the-event-not-the-geometry; (D) the grid's live count ([data-table-count], the composite's documented single source of truth — show-total=false on the grid child is deliberate) is visible and CHANGES when a facet narrows it; (E) a 1440/1280/768/390 responsive sweep has no horizontal document overflow and no element clipped to zero height (.rd-td/.rd-th ellipsis excluded — expected). Spawns its own serve-strict on PORT (default 4391); SKIPs (exit 0) if the tree is not settled at START OR re-settled at END, or pray.doxa.life is unreachable — never reports a defect from a torn tree. Also best-effort probes any cap-5 shell the manifest declares (type nested-mfe-spa) by CONTENT-MATCHING a tab, never by hardcoded structure, so it survives the shell's own in-flight restructure without depending on it. Needs a built tree (`bun run build`). run `check:fits-screen:self-test` for the NEGATIVE test.",
|
||||
"check:fits-screen:self-test": "FIVE regression modes, one per clause, each required to fire ITS OWN [A]/[B]/[C]/[D]/[E]-named failure: run clause a via THIS entry and b-e via `check:fits-screen:self-test:<b|c|d|e>`. a (default — forces the dashboard section past the viewport, the AP-fixing-reachability shape), b (n/a here — B is proven by A's own poison already trapping the grid below the fold; kept as a mode for a future independent poison), c (freezes map.scrollZoom enabled, the BUG-MAP-EATS-THE-WHEEL regression), d (blanks the live count element), e (forces the shell wider than the viewport at 1440). A gate that has only ever printed PASS is unproven.",
|
||||
"check:fits-screen:self-test:b": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
|
|
|
|||
|
|
@ -40,8 +40,11 @@
|
|||
* next bug, so the gate compares the two that exist.
|
||||
* C. for every base tier: exactly one code -> a NON-EMPTY languageCode; several -> an EMPTY
|
||||
* languageCode AND a populated languageCodes.
|
||||
* D. the composite fails OPEN: gridLanguage() must return '' when the selection covers several
|
||||
* codes. Returning the label there is what produced the empty grid.
|
||||
* D. the composite sends the WHOLE code set when a selection covers several (SPEC-DASHBOARD-KIT
|
||||
* D1), and never the tier NAME. Before D1 the honest answer was to abstain, because the grid
|
||||
* could hold one value; now that it takes a list, abstaining would leave a selection
|
||||
* unexpressed that the receiving end can express. Asserted per tier: the joined set must
|
||||
* select exactly the rows the map selected.
|
||||
* E. no tier may produce a value that selects zero rows. That is the user-visible invariant and
|
||||
* it is asserted directly, not inferred.
|
||||
*
|
||||
|
|
@ -169,12 +172,21 @@ if (matchesLanguage) {
|
|||
many++
|
||||
if (languageCode) fails.push(`C "${base}" covers ${codes.length} codes but still emitted a single languageCode — one of them would show a fraction of the selection while looking correct.`)
|
||||
if (!codes.length) fails.push(`C "${base}" resolved no codes at all.`)
|
||||
// D1: the SET is what the grid now filters on, so it has to select exactly the rows the map
|
||||
// selected. Asserted per tier rather than on one example, because a join that dropped or
|
||||
// duplicated a code would still look plausible on "Arabic".
|
||||
const joined = codes.join('|')
|
||||
const wants = new Set(joined.split('|'))
|
||||
const gridRows = rows.filter((r) => wants.has(r.primary_language.value))
|
||||
if (gridRows.length !== selected.length) {
|
||||
fails.push(`C "${base}": the map selects ${selected.length} row(s) but the joined code set selects ${gridRows.length} — the two ends of the link disagree.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (zeroSelecting.length) {
|
||||
fails.push(`E ${zeroSelecting.length} language tier(s) select ZERO rows, so clicking them empties the grid: ${zeroSelecting.slice(0, 6).join(', ')}`)
|
||||
}
|
||||
notes.push(`tiers: ${byBase.size} — ${single} resolve one code (the grid filters), ${many} cover several (the axis is left open)`)
|
||||
notes.push(`tiers: ${byBase.size} — ${single} resolve one code, ${many} cover several and cross as the whole set (D1); every tier narrows the grid to the rows the map selected`)
|
||||
if (many === 0) fails.push('C no tier in the fixture covers several codes, so the ambiguous path is untested — the fixture was sampled rather than deduplicated.')
|
||||
}
|
||||
|
||||
|
|
@ -189,9 +201,17 @@ if (matchesLanguage) {
|
|||
if (fn) {
|
||||
const many = { language: { value: '', label: 'Arabic', codes: ['apd', 'acm', 'ayl'] } }
|
||||
const one = { language: { value: 'ben', label: 'Bengali', codes: ['ben'] } }
|
||||
if (fn(many) !== '') fails.push(`D gridLanguage() returned ${JSON.stringify(fn(many))} for a selection covering 3 codes — it must return '' and leave the axis unfiltered. Returning the label is what took the grid to 0.`)
|
||||
// ★ UPDATED FOR D1. This used to require '' for a multi-code tier, and that was the right
|
||||
// answer while the grid could hold one value: the map narrowed, the grid did not, and the
|
||||
// reader could see both. Now that valueMatches() takes a LIST, abstaining would be leaving a
|
||||
// selection unexpressed that the receiving end can express — so the whole code set crosses.
|
||||
// What has NOT changed, and is asserted below, is that it never sends the tier NAME: "Arabic"
|
||||
// matches no row reading "Arabic, Sudanese", and that is what emptied the grid originally.
|
||||
const got = fn(many)
|
||||
if (got !== 'apd|acm|ayl') fails.push(`D gridLanguage() returned ${JSON.stringify(got)} for a selection covering 3 codes — it must send all three, pipe-joined, so the grid narrows to the same rows the map did. (`|` and not `,`: a language label routinely contains a comma.)`)
|
||||
if (/[A-Z]/.test(String(got))) fails.push(`D gridLanguage() returned ${JSON.stringify(got)}, which looks like a display NAME rather than codes — a tier name matches no row and empties the grid.`)
|
||||
if (fn(one) !== 'ben') fails.push(`D gridLanguage() returned ${JSON.stringify(fn(one))} for an unambiguous selection — it must send the single code.`)
|
||||
if (!fails.some((f) => f.startsWith('D'))) notes.push('composite: gridLanguage() sends the code when there is one and abstains when there is not')
|
||||
if (!fails.some((f) => f.startsWith('D'))) notes.push('composite: gridLanguage() sends the single code, or every code in the selection — never the tier name')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -580,11 +580,32 @@ const DERIVED_AXES = {
|
|||
}
|
||||
// ONE resolver for both filter loops: a derived key runs its accessor, a real key reads the row.
|
||||
const rowAxisValue = (u, field) => (DERIVED_AXES[field] ? DERIVED_AXES[field](u) : u[field])
|
||||
// ★ A SELECTION MAY BE SEVERAL VALUES (SPEC-DASHBOARD-KIT D1), and this is the end of the wire
|
||||
// that makes the "Arabic" case a real narrowing instead of an honest abstention. The language tier
|
||||
// "Arabic" covers 22 ISO codes, so no single value expresses it; until now the composite had to
|
||||
// send nothing and leave the axis unfiltered rather than show a fraction of the selection or an
|
||||
// empty grid. A comma-separated list is accepted here and matched as OR-within-the-axis, which is
|
||||
// exactly what the bus's facetMatches() does at the other end — one rule, two implementations,
|
||||
// and they have to stay the same shape.
|
||||
// ★ THE SEPARATOR IS `|`, AND IT IS NOT `,` FOR A MEASURED REASON. A separator that can also occur
|
||||
// inside a value is not a separator — the rule the URL hash already states one file over. The first
|
||||
// version of this split on `,` and BROKE LABEL MATCHING, because 40% of the live language labels
|
||||
// are comma-inverted ("Arabic, Mesopotamian"): the selection was chopped into "Arabic" and
|
||||
// "Mesopotamian" and matched neither. Caught by check:filter-multivalue's own label assertion
|
||||
// before it left this machine, which is the entire point of asserting a behaviour you are keeping
|
||||
// rather than only the one you are adding. Measured on the 864 live label/value pairs: `,` appears
|
||||
// in 120 of them, `|` in none.
|
||||
// A HOST STILL SENDS ONE STRING: an attribute is a string, so a list arrives as "apd|acm|ayl", and
|
||||
// a single value with no separator takes the identical path it always did.
|
||||
function valueMatches(vl, selection) {
|
||||
if (!vl) return false
|
||||
const want = norm(selection?.value)
|
||||
if (!want) return false
|
||||
return norm(vl.value) === want || norm(vl.label) === want
|
||||
const raw = selection?.value
|
||||
if (raw === null || raw === undefined || raw === '') return false
|
||||
const wants = (Array.isArray(raw) ? raw : String(raw).split('|'))
|
||||
.map((v) => norm(v)).filter(Boolean)
|
||||
if (!wants.length) return false
|
||||
const value = norm(vl.value), label = norm(vl.label)
|
||||
return wants.some((want) => value === want || label === want)
|
||||
}
|
||||
|
||||
function applyDropdownFilters(list) {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ function rowsExcluding(exclude) {
|
|||
const keys = Object.keys(filterSet).filter((k) => !exclude.includes(k))
|
||||
const hasText = !!(bus?.searchTerm?.value || '').trim()
|
||||
if (!keys.length && !hasText) return rows
|
||||
const faceted = rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
const faceted = rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC))
|
||||
return filterRowsBySearch(faceted, matchSearch)
|
||||
}
|
||||
|
||||
|
|
@ -89,9 +89,9 @@ const charts = computed(() => (cfg.charts || []).map((c) => {
|
|||
: buildDonut(agg, 132, 0.58, c.facet, overrides),
|
||||
}
|
||||
}))
|
||||
const isActive = (facet, key) => filterSet[facet] === key
|
||||
const isActive = (facet, key) => bus.isFilterActive(facet, key)
|
||||
// true when this chart's own facet has an active selection → drives the pop-out / dim-others state.
|
||||
const hasSelection = (facet) => filterSet[facet] != null
|
||||
const hasSelection = (facet) => filterSet[facet] != null // any selection, scalar or array
|
||||
|
||||
function pick(facet, key) {
|
||||
if (!key || key === 'Other' || !bus) return
|
||||
|
|
|
|||
|
|
@ -60,7 +60,10 @@ function rowsExcluding(exclude) {
|
|||
const keys = Object.keys(filterSet).filter((k) => !exclude.includes(k))
|
||||
const hasText = !!(bus?.searchTerm?.value || '').trim()
|
||||
if (!keys.length && !hasText) return rows
|
||||
const faceted = rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
// The rail prints the COUNTS a reader acts on before clicking, so it above all must use the
|
||||
// same predicate the map and table use — a rail still comparing with === would show counts for
|
||||
// a scope the other panels no longer render. See D1 in useSelectionBus.js.
|
||||
const faceted = rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC))
|
||||
return filterRowsBySearch(faceted, matchSearch)
|
||||
}
|
||||
|
||||
|
|
@ -125,14 +128,14 @@ function isExpandedRaw(facet, value) {
|
|||
const k = nodeKey(facet, value)
|
||||
// undefined = never touched → fall back to "expanded iff this node is the active filter",
|
||||
// so an active branch still opens itself exactly as it always did.
|
||||
return k in expanded ? expanded[k] : filterSet[facet] === value
|
||||
return k in expanded ? expanded[k] : bus.isFilterActive(facet, value)
|
||||
}
|
||||
|
||||
function toRows(agg, facet, level) {
|
||||
const max = agg.reduce((m, d) => Math.max(m, d.value), 0) || 1
|
||||
return agg.map((d) => ({
|
||||
facet, value: d.key, label: d.key, count: d.value, frac: d.value / max, level,
|
||||
other: !!d.other, active: !d.other && filterSet[facet] === d.key,
|
||||
other: !!d.other, active: !d.other && bus.isFilterActive(facet, d.key),
|
||||
// Carried per-ROW rather than looked up in the template, so the zero-row baseline snapshot
|
||||
// (which spreads a row object) keeps the mark when a row is re-admitted.
|
||||
dashboardOnly: DASHBOARD_ONLY.has(facet),
|
||||
|
|
@ -390,7 +393,7 @@ const scopeCount = computed(() => {
|
|||
const rows = dash?.rows?.value || []
|
||||
const keys = Object.keys(filterSet)
|
||||
if (!keys.length) return rows.length
|
||||
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true })).length
|
||||
return rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC)).length
|
||||
})
|
||||
|
||||
// each rendered row already carries its own facet key (data-facet/data-value).
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ const filteredRows = computed(() => {
|
|||
const keys = Object.keys(filterSet)
|
||||
const hasText = !!(bus?.searchTerm?.value || '').trim()
|
||||
if (!keys.length && !hasText) return rows
|
||||
const faceted = rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
const faceted = rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC))
|
||||
return filterRowsBySearch(faceted, matchSearch)
|
||||
})
|
||||
// Geo join is config-driven: geoFacet selects the facet whose `geoIso` accessor yields the ISO-3 join
|
||||
|
|
|
|||
|
|
@ -157,7 +157,12 @@ const filters = computed(() => dropdownKeys.value.map((key) => ({
|
|||
name: key,
|
||||
label: FACETS.value[key]?.label || key,
|
||||
options: optionsFor(key),
|
||||
value: filterSet[key] || '',
|
||||
// ★ A DROPDOWN CANNOT SHOW A MULTI-VALUE SELECTION, SO IT SHOWS NONE (D1).
|
||||
// Picking the first of several would put a tick beside one option while the board is narrowed by
|
||||
// three — a control stating something false about the state it belongs to, which is worse than a
|
||||
// control stating nothing. The CHIP still names every value, so the selection is never invisible:
|
||||
// it is simply reported by the surface that can report it honestly.
|
||||
value: Array.isArray(filterSet[key]) ? '' : (filterSet[key] || ''),
|
||||
})))
|
||||
|
||||
// Chips derived from the live filterSet. For THIS component's own dropdown axes label === value
|
||||
|
|
|
|||
|
|
@ -69,7 +69,9 @@ export const EXPORT_FIELDS = [
|
|||
* Optional: omitted, the stamp falls back to the raw value, so existing callers keep working.
|
||||
* It is applied to the COMMENT LINE ONLY — never to a data column (see buildCsv).
|
||||
*/
|
||||
export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC, dataOpts = {}, labelFor = null }) {
|
||||
export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC, dataOpts = {}, labelFor = null,
|
||||
// Defaulted so a caller that predates D1 keeps working exactly as before rather than throwing.
|
||||
rowMatches = (r, ACC) => Object.keys(filterSet).every((k) => { const a = ACC[k]; return a ? a(r) === filterSet[k] : true }) }) {
|
||||
// slug -> wide API record, filled by the lazy all-fields fetch below.
|
||||
let wideStore = null
|
||||
let widePromise = null
|
||||
|
|
@ -117,7 +119,10 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
|
|||
}
|
||||
const keys = Object.keys(filterSet)
|
||||
if (keys.length) {
|
||||
return (rows.value || []).filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
// The bus's own predicate, handed in by the shell — the exporter must select the same rows
|
||||
// the reader is looking at, and a private copy of the rule is how an export starts
|
||||
// disagreeing with the screen it was taken from.
|
||||
return (rows.value || []).filter((r) => rowMatches(r, FACET_ACC))
|
||||
}
|
||||
return rows.value || []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -227,13 +227,21 @@ function useResponsiveShell(bus) {
|
|||
const slugify = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
|
||||
// Minimal escaping, maximal legibility: reserve our three delimiters + %, keep spaces as `+`
|
||||
// (the x-www-form-urlencoded convention), leave everything else -- including unicode -- alone.
|
||||
const escapeValue = (s) => String(s).replace(/[%;:*+]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/ /g, '+')
|
||||
// `,` joins the values of ONE key (D1: OR-within-key). It is reserved here for the same reason
|
||||
// `;` `:` and `*` already are — a delimiter that can also occur inside a value is not a
|
||||
// delimiter — so a literal comma in a facet value is percent-encoded before the join.
|
||||
const escapeValue = (s) => String(s).replace(/[%;:*+,]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/ /g, '+')
|
||||
const unescapeValue = (s) => { try { return decodeURIComponent(String(s).replace(/\+/g, '%20')) } catch (_) { return String(s).replace(/\+/g, ' ') } }
|
||||
|
||||
function useHashSync(bus, labelFor) {
|
||||
function syncHash() {
|
||||
try {
|
||||
const pairs = Object.entries(bus.filterSet).map(([k, v]) => {
|
||||
// A MULTI-VALUE KEY KEEPS THE CODE CANONICAL AND DROPS THE SLUG. The `*slug` is
|
||||
// display-only and singular by construction — one human name for one code — so there is no
|
||||
// honest slug for "these twenty-two codes". Writing one would put a label in a URL that
|
||||
// does not describe what the URL selects, and the slug is already stripped on parse.
|
||||
if (Array.isArray(v)) return k + ':' + v.map(escapeValue).join(',')
|
||||
const label = typeof labelFor === 'function' ? labelFor(k, v) : null
|
||||
const slug = label && String(label) !== String(v) ? slugify(label) : ''
|
||||
return k + ':' + escapeValue(v) + (slug ? '*' + slug : '')
|
||||
|
|
@ -257,8 +265,12 @@ function useHashSync(bus, labelFor) {
|
|||
const i = pair.indexOf(':')
|
||||
if (i < 1) continue
|
||||
const key = pair.slice(0, i)
|
||||
const value = unescapeValue(pair.slice(i + 1).split('*')[0])
|
||||
if (key && value) bus.setFilter(key, value)
|
||||
const raw = pair.slice(i + 1).split('*')[0]
|
||||
// Split BEFORE unescaping: a literal comma inside a value is percent-encoded by
|
||||
// escapeValue, so every `,` still present here is a separator. Unescaping first would
|
||||
// resurrect the content commas and split the value in half.
|
||||
const parts = raw.split(',').map(unescapeValue).filter(Boolean)
|
||||
if (key && parts.length) bus.setFilter(key, parts.length > 1 ? parts : parts[0])
|
||||
}
|
||||
} catch (_) { /* ignore bad hash */ }
|
||||
})
|
||||
|
|
@ -293,7 +305,9 @@ export function useDashboardShell(config) {
|
|||
const hasText = !!(bus.searchTerm?.value || '').trim()
|
||||
if (!keys.length && !hasText) return rows
|
||||
const faceted = keys.length
|
||||
? rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === bus.filterSet[k] : true }))
|
||||
// ONE predicate, owned by the bus (SPEC-DASHBOARD-KIT D1). This line was the seventh copy
|
||||
// of `a(r) === filterSet[k]`; with multi-value filters the copies would have disagreed.
|
||||
? rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC))
|
||||
: rows
|
||||
// Tiered search over the faceted scope: exact first, fuzzy only if exact finds nothing.
|
||||
return filterRowsBySearch(faceted, matchSearch)
|
||||
|
|
@ -355,11 +369,12 @@ export function useDashboardShell(config) {
|
|||
// here, below `dataStore`, because the resolver reads the normalized features it exposes.
|
||||
// Data columns are unaffected (see useCsvExport.buildCsv).
|
||||
const { labelFor } = useTreeLegendLabels(config, dataStore, searchSourceId)
|
||||
// The exporter selects rows with the BUS's predicate, not a copy of it (D1).
|
||||
|
||||
// shareable view-state hash URL -- below labelFor on purpose: the hash's display-only slugs
|
||||
// resolve through the SAME code->name resolver the chips and the CSV provenance line use.
|
||||
useHashSync(bus, labelFor)
|
||||
const exporter = useCsvExport({ rowStore: data.rowStore, rows: data.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC, dataOpts: config.data, 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 })
|
||||
|
||||
provide('selectionBus', bus)
|
||||
provide('dashboardData', data)
|
||||
|
|
|
|||
|
|
@ -77,6 +77,13 @@ export function useTreeLegendLabels(cfg, dataStore, searchSourceId) {
|
|||
*/
|
||||
function labelFor(facetKey, value) {
|
||||
const map = affinityLabelMaps.value[facetKey]
|
||||
// ★ ARRAY-AWARE AT THE ONE OWNER (SPEC-DASHBOARD-KIT D1). Every surface that turns a filter
|
||||
// into words goes through here — the chip bar, the research-filter chips and the CSV
|
||||
// provenance comment — so resolving the list once is what keeps the three saying the same
|
||||
// thing. Without it an array fell through to its default toString and a chip read
|
||||
// "Religion: Islam,Hinduism": the codes joined by a bare comma, with no spacing and no code
|
||||
// resolved to its human name.
|
||||
if (Array.isArray(value)) return value.map((v) => (map && map.get(v)) || v).join(', ')
|
||||
if (!map) return value
|
||||
return map.get(value) || value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -370,7 +370,16 @@ function onMapFiltered(ev) {
|
|||
// then takes the whole set and this becomes a real narrowing instead of an honest abstention.
|
||||
function gridLanguage() {
|
||||
const codes = Array.isArray(linked.language.codes) ? linked.language.codes : []
|
||||
if (codes.length > 1) return ''
|
||||
// ★ THE ABSTENTION IS OVER (SPEC-DASHBOARD-KIT D1). When the map reported a language TIER —
|
||||
// "Arabic", 22 ISO codes — there was no single value that meant it, so this left the axis
|
||||
// unfiltered: honest, and visibly incomplete, because the map narrowed and the grid did not.
|
||||
// The grid's valueMatches() now takes a LIST and matches OR-within-the-axis, so the whole
|
||||
// selection crosses as the codes it actually is. Still codes and never the tier NAME: the name
|
||||
// matches no row that reads "Arabic, Sudanese", which is what emptied the grid in the first
|
||||
// place.
|
||||
// `|`, not `,`: a language LABEL routinely contains a comma ("Arabic, Mesopotamian"), so a
|
||||
// comma-joined list is ambiguous with a single value. See valueMatches() in people-groups-grid.
|
||||
if (codes.length > 1) return codes.join('|')
|
||||
return linked.language.value || linked.language.label
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-filter-multivalue.mjs — OR-within-key, AND-across-keys, and ONE predicate that says so.
|
||||
*
|
||||
* SPEC-DASHBOARD-KIT D1. `filterSet[key]` may hold an array meaning "any of these"; keys still
|
||||
* AND together. This gate exists because the change is four lines of behaviour sitting on top of a
|
||||
* rule that was WRITTEN OUT SEVEN TIMES.
|
||||
*
|
||||
* THE DANGER D1 CREATED, which is the whole reason this is gated rather than reviewed:
|
||||
* `a(r) === filterSet[k]` appeared in the shell's filteredRows, the facet rail twice, the map
|
||||
* panel, the table, the charts rail and the CSV exporter. Seven copies of a one-character rule are
|
||||
* survivable. The moment the rule grows an array case, six of the seven keep comparing an array
|
||||
* with `===`, every one of them quietly matches NOTHING, and the panels disagree with each other
|
||||
* about what is on screen — the facet rail printing counts for a scope the map and table no longer
|
||||
* render, which is a number a reader acts on before clicking. So D1 is not only "arrays are
|
||||
* allowed"; it is "there is one function that answers *does this row match*, and every panel calls
|
||||
* it". This gate asserts the second half, because only the second half can be silently undone.
|
||||
*
|
||||
* WHAT IT CHECKS:
|
||||
* A. BEHAVIOUR, by running the real bus: an array ORs within the key; keys AND; a one-element
|
||||
* array stores as the scalar it means; an EMPTY array clears rather than matching nothing;
|
||||
* toggleFilterValue adds, removes, and clears on the last value; the cascade still fires.
|
||||
* B. NO SURVIVING COPIES: `=== filterSet[k]` / `=== bus.filterSet[k]` must not appear in any
|
||||
* panel. One allowed exception, named: useCsvExport's parameter default, which exists so a
|
||||
* caller predating D1 does not throw.
|
||||
* C. THE HASH round-trips a list, including a value that CONTAINS a comma — a delimiter that can
|
||||
* also occur inside a value is not a delimiter.
|
||||
* D. THE GRID'S END matches the same way: valueMatches() takes a list and ORs it, so the two
|
||||
* implementations of one rule cannot drift into disagreeing.
|
||||
*
|
||||
* NOT CHECKED, because it is deliberately NOT part of D1: a plain click still REPLACES rather than
|
||||
* adds. D1 gives the bus the capability; making a rail row additive on an unmodified click would
|
||||
* change what every existing click does to the flagship, which is a product decision and not this
|
||||
* one. The array path is reached programmatically today (the composite's language code set).
|
||||
*
|
||||
* NEGATIVE-TESTED: restoring `=== filterSet[k]` in any panel fails B naming the file; making
|
||||
* facetMatches use `includes` on a scalar fails A; dropping the comma from escapeValue's reserved
|
||||
* set fails C. No browser.
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const TAG = 'FILTER-MULTIVALUE'
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const fails = []
|
||||
const notes = []
|
||||
const eq = (what, got, want) => { if (JSON.stringify(got) !== JSON.stringify(want)) fails.push(`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`) }
|
||||
|
||||
// ── A. the real bus, exercised ───────────────────────────────────────────────────────────────
|
||||
const BUS = join(ROOT, 'library/composables/useSelectionBus.js')
|
||||
if (!existsSync(BUS)) { console.log(`${TAG}: SKIP — no ${BUS.replace(ROOT + '/', '')} (kit landing 1 not applied).`); process.exit(0) }
|
||||
const { createSelectionBus } = await import(BUS)
|
||||
{
|
||||
const bus = createSelectionBus()
|
||||
bus.configureHierarchy({ facetRail: [{ id: 'place', drill: ['region', 'block', 'country'] }] })
|
||||
bus.setFilter('religion', ['Islam', 'Hinduism'])
|
||||
eq('A array is stored as an array', bus.filterSet.religion, ['Islam', 'Hinduism'])
|
||||
eq('A OR within the key (member)', bus.facetMatches('Hinduism', bus.filterSet.religion), true)
|
||||
eq('A OR within the key (non-member)', bus.facetMatches('Jewish', bus.filterSet.religion), false)
|
||||
eq('A isFilterActive sees into the array', bus.isFilterActive('religion', 'Islam'), true)
|
||||
|
||||
bus.setFilter('region', 'Asia')
|
||||
const ACC = { religion: (r) => r.religion, region: (r) => r.region }
|
||||
eq('A AND across keys (both hold)', bus.rowMatchesFilters({ religion: 'Islam', region: 'Asia' }, ACC), true)
|
||||
eq('A AND across keys (one fails)', bus.rowMatchesFilters({ religion: 'Islam', region: 'Africa' }, ACC), false)
|
||||
eq('A a key with no accessor is skipped, not failed', bus.rowMatchesFilters({ religion: 'Islam', region: 'Asia' }, { religion: ACC.religion }), true)
|
||||
|
||||
bus.setFilter('religion', ['Islam'])
|
||||
eq('A a one-element array stores as the scalar it means', bus.filterSet.religion, 'Islam')
|
||||
bus.setFilter('religion', [])
|
||||
eq('A an EMPTY array CLEARS rather than matching nothing', 'religion' in bus.filterSet, false)
|
||||
|
||||
bus.setFilter('tags', 'a'); bus.toggleFilterValue('tags', 'b')
|
||||
eq('A toggle adds', bus.filterSet.tags, ['a', 'b'])
|
||||
bus.toggleFilterValue('tags', 'b')
|
||||
eq('A toggle removes, collapsing to the scalar', bus.filterSet.tags, 'a')
|
||||
bus.toggleFilterValue('tags', 'a')
|
||||
eq('A toggling the last value clears the key', 'tags' in bus.filterSet, false)
|
||||
|
||||
// The hierarchy contract must still hold with arrays in play.
|
||||
bus.setFilter('region', ['Asia', 'Africa']); bus.setFilter('block', 'South Asia'); bus.setFilter('country', 'IND')
|
||||
bus.setFilter('region', null)
|
||||
eq('A clearing a multi-value parent still cascades', Object.keys(bus.filterSet).sort(), [])
|
||||
notes.push('bus: OR within a key, AND across keys, empty array clears, cascade intact')
|
||||
}
|
||||
|
||||
// ── B. no surviving copies of the predicate ──────────────────────────────────────────────────
|
||||
{
|
||||
const PANELS = [
|
||||
'plugin-designs/research-dashboard/src/composables/useDashboardShell.js',
|
||||
'plugin-designs/research-dashboard/src/composables/useCsvExport.js',
|
||||
'plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue',
|
||||
'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue',
|
||||
'plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue',
|
||||
]
|
||||
for (const rel of PANELS) {
|
||||
const p = join(ROOT, rel)
|
||||
if (!existsSync(p)) { fails.push(`B ${rel} is missing — this gate is blind to it.`); continue }
|
||||
for (const [i, line] of readFileSync(p, 'utf8').split('\n').entries()) {
|
||||
if (/^\s*(\/\/|\*)/.test(line)) continue // a comment quoting the old rule is fine
|
||||
if (!/===\s*(bus\.)?filterSet\[/.test(line)) continue
|
||||
// THE ONE ALLOWED SITE, named rather than pattern-matched: the exporter's parameter default,
|
||||
// which keeps a pre-D1 caller working instead of throwing.
|
||||
if (rel.endsWith('useCsvExport.js') && /rowMatches\s*=/.test(line)) continue
|
||||
fails.push(`B ${rel}:${i + 1} still compares with === against filterSet — with an array that matches NOTHING, and this panel would disagree with the others about what is on screen.\n ${line.trim().slice(0, 140)}`)
|
||||
}
|
||||
}
|
||||
if (!fails.some((f) => f.startsWith('B'))) notes.push(`panels: ${PANELS.length} checked, no surviving copy of the predicate`)
|
||||
}
|
||||
|
||||
// ── C. the hash round-trips a list, commas and all ───────────────────────────────────────────
|
||||
{
|
||||
const src = readFileSync(join(ROOT, 'plugin-designs/research-dashboard/src/composables/useDashboardShell.js'), 'utf8')
|
||||
const esc = /const escapeValue = (\(s\) =>[^\n]*)/.exec(src)
|
||||
const unesc = /const unescapeValue = (\(s\) =>[^\n]*)/.exec(src)
|
||||
if (!esc || !unesc) fails.push('C could not lift escapeValue/unescapeValue from useDashboardShell.js — the hash format moved and this gate is blind to it.')
|
||||
else {
|
||||
const escapeValue = new Function('return ' + esc[1])()
|
||||
const unescapeValue = new Function('return ' + unesc[1])()
|
||||
for (const list of [['apd', 'acm', 'ayl'], ['Arabic, Sudanese'], ['Islam', 'Hinduism'], ['a+b', 'c;d']]) {
|
||||
const back = list.map(escapeValue).join(',').split(',').map(unescapeValue)
|
||||
eq(`C hash round-trip ${JSON.stringify(list)}`, back, list)
|
||||
}
|
||||
if (!fails.some((f) => f.startsWith('C'))) notes.push('hash: a list round-trips, including a value that contains a comma')
|
||||
}
|
||||
}
|
||||
|
||||
// ── D. the grid ORs the same way ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
const src = readFileSync(join(ROOT, 'plugin-designs/people-groups-grid/people-groups-grid.vue'), 'utf8')
|
||||
const m = /function valueMatches\(vl, selection\) \{([\s\S]*?)\n\}/.exec(src)
|
||||
const n = /const norm = ([^\n]*)/.exec(src)
|
||||
if (!m || !n) fails.push('D could not lift valueMatches/norm from people-groups-grid.vue — the grid matcher moved.')
|
||||
else {
|
||||
const valueMatches = new Function('norm', `return function valueMatches(vl, selection) {${m[1]}\n}`)(new Function('return ' + n[1])())
|
||||
const row = { value: 'acm', label: 'Arabic, Mesopotamian' }
|
||||
eq('D grid matches a member of the list', valueMatches(row, { value: 'apd|acm|ayl' }), true)
|
||||
eq('D grid rejects a non-member', valueMatches(row, { value: 'apd|ayl' }), false)
|
||||
eq('D grid still matches a single value', valueMatches(row, { value: 'acm' }), true)
|
||||
// ★ THIS ASSERTION IS THE ONE THAT EARNED ITS KEEP. The first D1 draft split the selection on
|
||||
// `,` and broke label matching outright — 40% of live language labels are comma-inverted, so
|
||||
// the selection was chopped in half and matched nothing. Asserting a behaviour you are KEEPING
|
||||
// is what caught it; asserting only the behaviour you are adding would have shipped it.
|
||||
eq('D grid still matches on the LABEL, commas and all', valueMatches(row, { value: 'Arabic, Mesopotamian' }), true)
|
||||
eq('D an empty selection matches nothing', valueMatches(row, { value: '' }), false)
|
||||
if (!fails.some((f) => f.startsWith('D'))) notes.push('grid: valueMatches ORs a list, and a single value takes the path it always did')
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of notes) console.log(' · ' + n)
|
||||
if (fails.length) {
|
||||
console.error(`\n${TAG}: FAIL`)
|
||||
for (const f of fails) console.error(' ✗ ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`${TAG}: PASS — one predicate, OR within a key, AND across keys, and both ends of the link agree.`)
|
||||
Loading…
Reference in a new issue