105 lines
4 KiB
JavaScript
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
|
|
}
|
|
}
|