- 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
208 lines
5.9 KiB
TypeScript
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);
|
|
} |