Kit landing 4a: the shared search, because the facet rail cannot move yet

★ LANDING 4 AS I SCOPED IT IS NOT POSSIBLE, AND THE SPEC WAS WRONG ABOUT THAT.
go-int asked for the facet rail next and I agreed. Then the first step of a
landing — look before moving — found that DashboardFacetRail imports FOUR
plugin-local modules: useDashboardAggregations, useDashboardShell,
useTreeLegendColors and useTreeLegendLabels. Moving the rail into library/ would
have made a library component import plugin composables, which
check:no-cross-plugin-imports rightly refuses. And two of those four are
research-specific by my own spec's reckoning — useTreeLegendColors carries the
DOXA colour strategies and is supposed to STAY in the plugin — so the rail cannot
become library code by moving alone. Its colour and label dependencies have to be
inverted into injection first, which section 8 named as a risk and then ordered as
if it were free.

So landing 4 is three commits, not one, and this is the first: the piece the rail
stands on.

THE SEARCH WAS NEVER A SHELL CONCERN. buildSearchAccessors, makeSearchMatcher and
filterRowsBySearch were exported from useDashboardShell, and EIGHT files reached
into the shell to get them — the table, the map panel, the charts rail, the facet
rail, the research filters, the top bar, the profile and the config. A module that
eight things import is a module, not a corner of another one. Nothing in it is
domain-specific: the accessors come from config.facets / config.columns /
config.searchKeys, and the matching comes from the already-shared fuzzy.js.

Bodies are verbatim; this is a move. The shell re-exports the three names for one
cycle, so none of the eight importers changes in this commit — a barrel line
rather than a shim file, so there is nothing to delete later, only a line to
remove when the eight point at the library directly.

The now-dead fuzzy.js import is removed from the shell rather than left. An import
nothing uses is the kind of thing that survives three more landings because it
looks load-bearing.

WHAT THIS DELIBERATELY DOES NOT TOUCH: the shell keeps rowMatchesFilters and the
URL-hash escape helpers, which are what check:filter-multivalue reads out of it by
path. I checked that before slicing, so no gate path moves in this commit.

Library index regenerated in-commit. Source only; no rebuild needed beyond the
usual, since every importer still resolves through the shell.
This commit is contained in:
Doxa Maps 2026-09-27 01:39:04 +00:00
parent db2b24c753
commit c2ab779623
3 changed files with 110 additions and 84 deletions

View file

@ -117,10 +117,10 @@ and a change here can break other maps.
`import … from '@map/composables/useAvatarGenerator.js'`
- **useCountryOutline** — useCountryOutline.js — country boundary OUTLINE overlay on country search.
`import … from '@map/composables/useCountryOutline.js'`
- **useCsvExport** — useCsvExport.js — ★ SHARED LIBRARY CODE (dashboard-kit landing 3). Two parameters make it
`import … from '@map/composables/useCsvExport.js'`
- **useDashboardLayout** — useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid.
`import … from '@map/composables/useDashboardLayout.js'`
- **useDashboardSearch** — useDashboardSearch.js — the ONE free-text search the whole dashboard shares.
`import … from '@map/composables/useDashboardSearch.js'`
- **useDocumentFont** — useDocumentFont.js — register a self-hosted @font-face at DOCUMENT level, once per page.
`import … from '@map/composables/useDocumentFont.js'`
- **useEdgeScrollAffordance** — useEdgeScrollAffordance — the gray "you can scroll past this" edge strips.
@ -149,8 +149,6 @@ and a change here can break other maps.
`import … from '@map/composables/useMapSelection.js'`
- **useMapTheme** — useMapTheme.js — shared light/dark theme for every map profile.
`import … from '@map/composables/useMapTheme.js'`
- **useOptionalT** — ★ NO <area>.<key>-SHAPED EXAMPLES IN THIS FILE'S COMMENTS. check:i18n-keys-exist reads any such
`import … from '@map/composables/useOptionalT.js'`
- **usePosterPrints** — usePosterPrints.js — THE print actions every poster wall shares.
`import … from '@map/composables/usePosterPrints.js'`
- **usePplrInstance** — usePplrInstance.js — per-map "instance store" mediator.
@ -165,14 +163,10 @@ and a change here can break other maps.
`import … from '@map/composables/useSelectedPin.js'`
- **useSelectionBus** — useSelectionBus.js — hand-rolled pub/sub brushing-and-linking bus.
`import … from '@map/composables/useSelectionBus.js'`
- **useSelectionCamera** — useSelectionCamera.js — "select rows in the table, the map zooms to that selection."
`import … from '@map/composables/useSelectionCamera.js'`
- **useShadowStyles** — useShadowStyles.js — Shadow DOM Style Injection
`import … from '@map/composables/useShadowStyles.js'`
- **useSharedMapToken** — useSharedMapToken.js — ONE page, ONE map token, shared by every element on it.
`import … from '@map/composables/useSharedMapToken.js'`
- **useTextHighlight** — highlightRanges(value, indices) — wrap each matched index range in
`import … from '@map/composables/useTextHighlight.js'`
### Utilities (pure helper functions)

View file

@ -0,0 +1,100 @@
/**
* useDashboardSearch.js — the ONE free-text search the whole dashboard shares.
*
* ★ EXTRACTED FROM useDashboardShell (dashboard-kit landing 4a) BECAUSE EIGHT THINGS IMPORT IT.
* These three functions were exported from the shell, and every panel reached into the shell to get
* them: the table, the map panel, the charts rail, the facet rail, the research filters, the top bar,
* the profile and the config. That is not a shell concern; it is a module in its own right that
* happened to live inside one.
*
* It also unblocks the facet rail. Landing 4 was supposed to move the rail, and the rail imports
* these helpers — so moving the rail first would have meant a library component importing a plugin
* composable, which check:no-cross-plugin-imports rightly refuses. The dependency had to come first;
* SPEC-DASHBOARD-KIT section 8 did not notice that, and now says so.
*
* Nothing here is domain-specific: the accessors come from `config.facets`, `config.columns` and
* `config.searchKeys`, and the matching comes from the shared @map/components/search/fuzzy.js. A
* dashboard with different facets gets its own search for free.
*
* Bodies are verbatim from the shell — this is a move, not a rewrite.
*/
import { exactMatches, fuzzyMatches, resolveFuzzyValues } from '@map/components/search/fuzzy.js'
// tiny lodash-free mapValues: { k: v } -> { k: fn(v, k) }
const mapValues = (obj, fn) =>
Object.fromEntries(Object.entries(obj || {}).map(([k, v]) => [k, fn(v, k)]))
/**
* 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)
}

View file

@ -23,11 +23,11 @@
* 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 '@map/composables/useCsvExport.js'
import { useCsvExport } from './useCsvExport.js'
import { useTreeLegendLabels } from './useTreeLegendLabels.js'
import { DataSourceManager } from '@map/api/DataSourceManager.js'
import sourcesConfig from '@map/api/sources.json'
@ -40,80 +40,12 @@ 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'))
/**
* 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)
}
// ★ 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'
/**
* Generic KPI computation driven by config.kpis. Each kpi def declares a `kind`: