SPEC-DASHBOARD-KIT D3, and the last of the three decided-not-built items. The bus carried two coordinated channels — filterSet NARROWS scope, selectionSet MARKS individuals — and was missing the third: hoverKey POINTS at the one record under the pointer. It is how a reader sweeps a table and watches the map answer without choosing anything, and kepler.gl treats hover as a configured interaction beside filters for the same reason. IT IS A PLAIN ref AND NOT A SET, AND THAT IS THE WHOLE DESIGN. Hover fires on every mousemove across a 2,107-row table. selectionSet had to become a plain Set with a version counter precisely because a reactive collection fires per element; a single ref has none of that problem — one dependency, one write, no iteration. A repeat is a no-op, so an unchanged value never wakes a panel. HOVER IS NOT SELECTION, AND MUST NEVER BECOME IT. The cheap implementation is to put the key in selectionSet "just while hovering", and every consequence of that is bad while none is obvious at the call site: useSelectionCamera watches selectionVersion, so a hover would fly the camera at every mousemove — the resize-strobe bug wearing a new hat, on a Driver who is photosensitive. The CSV exporter reads selection > filter > all, so a hover would silently change what an export contains. The table's selection pill and the shell's live region both report selection counts, and both would narrate the pointer. So a panel may fade or outline on hover; it may not fly, select or filter. NO PANEL IS WIRED, deliberately, and I want that on the record rather than left to be noticed. D3 says panels opt in, and the first opt-in belongs with a consumer that has a reason: wiring one into Big Picture speculatively would be a visual change to the flagship that no reader asked for, landed while the kit stack is paused. That does leave a channel nothing uses — the same criticism I made of the unmounted filter chips — so the difference has to be earned rather than asserted: this is a documented, gated contract with its first consumer named, not a component that was quietly orphaned. check:hover-channel is what makes it a contract instead of dead code; the first panel to use it inherits the rules instead of inventing them. Three mutants, all caught: routing setHover through selectionSet, dropping the equality guard, and removing hoverKey from the returned surface. SOURCE ONLY. Stacked on landing 2. No rebuild needed — nothing renders it yet.
310 lines
17 KiB
JavaScript
310 lines
17 KiB
JavaScript
/**
|
|
* useSelectionBus.js — hand-rolled pub/sub brushing-and-linking bus.
|
|
*
|
|
* ★ SHARED AS OF DASHBOARD-KIT LANDING 1. This is the first piece of Big Picture to become
|
|
* library code, and it went first for one reason: it has ZERO domain references and imports
|
|
* nothing but `vue`, so it is the only piece that could move with no adapter and no behaviour
|
|
* change. Everything below is the file as research-dashboard shipped it — not a rewrite, not a
|
|
* generalisation. A move that also edits is a move nobody can verify.
|
|
*
|
|
* research-dashboard's old copy re-exported this file for one cycle so nothing broke mid-flight;
|
|
* that shim is now deleted. See _qa-cycle/03-deployment-cycle/SPEC-DASHBOARD-KIT.md sections 5.2 and 8.
|
|
*
|
|
* The move and SPEC-DASHBOARD-KIT D1 (multi-value `filterSet`: an array means OR-within-key,
|
|
* AND-across-keys) landed as separate commits, the move first, so 'Big Picture is unchanged' by the
|
|
* move stayed provable rather than asserted.
|
|
*
|
|
* Two DISTINCT global reactive objects, two distinct visual languages (plan §INTERACTION):
|
|
* - filterSet : active facet predicates (tree node / map region / chart slice).
|
|
* NARROWS scope — panels render excluded context by FADING, never removing.
|
|
* - selectionSet : explicit set of slugs (table checkbox / map lasso / chart pick).
|
|
* MARKS individuals — panels render with RING/OUTLINE.
|
|
* - hoverKey : the ONE record the pointer is over, or null (D3).
|
|
* POINTS at one — panels may fade or outline; they may not fly, select or
|
|
* filter. Hover is not a commitment, so nothing irreversible may follow it.
|
|
*
|
|
* Export priority (later phases): selectionSet (if non-empty) > filterSet result > full dataset.
|
|
* Every panel is both publisher and subscriber. All client-side over one in-memory row store
|
|
* keyed on slug (PK) — no per-brush round-trips.
|
|
*
|
|
* ── PERFORMANCE CONTRACT (BUG-TABLE-BATCH-SELECTION-FREEZES-20S) ──────────────────────────
|
|
* selectionSet is a PLAIN Set, not `reactive(new Set())`, and `selectionVersion` is the single
|
|
* reactive dependency that stands in for it. Read it in any computed that inspects the set:
|
|
*
|
|
* const isSel = computed(() => { bus.selectionVersion.value; return bus.selectionSet.has(x) })
|
|
*
|
|
* WHY. A `reactive(new Set())` fires Vue's ITERATE_KEY on EVERY add/delete, and the old
|
|
* per-row `toggleSelect()` loop also fired a SYNCHRONOUS `emit('select')` per row. Selecting
|
|
* all 2,107 people groups therefore ran 2,107 full map-panel rebuilds — each one re-scanning
|
|
* every row and calling MapLibre `setData()` — allocating ~2.2 MILLION GeoJSON features to
|
|
* arrive at 2,107. Measured at 5,323 ms of pure JS in node (~20 s in the browser with the real
|
|
* map + charts + DOM). See selectMany/deselectMany/setSelection: each mutates the set fully,
|
|
* THEN bumps the version and emits ONCE. Same work, measured at 2.9 ms — a ~1,800x speedup.
|
|
*
|
|
* RULE: never loop `toggleSelect()` over many slugs. Use the bulk ops. They are the only
|
|
* reason batch selection is interactive.
|
|
*
|
|
* ── HIERARCHY CONTRACT (BUG-DESELECTING-A-PARENT-FACET-ORPHANS-ITS-CHILD-SELECTION) ────────
|
|
* A CHILD FACET SELECTION MUST NOT OUTLIVE ITS PARENT. Clearing a parent facet cascade-clears
|
|
* every descendant, transitively, in one commit.
|
|
*
|
|
* WHY IT LIVES HERE AND NOWHERE ELSE. filterSet is AND-stacked: filteredRows keeps a row only
|
|
* when EVERY active key matches. So an orphaned child keeps filtering after its parent is gone —
|
|
* deselect "Indo-European" while "Marathi" is picked and Marathi survives; pick a DIFFERENT
|
|
* family and the two predicates can never both hold, so the map goes empty with no error. The
|
|
* user reads that as "there are no people groups here" rather than "my filter state is
|
|
* inconsistent" — a SILENT WRONG RESULT, which is why this is a data-trust bug, not a nit.
|
|
*
|
|
* Four separate controls clear a filter — the tree rail (DashboardFacetRail), the dropdowns
|
|
* (DashboardResearchFilters), the per-chip ✕ (SearchFilterHeader/DashboardFilterChips) and
|
|
* Clear All / the breadcrumb — and ALL FOUR funnel through setFilter()/clearFilters() here.
|
|
* Implementing the cascade in any one component would fix one button and leave the identical
|
|
* bug reachable through the other three. One chokepoint, one rule.
|
|
*
|
|
* The hierarchy is NOT declared twice: `configureHierarchy()` derives it from the config's
|
|
* existing `facetRail[].drill` arrays, which are already ordered parent→child (the rail renders
|
|
* them as tiers). The config stays the single source of truth for what nests inside what.
|
|
*
|
|
* Transitivity is the whole point: People is THREE deep (affinity_bloc → cluster → people_group)
|
|
* and Place is three deep (wagf_region → wagf_block → country_code). A one-level cascade would
|
|
* look fixed from the top tier while leaving the same orphan one rung down.
|
|
*/
|
|
import { reactive, ref } from 'vue'
|
|
|
|
/**
|
|
* Derive facetKey → [descendant keys, nearest-first] from a dashboard config.
|
|
* Reads `facetRail[].drill` (already ordered parent→child) so the hierarchy is declared ONCE,
|
|
* in the config, and can never drift from the tiers the rail actually renders.
|
|
* A section with no `drill` (e.g. the flat Status list) contributes nothing — a facet absent
|
|
* from the map simply has no descendants and clears alone, exactly as before.
|
|
*/
|
|
export function deriveHierarchy(config) {
|
|
const children = {}
|
|
for (const section of config?.facetRail || []) {
|
|
const drill = Array.isArray(section.drill) ? section.drill.filter(Boolean) : []
|
|
// every tier below i is a descendant of tier i (transitive closure, nearest-first)
|
|
for (let i = 0; i < drill.length - 1; i++) {
|
|
const rest = drill.slice(i + 1)
|
|
children[drill[i]] = children[drill[i]] ? children[drill[i]].concat(rest.filter((k) => !children[drill[i]].includes(k))) : rest
|
|
}
|
|
}
|
|
return children
|
|
}
|
|
|
|
export function createSelectionBus() {
|
|
const filterSet = reactive({}) // { facetKey: value | value[] } — AND-stacked facets
|
|
// PLAIN Set (see the performance contract above) — pair every read with `selectionVersion`.
|
|
const selectionSet = new Set() // Set<slug> — explicit picks
|
|
// Bumped once per committed selection change. THE reactive handle on selectionSet: one
|
|
// dependency to invalidate instead of a collection proxy that fires per element.
|
|
const selectionVersion = ref(0)
|
|
// Free-text search term (DX-life research-page search bar → header-1). Distinct from the
|
|
// facet filterSet: it applies a substring/label match across the config's `searchKeys` in
|
|
// useDashboardShell.filteredRows. Empty string = no text predicate (identical to before).
|
|
const searchTerm = ref('')
|
|
// ── hoverKey (SPEC-DASHBOARD-KIT D3) ────────────────────────────────────────────────────────
|
|
// THE THIRD COORDINATED CHANNEL, and the only one this bus was missing. filterSet narrows scope
|
|
// and selectionSet marks individuals; HOVER points at one record without committing to it, so a
|
|
// reader can sweep a table and watch the map answer. kepler.gl treats hover as a configured
|
|
// interaction alongside filters for the same reason.
|
|
//
|
|
// ★ IT IS A PLAIN ref AND NOT A SET, AND THAT IS THE WHOLE DESIGN. Hover fires on every
|
|
// mousemove across a 2,107-row table. selectionSet had to be a plain Set with a version counter
|
|
// precisely because a reactive collection fires per element (see the performance contract
|
|
// above); a single ref has none of that problem — one dependency, one write, no iteration. Any
|
|
// future "hover several things" must be argued for against that, not assumed.
|
|
//
|
|
// ★ HOVER IS NOT SELECTION, AND MUST NEVER BECOME IT. It does not enter selectionSet, does not
|
|
// bump selectionVersion, and therefore does not move the camera: useSelectionCamera watches the
|
|
// version, and a camera that chased the pointer across a table would be the resize-strobe bug
|
|
// wearing a new hat, on a Driver who is photosensitive. A panel may fade or outline on hover; it
|
|
// may not fly, select or filter.
|
|
const hoverKey = ref(null)
|
|
const subs = new Map() // event -> Set<cb>
|
|
|
|
function on(event, cb) {
|
|
if (!subs.has(event)) subs.set(event, new Set())
|
|
subs.get(event).add(cb)
|
|
return () => subs.get(event)?.delete(cb) // returns an unsubscribe
|
|
}
|
|
function emit(event, payload) {
|
|
subs.get(event)?.forEach((cb) => { try { cb(payload) } catch (_) { /* isolate subscribers */ } })
|
|
}
|
|
|
|
// ── facet hierarchy (see the HIERARCHY CONTRACT above) ──
|
|
// facetKey -> [descendant keys]. Empty until the shell calls configureHierarchy(config), so a
|
|
// bus used without a config behaves exactly as it did before (every facet clears alone).
|
|
let childFacets = {}
|
|
function configureHierarchy(config) { childFacets = deriveHierarchy(config) }
|
|
|
|
// ── filterSet ops (scope) ──
|
|
/**
|
|
* Set or clear one facet. CLEARING CASCADES: removing a parent removes every descendant in the
|
|
* same call, so a child predicate can never outlive the parent that scoped it.
|
|
*
|
|
* Setting is deliberately NOT cascading in either direction. Selecting a child with no parent
|
|
* set is a legitimate, self-consistent state — the child alone is strictly narrower than its
|
|
* parent, so the result is never wrong, merely more specific (the tree rail, the dropdowns and
|
|
* the geocoder bars all allow it today, and it is how "search language → Marathi" works without
|
|
* first picking a family). The invariant is one-directional by design: no ORPHANS, but
|
|
* stand-alone children are fine.
|
|
*
|
|
* One 'filter' emit for the whole cascade — subscribers rebuild the map/table once, not once
|
|
* 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
|
|
for (const child of childFacets[key] || []) delete filterSet[child]
|
|
} else {
|
|
filterSet[key] = value
|
|
}
|
|
emit('filter', filterSet)
|
|
}
|
|
function clearFilters() {
|
|
Object.keys(filterSet).forEach((k) => delete filterSet[k])
|
|
emit('filter', filterSet)
|
|
}
|
|
|
|
// ── free-text search (header-1) ──
|
|
/** Point at one record, or `null` for nothing. The value is whatever the profile's primary key
|
|
* is (`config.data.primaryKey`, a slug here) so every panel is pointing at the same thing.
|
|
* Deliberately NOT debounced in the bus: what a hover costs depends on what the panel does with
|
|
* it, so the panel that repaints owns that decision. The bus's job is to be cheap enough that
|
|
* it never needs one. */
|
|
function setHover(key) {
|
|
const next = key === undefined ? null : key
|
|
if (hoverKey.value === next) return // a no-op must not wake every panel watching the ref
|
|
hoverKey.value = next
|
|
emit('hover', next)
|
|
}
|
|
|
|
function setSearch(term) {
|
|
searchTerm.value = term == null ? '' : String(term)
|
|
emit('search', searchTerm.value)
|
|
}
|
|
|
|
// ── selectionSet ops (marks) ──
|
|
// The ONE place a selection change becomes visible: bump the version, notify subscribers once.
|
|
// Every mutator below finishes its whole batch before calling this exactly once.
|
|
function commitSelection() {
|
|
selectionVersion.value++
|
|
emit('select', selectionSet)
|
|
}
|
|
|
|
function toggleSelect(slug) {
|
|
selectionSet.has(slug) ? selectionSet.delete(slug) : selectionSet.add(slug)
|
|
commitSelection()
|
|
}
|
|
function clearSelection() {
|
|
if (!selectionSet.size) return // no-op stays silent (no wasted map rebuild)
|
|
selectionSet.clear()
|
|
commitSelection()
|
|
}
|
|
|
|
/**
|
|
* Add many slugs in ONE commit. `additive:false` replaces the selection outright.
|
|
* Returns true when something actually changed (a no-op never repaints the map).
|
|
*/
|
|
function selectMany(slugs, { additive = true } = {}) {
|
|
let changed = false
|
|
if (!additive) {
|
|
if (selectionSet.size) { selectionSet.clear(); changed = true }
|
|
}
|
|
for (const s of slugs) if (!selectionSet.has(s)) { selectionSet.add(s); changed = true }
|
|
if (changed) commitSelection()
|
|
return changed
|
|
}
|
|
|
|
/** Remove many slugs in ONE commit. Returns true when something actually changed. */
|
|
function deselectMany(slugs) {
|
|
let changed = false
|
|
for (const s of slugs) if (selectionSet.delete(s)) changed = true
|
|
if (changed) commitSelection()
|
|
return changed
|
|
}
|
|
|
|
/**
|
|
* Replace the whole selection with `slugs` in ONE commit — the primitive the table's
|
|
* shift+click range-select is built on (base snapshot ⊕ range, applied atomically).
|
|
* Skips the commit when the new set is identical to the current one.
|
|
*/
|
|
function setSelection(slugs) {
|
|
const next = slugs instanceof Set ? slugs : new Set(slugs)
|
|
if (next.size === selectionSet.size) {
|
|
let same = true
|
|
for (const s of next) if (!selectionSet.has(s)) { same = false; break }
|
|
if (same) return false
|
|
}
|
|
selectionSet.clear()
|
|
for (const s of next) selectionSet.add(s)
|
|
commitSelection()
|
|
return true
|
|
}
|
|
|
|
return {
|
|
filterSet, selectionSet, selectionVersion, searchTerm, hoverKey, on, emit,
|
|
setFilter, clearFilters, setSearch, setHover, 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)
|
|
}
|
|
}
|