/** * search.ts — fuzzy search over Navigator's POIs and zones. * ---------------------------------------------------------- * Pure, dependency-free search module. The page builds a flat list of * "searchable" records from the loaded layer data, then this module ranks * them against a free-text query. * * Matching features: * - Token-based: the query is split into tokens; each token must loosely * match the record's name / description / address. * - Fuzzy: tokens are matched with substring + Levenshtein edit-distance * tolerance, so typos and near-matches still hit (e.g. "peoria av" finds * "Peoria Ave", "philbrick" finds "Philbrook"). * - Address-aware: common address words and suffixes (st, rd, ave, dr, num, * north/south/east/west, numbers) are normalized so partial addresses * resolve to nearby points. * - Ranking: matches on name score highest, then address, then description. */ export interface Searchable { id: string; name: string; label: string; // human label in results kind: 'poi' | 'zone' | 'path'; layerName: string; layerLabel: string; layerColor: string; description?: string; address?: string; bearing?: number; fov?: number; // Map focus target after selection: lat: number; lon: number; bounds?: unknown; // zones provide bounds for fit (Leaflet LatLngBounds) feature: GeoJSON.Feature; } export interface SearchResult { item: Searchable; score: number; matchField: 'name' | 'address' | 'description' | 'fuzzy'; highlights: string[]; } // --------------------------------------------------------------------------- // Text normalization // --------------------------------------------------------------------------- // Expand common street suffixes & compass words so partial/abbreviated // addresses match canonical ones. const EXPANSIONS: Record = { // street suffixes st: 'street', street: 'street', rd: 'road', road: 'road', ave: 'avenue', av: 'avenue', avenue: 'avenue', blvd: 'boulevard', boulevard: 'boulevard', dr: 'drive', drive: 'drive', ln: 'lane', lane: 'lane', ct: 'court', court: 'court', pl: 'place', place: 'place', ter: 'terrace', terrace: 'terrace', trl: 'trail', trail: 'trail', pkwy: 'parkway', parkway: 'parkway', hwy: 'highway', highway: 'highway', // compass n: 'north', north: 'north', s: 'south', south: 'south', e: 'east', east: 'east', w: 'west', west: 'west' }; export function normalizeText(value: string): string[] { return value .toLowerCase() .replace(/[.,/#!$%^&*;:{}="'`~()|<>?[\]\\]/g, ' ') .replace(/\s+/g, ' ') .trim() .split(' ') .map((t) => EXPANSIONS[t] ?? t) .filter(Boolean); } function editDistance(a: string, b: string): number { const m = a.length; const n = b.length; if (m === 0) return n; if (n === 0) return m; const dp: number[] = Array.from({ length: n + 1 }, (_, i) => i); for (let i = 1; i <= m; i++) { let prev = dp[0]; dp[0] = i; for (let j = 1; j <= n; j++) { const tmp = dp[j]; dp[j] = Math.min( dp[j] + 1, // deletion dp[j - 1] + 1, // insertion prev + (a[i - 1] === b[j - 1] ? 0 : 1) // substitution ); prev = tmp; } } return dp[n]; } // A query token is a "hit" against a record token if they're equal, one is a // prefix of the other, or their edit distance is small relative to the length. function tokensMatch(q: string, target: string): boolean { if (q === target) return true; if (q.length >= 3 && target.startsWith(q)) return true; if (target.length >= 3 && q.startsWith(target)) return true; const maxDist = q.length <= 3 ? 1 : 2; if (q.length >= 3 && editDistance(q, target) <= maxDist) return true; return false; } // Does a single normalized query token appear anywhere in the record's tokens? function fieldHasToken(qToken: string, fieldTokens: string[]): boolean { return fieldTokens.some((t) => tokensMatch(qToken, t)); } // --------------------------------------------------------------------------- // Search // --------------------------------------------------------------------------- export function search( query: string, items: Searchable[] ): SearchResult[] { const qTokens = normalizeText(query); if (qTokens.length === 0) return []; const results: SearchResult[] = []; for (const item of items) { const nameTokens = normalizeText(item.name); const addressTokens = item.address ? normalizeText(item.address) : []; const descTokens = item.description ? normalizeText(item.description) : []; let score = 0; let matchField: SearchResult['matchField'] = 'fuzzy'; const highlights: string[] = []; let nameHits = 0; let addressHits = 0; let descHits = 0; for (const qt of qTokens) { if (fieldHasToken(qt, nameTokens)) nameHits++; if (fieldHasToken(qt, addressTokens)) addressHits++; if (fieldHasToken(qt, descTokens)) descHits++; } const totalQLen = qTokens.length; const nameRatio = nameHits / totalQLen; const addressRatio = addressHits / totalQLen; const descRatio = descHits / totalQLen; // Name matches weigh heaviest. if (nameRatio >= 0.5) { score += 100 * nameRatio; matchField = 'name'; } // Address matches next (an exact token hit is worth more than the ratio). if (addressRatio >= 0.5) { score += 60 * addressRatio; if (nameRatio < 0.5) matchField = 'address'; } // Description small weight. if (descRatio >= 0.75) { score += 15 * descRatio; if (nameRatio < 0.5 && addressRatio < 0.5) matchField = 'description'; } // Favor exact whole-field matches. if (nameRatio === 1 && nameTokens.length === qTokens.length) score += 50; // Fill highlights for display. if (nameRatio > 0) highlights.push(item.name); if (item.address && addressRatio > 0) highlights.push(item.address); // Only keep records with at least one meaningful hit. if (nameHits > 0 || addressHits > 0 || (descHits > 0 && descRatio >= 0.75)) { results.push({ item, score, matchField, highlights }); } } return results .sort((a, b) => b.score - a.score) .slice(0, 12); }