navigator/src/lib/search.ts
hermes-explorigin 4e3fef6559 Add path/route layer type + online geocoding + Rogers County example data
- custom-layers.mjs: new 'path' layerType (LineStrings like GPX tracks/trails)
  with a 'line' feature format, an example trails layer, and a seeded
  Rogers County POI layer with real geocoded coordinates (museums, courthouse,
  university, towns)
- osm-data.mjs: converts path features to GeoJSON LineStrings and tags
  layerType='path' in the manifest; fixes kind detection for all three types
- MapView.svelte: styles path layers as dashed polylines, computes bounds for
  LineStrings, supports 'path' in search results, and adds a Nominatim
  'Places (online)' geocoding fallback section to the search box (debounced,
  fit-to-bounds on select)
- geocode.ts: dependency-free Nominatim client
- README: documents path layers and online location search
2026-08-08 18:26:48 +00:00

208 lines
5.9 KiB
TypeScript

/**
* 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<string, string> = {
// 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);
}