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:
parent
db2b24c753
commit
c2ab779623
3 changed files with 110 additions and 84 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
100
library/composables/useDashboardSearch.js
Normal file
100
library/composables/useDashboardSearch.js
Normal 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)
|
||||
}
|
||||
|
|
@ -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`:
|
||||
|
|
|
|||
Loading…
Reference in a new issue