diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md index 83a70d43f..8533ebabd 100644 --- a/documentation/reference-library-index.md +++ b/documentation/reference-library-index.md @@ -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 .-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) diff --git a/library/composables/useDashboardSearch.js b/library/composables/useDashboardSearch.js new file mode 100644 index 000000000..35095b62c --- /dev/null +++ b/library/composables/useDashboardSearch.js @@ -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) +} diff --git a/plugin-designs/research-dashboard/src/composables/useDashboardShell.js b/plugin-designs/research-dashboard/src/composables/useDashboardShell.js index 843ff8d54..e9f828e7c 100644 --- a/plugin-designs/research-dashboard/src/composables/useDashboardShell.js +++ b/plugin-designs/research-dashboard/src/composables/useDashboardShell.js @@ -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`: