doxa-maps/library/components/search/fuzzy.js

105 lines
4 KiB
JavaScript

/**
* fuzzy.js — SHARED (@map/components/search/fuzzy.js).
*
* A small, self-contained fuzzy matcher with the SAME public surface consumers
* use from Fuse.js, vendored here so any doxa-maps element ships with NO external
* npm dependency (fuse.js is not a dependency of the doxa-maps bundler, and adding
* one would touch the shared package.json/bun.lock). This is the ONE shared home;
* plugins (people-groups-grid, big-picture, …) import it via
* import Fuse from '@map/components/search/fuzzy.js'
* rather than keeping a local copy.
*
* COPY-ONLY NOTE: the DX-life source (UupgsList.vue) uses `new Fuse(list, opts).search(term)`
* with { keys, threshold, includeMatches, ignoreLocation, minMatchCharLength }. We reproduce
* exactly the parts of that contract the grid relies on:
* - keys: array of dotted paths ('name', 'country_code.label', …) read from each item
* - threshold: 0 = exact substring only; >0 = fuzzy (subsequence) allowed
* - minMatchCharLength: shortest run counted as a match
* - .search(term) -> [{ item, matches:[{ key, indices:[[start,end], …] }] }]
* The `matches[].indices` drive the <span class="search-highlight"> wrapping in the grid,
* identical in shape to Fuse's includeMatches output.
*/
function getByPath(obj, path) {
if (!path.includes('.')) return obj == null ? undefined : obj[path]
const [head, ...rest] = path.split('.')
let cur = obj == null ? undefined : obj[head]
for (const k of rest) cur = cur == null ? undefined : cur[k]
return cur
}
// Contiguous case-insensitive substring match -> the run of [start,end] indices.
function exactIndices(haystack, needle) {
const h = haystack.toLowerCase()
const n = needle.toLowerCase()
const out = []
let from = 0
let idx = h.indexOf(n, from)
while (idx !== -1) {
out.push([idx, idx + n.length - 1])
from = idx + n.length
idx = h.indexOf(n, from)
}
return out
}
// Fuzzy (subsequence) match: every char of `needle` appears in order in `haystack`.
// Returns the runs of consecutive matched positions, or null if not all chars matched.
function fuzzyIndices(haystack, needle) {
const h = haystack.toLowerCase()
const n = needle.toLowerCase()
const runs = []
let hi = 0
let runStart = -1
let runEnd = -1
for (let ni = 0; ni < n.length; ni++) {
const ch = n[ni]
let found = -1
for (let k = hi; k < h.length; k++) {
if (h[k] === ch) { found = k; break }
}
if (found === -1) return null
if (runStart === -1) { runStart = runEnd = found }
else if (found === runEnd + 1) { runEnd = found }
else { runs.push([runStart, runEnd]); runStart = runEnd = found }
hi = found + 1
}
if (runStart !== -1) runs.push([runStart, runEnd])
return runs
}
export default class Fuse {
constructor(list, options = {}) {
this.list = Array.isArray(list) ? list : []
this.keys = options.keys || []
this.threshold = typeof options.threshold === 'number' ? options.threshold : 0.4
this.minMatchCharLength = options.minMatchCharLength || 1
}
search(term) {
const q = String(term || '').trim()
if (!q) return this.list.map(item => ({ item, matches: [] }))
if (q.length < this.minMatchCharLength) return []
const exactOnly = this.threshold === 0
const results = []
for (const item of this.list) {
const matches = []
for (const key of this.keys) {
const raw = getByPath(item, key)
if (typeof raw !== 'string' || !raw) continue
let indices = exactIndices(raw, q)
if (indices.length === 0 && !exactOnly) {
const fz = fuzzyIndices(raw, q)
if (fz) {
// keep only runs at least minMatchCharLength long (mirrors Fuse's minMatch)
const kept = fz.filter(([s, e]) => e - s + 1 >= Math.min(this.minMatchCharLength, q.length))
if (kept.length) indices = kept
}
}
if (indices.length) matches.push({ key, indices })
}
if (matches.length) results.push({ item, matches })
}
return results
}
}