Add fuzzy search + richer POI data (address, bearing, fov)
- src/lib/search.ts: dependency-free fuzzy search engine that matches POIs and zones by name/address with text normalization (st->street, compass words), substring + Levenshtein fuzzy matching, and field-aware ranking - MapView.svelte: builds a searchable index from loaded layers, adds a search box that shows ranked results, pans/zooms + opens popup on select, and renders dedicated popup rows for address, Facing (compass), and Field of view - custom-layers.mjs: documents optional address / bearing / fov POI fields with examples; osm-data.mjs normalizes bearing (compass point or degrees) into degrees and carries the new properties through to GeoJSON - README: documents POI data fields and the search feature
This commit is contained in:
parent
347e9b2a0a
commit
63f1b75456
33
README.md
33
README.md
@ -103,12 +103,27 @@ Each layer has:
|
||||
- `color` — hex color used for markers/fill
|
||||
- `features` — array of feature objects
|
||||
|
||||
POI (point):
|
||||
POI (point) — optional `address`, `bearing`, and `fov` fields:
|
||||
|
||||
```js
|
||||
{ name: 'BOK Center', desc: 'Arena & events', lon: -95.9966, lat: 36.1498 }
|
||||
{
|
||||
name: 'Gathering Place',
|
||||
desc: 'Riverside park',
|
||||
address: '2650 S John Williams Way E, Tulsa, OK 74114',
|
||||
bearing: 'SE', // compass point (N, NNE, NE, ...) OR numeric degrees (0-360)
|
||||
fov: 140, // field of view in degrees (optional)
|
||||
lon: -95.9791,
|
||||
lat: 36.1098
|
||||
}
|
||||
```
|
||||
|
||||
- `address` — street address shown in the popup
|
||||
- `bearing` — compass direction the POI faces; accepts a compass point
|
||||
(`'N'`, `'NNE'`, `'NE'`, ...) or a numeric bearing in degrees
|
||||
- `fov` — field of view in degrees
|
||||
|
||||
The popup renders these as dedicated rows (Facing / Field of view).
|
||||
|
||||
Zone (polygon — the ring is closed for you):
|
||||
|
||||
```js
|
||||
@ -128,6 +143,20 @@ Coordinates are `[longitude, latitude]` (lon first, like GeoJSON). The file also
|
||||
includes a copy-paste TEMPLATE block for adding new layers. After editing, run
|
||||
`npm run build:data` (or `npm run build`) to regenerate the static data.
|
||||
|
||||
## Search
|
||||
|
||||
The webapp includes a **fuzzy search** in the top bar that matches POIs and
|
||||
zones by **name or address**. Typing at least two characters shows ranked
|
||||
results; selecting one pans/zooms the map to it and opens its popup.
|
||||
|
||||
The matcher:
|
||||
- Normalizes text (case, punctuation, and common address words — `st`→`street`,
|
||||
`rd`→`road`, `ave`→`avenue`, `n`→`north`, etc.), so abbreviated or partial
|
||||
addresses resolve correctly.
|
||||
- Uses substring + Levenshtein edit-distance tolerance, so typos and near-
|
||||
matches still hit (e.g. `philbrick` finds `Philbrook`).
|
||||
- Ranks name matches above address matches above description matches.
|
||||
|
||||
## Deployment
|
||||
|
||||
The `build/` directory is entirely static. Serve it directly:
|
||||
|
||||
@ -17,12 +17,20 @@
|
||||
*
|
||||
* Feature format (by layerType):
|
||||
* POI (point):
|
||||
* { name: 'Label', desc: 'optional description', lon: -95.7, lat: 36.1 }
|
||||
* { name, desc?, address?, bearing?, fov?, lon, lat }
|
||||
*
|
||||
* Zone (polygon — list the corner points; it is closed automatically):
|
||||
* { name: 'Label', desc: '...', polygon: [[lon,lat], [lon,lat], ...] }
|
||||
* { name, desc?, polygon: [[lon,lat], [lon,lat], ...] }
|
||||
*
|
||||
* Coordinates are [longitude, latitude] (lon first, like GeoJSON).
|
||||
* All fields except POI `lon`/`lat` (or zone `polygon`) and `name` are optional.
|
||||
*
|
||||
* Optional POI fields:
|
||||
* - address street address / place description
|
||||
* - bearing compass direction the POI faces. Accepts a compass point
|
||||
* ('N','NNE','NE',...) OR a numeric bearing in degrees (0-360,
|
||||
* 0 = north, 90 = east) OR a 16-point rose ('NE-by-N', etc).
|
||||
* - fov field of view in degrees (how wide the view / coverage is).
|
||||
*/
|
||||
|
||||
export const CUSTOM_LAYERS = [
|
||||
@ -36,10 +44,37 @@ export const CUSTOM_LAYERS = [
|
||||
layerType: 'poi',
|
||||
color: '#e11d48',
|
||||
features: [
|
||||
{ name: 'BOK Center', desc: 'Arena & events', lon: -95.9966, lat: 36.1498 },
|
||||
{ name: 'Philbrook Museum', desc: 'Art museum & gardens', lon: -95.9747, lat: 36.1258 },
|
||||
{ name: 'Gathering Place', desc: 'Riverside park', lon: -95.9791, lat: 36.1098 },
|
||||
{ name: 'Woodward Park', desc: 'Rose garden', lon: -95.9792, lat: 36.1334 }
|
||||
{
|
||||
name: 'BOK Center',
|
||||
desc: 'Arena & events',
|
||||
address: '100 W 5th St, Tulsa, OK 74103',
|
||||
lon: -95.9966,
|
||||
lat: 36.1498
|
||||
},
|
||||
{
|
||||
name: 'Philbrook Museum',
|
||||
desc: 'Art museum & gardens',
|
||||
address: '2727 S Rockford Rd, Tulsa, OK 74114',
|
||||
lon: -95.9747,
|
||||
lat: 36.1258
|
||||
},
|
||||
{
|
||||
name: 'Gathering Place',
|
||||
desc: 'Riverside park',
|
||||
address: '2650 S John Williams Way E, Tulsa, OK 74114',
|
||||
bearing: 'SE', // bearing + fov optional
|
||||
fov: 140,
|
||||
lon: -95.9791,
|
||||
lat: 36.1098
|
||||
},
|
||||
{
|
||||
name: 'Woodward Park',
|
||||
desc: 'Rose garden',
|
||||
address: '2435 S Peoria Ave, Tulsa, OK 74114',
|
||||
bearing: 10, // numeric bearing also accepted (degrees)
|
||||
lon: -95.9792,
|
||||
lat: 36.1334
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -85,7 +120,8 @@ export const CUSTOM_LAYERS = [
|
||||
// layerType: 'poi', // or 'zone'
|
||||
// color: '#22c55e',
|
||||
// features: [
|
||||
// { name: 'Example point', desc: '...', lon: -95.7, lat: 36.1 },
|
||||
// { name: 'Example point', desc: '...', address: '1 Main St',
|
||||
// bearing: 'N', fov: 90, lon: -95.7, lat: 36.1 },
|
||||
// { name: 'Example zone', desc: '...', polygon: [[-95.7, 36.1], [-95.6, 36.1], [-95.6, 36.2], [-95.7, 36.2]] }
|
||||
// ]
|
||||
// }
|
||||
|
||||
@ -177,6 +177,9 @@ function customFeatureToGeoJSON(f, layerId) {
|
||||
// Point of interest
|
||||
const props = { name: f.name };
|
||||
if (f.desc) props.description = f.desc;
|
||||
if (f.address) props.address = f.address;
|
||||
if (f.bearing !== undefined) props.bearing = normalizeBearing(f.bearing);
|
||||
if (f.fov !== undefined) props.fov = Number(f.fov);
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: `custom/${layerId}/${f.name}`,
|
||||
@ -192,6 +195,7 @@ function customFeatureToGeoJSON(f, layerId) {
|
||||
}
|
||||
const props = { name: f.name };
|
||||
if (f.desc) props.description = f.desc;
|
||||
if (f.address) props.address = f.address;
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: `custom/${layerId}/${f.name}`,
|
||||
@ -202,6 +206,30 @@ function customFeatureToGeoJSON(f, layerId) {
|
||||
throw new Error(`Feature "${f.name}" in layer "${layerId}" is missing lon/lat (POI) or polygon[>=3] (zone)`);
|
||||
}
|
||||
|
||||
// Normalize a compass/bearing value to a canonical numeric bearing in degrees
|
||||
// (0-360). Accepts compass points (N, NNE, NE, NE-by-N, SE, ...), 16-point rose
|
||||
// abbreviations, or a numeric degree value. Returns null if unrecognized.
|
||||
function normalizeBearing(val) {
|
||||
const COMPASS = {
|
||||
N: 0, 'N-by-E': 11.25, NNE: 22.5, 'NE-by-N': 33.75, NE: 45, 'NE-by-E': 56.25,
|
||||
ENE: 67.5, 'E-by-N': 78.75, E: 90, 'E-by-S': 101.25, ESE: 112.5, 'SE-by-E': 123.75,
|
||||
SE: 135, 'SE-by-S': 146.25, SSE: 157.5, 'S-by-E': 168.75, S: 180, 'S-by-W': 191.25,
|
||||
SSW: 202.5, 'SW-by-S': 213.75, SW: 225, 'SW-by-W': 236.25, WSW: 247.5, 'W-by-S': 258.75,
|
||||
W: 270, 'W-by-N': 281.25, WNW: 292.5, 'NW-by-W': 303.75, NW: 315, 'NW-by-N': 326.25,
|
||||
NNW: 337.5, 'N-by-W': 348.75
|
||||
};
|
||||
if (typeof val === 'number') {
|
||||
const n = ((Number(val) % 360) + 360) % 360;
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
const raw = String(val).trim().toUpperCase();
|
||||
if (raw in COMPASS) return COMPASS[raw];
|
||||
// Maybe a numeric string like "45" or "45.0"
|
||||
const num = Number(val);
|
||||
if (!Number.isNaN(num)) return Math.round(((num % 360) + 360) % 360 * 10) / 10;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function buildCustomLayers() {
|
||||
const entries = [];
|
||||
for (const layer of CUSTOM_LAYERS) {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Map as LeafletMap } from 'leaflet';
|
||||
import { search, type Searchable, type SearchResult } from '$lib/search';
|
||||
|
||||
type FeatureCollection = {
|
||||
type: string;
|
||||
@ -41,15 +42,37 @@
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
const layerGroups: Record<string, L.LayerGroup> = {};
|
||||
const featureLayers: Record<string, L.Layer & { bindPopup: (s: string) => void }> = {};
|
||||
// Which layers are currently displayed on the map.
|
||||
let visible = $state<Record<string, boolean>>({});
|
||||
let totalFeatures = $state(0);
|
||||
|
||||
function styleFor(type: MapLayer['layerType'], color: string): L.PathOptions {
|
||||
if (type === 'zone') {
|
||||
return { color, weight: 2, fillColor: color, fillOpacity: 0.2 };
|
||||
// --- search state ---
|
||||
const searchables: Searchable[] = [];
|
||||
let query = $state('');
|
||||
const results = $derived(query.trim().length >= 2 ? search(query, searchables) : []);
|
||||
let showResults = $state(false);
|
||||
|
||||
function focusOn(item: Searchable, openPopup = true) {
|
||||
if (!map) return;
|
||||
if (item.kind === 'zone' && item.bounds) {
|
||||
map.fitBounds(item.bounds as L.LatLngBoundsExpression, { padding: [60, 60] });
|
||||
} else {
|
||||
map.setView([item.lat, item.lon], Math.max(map.getZoom(), 15));
|
||||
}
|
||||
return { color, weight: 2, fillColor: color, fillOpacity: 0.25 };
|
||||
if (openPopup) {
|
||||
const fl = featureLayers[item.id];
|
||||
if (fl && typeof fl.bindPopup === 'function') {
|
||||
// openPopup is available on markers / path layers
|
||||
(fl as unknown as { openPopup: () => void }).openPopup?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onSelect(r: SearchResult) {
|
||||
query = r.item.name;
|
||||
showResults = false;
|
||||
focusOn(r.item);
|
||||
}
|
||||
|
||||
function toggle(name: string) {
|
||||
@ -60,14 +83,42 @@
|
||||
else map.removeLayer(g);
|
||||
}
|
||||
|
||||
function styleFor(type: MapLayer['layerType'], color: string): L.PathOptions {
|
||||
if (type === 'zone') {
|
||||
return { color, weight: 2, fillColor: color, fillOpacity: 0.2 };
|
||||
}
|
||||
return { color, weight: 2, fillColor: color, fillOpacity: 0.25 };
|
||||
}
|
||||
|
||||
const COMPASS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
||||
function compassPoint(deg: number): string {
|
||||
const idx = Math.round((((deg % 360) + 360) % 360) / 22.5) % 16;
|
||||
return COMPASS[idx];
|
||||
}
|
||||
|
||||
function popupHtml(f: GeoJSON.Feature): string {
|
||||
const props = (f.properties ?? {}) as Record<string, unknown>;
|
||||
const name = (props.name as string | undefined) ?? String(f.id ?? '');
|
||||
|
||||
let extra = '';
|
||||
if (props.address) extra += `<tr><th>Address</th><td>${String(props.address)}</td></tr>`;
|
||||
if (props.bearing !== undefined) {
|
||||
const b = Number(props.bearing);
|
||||
extra += `<tr><th>Facing</th><td>${compassPoint(b)} (${b}°)</td></tr>`;
|
||||
}
|
||||
if (props.fov !== undefined) {
|
||||
extra += `<tr><th>Field of view</th><td>${String(props.fov)}°</td></tr>`;
|
||||
}
|
||||
|
||||
const rows = Object.entries(props)
|
||||
.filter(([k]) => !k.startsWith('_'))
|
||||
.filter(([k]) => !['name', 'address', 'bearing', 'fov', 'description'].includes(k) && !k.startsWith('_'))
|
||||
.map(([k, v]) => `<tr><th>${k}</th><td>${String(v)}</td></tr>`)
|
||||
.join('');
|
||||
return `\n\t\t\t<strong>${name}</strong>\n\t\t\t<table class="popup">${rows}</table>`;
|
||||
|
||||
let desc = '';
|
||||
if (props.description) desc = `<div class="popup-desc">${String(props.description)}</div>`;
|
||||
|
||||
return `<strong>${name}</strong>${desc}<table class="popup">${extra}${rows}</table><div class="popup-layer">${' '}</div>`;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
@ -117,7 +168,39 @@
|
||||
style: styleFor(layer.layerType, color),
|
||||
onEachFeature: (f, lay) => {
|
||||
totalFeatures++;
|
||||
const props = (f.properties ?? {}) as Record<string, unknown>;
|
||||
lay.bindPopup(popupHtml(f));
|
||||
featureLayers[f.id as string] = lay;
|
||||
|
||||
// Build a searchable record.
|
||||
let lat, lon, bounds;
|
||||
if (f.geometry.type === 'Point') {
|
||||
[lon, lat] = f.geometry.coordinates as [number, number];
|
||||
} else if (f.geometry.type === 'Polygon') {
|
||||
const ring = (f.geometry.coordinates as number[][][])[0];
|
||||
const lats = ring.map((c) => c[1]);
|
||||
const lons = ring.map((c) => c[0]);
|
||||
lat = (Math.min(...lats) + Math.max(...lats)) / 2;
|
||||
lon = (Math.min(...lons) + Math.max(...lons)) / 2;
|
||||
bounds = [[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]];
|
||||
}
|
||||
const kind = layer.layerType === 'zone' ? 'zone' : 'poi';
|
||||
searchables.push({
|
||||
id: f.id as string,
|
||||
name: (props.name as string) ?? String(f.id ?? ''),
|
||||
label: (props.name as string) ?? String(f.id ?? ''),
|
||||
kind,
|
||||
layerName: layer.name,
|
||||
layerLabel: layer.label,
|
||||
layerColor: layer.color,
|
||||
description: props.description as string | undefined,
|
||||
address: props.address as string | undefined,
|
||||
bearing: props.bearing as number | undefined,
|
||||
fov: props.fov as number | undefined,
|
||||
lat: lat as number, lon: lon as number,
|
||||
bounds,
|
||||
feature: f
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -145,7 +228,30 @@
|
||||
<div class="wrap">
|
||||
<header class="topbar">
|
||||
<h1>{title}</h1>
|
||||
<span class="meta">{totalFeatures} features loaded</span>
|
||||
<div class="search" role="search">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search POIs & zones by name or address…"
|
||||
bind:value={query}
|
||||
onfocus={() => (showResults = true)}
|
||||
oninput={() => (showResults = true)}
|
||||
/>
|
||||
{#if results.length > 0 && showResults}
|
||||
<ul class="results">
|
||||
{#each results as r (r.item.id)}
|
||||
<li>
|
||||
<button type="button" onmouseenter={() => focusOn(r.item, false)} onclick={() => onSelect(r)}>
|
||||
<span class="dot" style="background:{r.item.layerColor}"></span>
|
||||
<span class="rname">{r.item.label}</span>
|
||||
<span class="rtype">{r.item.kind}</span>
|
||||
{#if r.item.address}<span class="raddr">{r.item.address}</span>{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="meta">{totalFeatures} features</span>
|
||||
</header>
|
||||
|
||||
{#if loading}<div class="notice">Loading map & data…</div>{/if}
|
||||
@ -168,12 +274,37 @@
|
||||
<style>
|
||||
.wrap { display: grid; grid-template-rows: auto 1fr; height: 100vh; position: relative; }
|
||||
.topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
display: flex; align-items: center; gap: 1.5rem;
|
||||
padding: 0 1.25rem; height: 56px;
|
||||
background: #0f172a; color: #fff; font-family: system-ui, sans-serif;
|
||||
}
|
||||
.topbar h1 { font-size: 1.15rem; margin: 0; font-weight: 600; }
|
||||
.meta { font-size: 0.8rem; color: #94a3b8; }
|
||||
.meta { font-size: 0.8rem; color: #94a3b8; margin-left: auto; }
|
||||
|
||||
.search { position: relative; flex: 0 1 380px; }
|
||||
.search input {
|
||||
width: 100%; padding: 8px 12px; border-radius: 8px;
|
||||
border: 1px solid #475569; background: #1e293b; color: #fff;
|
||||
font-size: 0.85rem; outline: none;
|
||||
}
|
||||
.search input:focus { border-color: #38bdf8; }
|
||||
.results {
|
||||
position: absolute; top: calc(100% + 6px); left: 0; right: 0;
|
||||
background: #fff; border-radius: 8px; box-shadow: 0 6px 20px rgba(0,0,0,.3);
|
||||
list-style: none; margin: 0; padding: 6px; max-height: 320px; overflow: auto;
|
||||
z-index: 2000;
|
||||
}
|
||||
.results li button {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||
background: none; border: none; cursor: pointer; text-align: left;
|
||||
padding: 7px 8px; border-radius: 6px; font: 0.8rem system-ui, sans-serif; color: #0f172a;
|
||||
}
|
||||
.results li button:hover { background: #f1f5f9; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
|
||||
.rname { font-weight: 600; }
|
||||
.rtype { text-transform: uppercase; font-size: 0.62rem; padding: 1px 5px; border-radius: 4px; background: #e2e8f0; color: #475569; }
|
||||
.raddr { color: #64748b; font-size: 0.7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.map { width: 100%; height: 100%; z-index: 0; }
|
||||
.notice {
|
||||
position: absolute; top: 66px; left: 50%; transform: translateX(-50%);
|
||||
@ -184,7 +315,7 @@
|
||||
.notice.error { background: #b91c1c; }
|
||||
|
||||
.legend {
|
||||
position: absolute; top: 66px; right: 12px; z-index: 1000;
|
||||
position: absolute; top: 66px; right: 12px; z-index: 1500;
|
||||
background: #fff; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,.25);
|
||||
padding: 10px; min-width: 170px;
|
||||
font: 0.8rem system-ui, sans-serif; color: #0f172a;
|
||||
@ -195,7 +326,8 @@
|
||||
.lbl { flex: 1; }
|
||||
.count { color: #94a3b8; font-size: 0.7rem; }
|
||||
:global(.popup) { border-collapse: collapse; margin-top: 4px; font-size: 0.75rem; }
|
||||
:global(.popup th) { text-align: left; padding-right: 10px; color: #64748b; }
|
||||
:global(.popup th) { text-align: left; padding-right: 10px; color: #64748b; font-weight: 600; }
|
||||
:global(.popup td) { padding: 1px 0; }
|
||||
:global(.popup-desc) { color: #475569; margin-top: 2px; font-size: 0.75rem; }
|
||||
:global(.leaflet-container) { font: inherit; }
|
||||
</style>
|
||||
208
src/lib/search.ts
Normal file
208
src/lib/search.ts
Normal file
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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';
|
||||
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);
|
||||
}
|
||||
@ -9,7 +9,7 @@
|
||||
36.3,
|
||||
-95.7
|
||||
],
|
||||
"featureCount": 512,
|
||||
"featureCount": 462,
|
||||
"path": "/data/tulsa.geojson"
|
||||
},
|
||||
{
|
||||
|
||||
@ -1 +1 @@
|
||||
{"type":"FeatureCollection","crs":{"type":"name","properties":{"name":"urn:ogc:def:crs:OGC:1.3:CRS84"}},"features":[{"type":"Feature","id":"custom/my-places/BOK Center","properties":{"name":"BOK Center","description":"Arena & events"},"geometry":{"type":"Point","coordinates":[-95.9966,36.1498]}},{"type":"Feature","id":"custom/my-places/Philbrook Museum","properties":{"name":"Philbrook Museum","description":"Art museum & gardens"},"geometry":{"type":"Point","coordinates":[-95.9747,36.1258]}},{"type":"Feature","id":"custom/my-places/Gathering Place","properties":{"name":"Gathering Place","description":"Riverside park"},"geometry":{"type":"Point","coordinates":[-95.9791,36.1098]}},{"type":"Feature","id":"custom/my-places/Woodward Park","properties":{"name":"Woodward Park","description":"Rose garden"},"geometry":{"type":"Point","coordinates":[-95.9792,36.1334]}}]}
|
||||
{"type":"FeatureCollection","crs":{"type":"name","properties":{"name":"urn:ogc:def:crs:OGC:1.3:CRS84"}},"features":[{"type":"Feature","id":"custom/my-places/BOK Center","properties":{"name":"BOK Center","description":"Arena & events","address":"100 W 5th St, Tulsa, OK 74103"},"geometry":{"type":"Point","coordinates":[-95.9966,36.1498]}},{"type":"Feature","id":"custom/my-places/Philbrook Museum","properties":{"name":"Philbrook Museum","description":"Art museum & gardens","address":"2727 S Rockford Rd, Tulsa, OK 74114"},"geometry":{"type":"Point","coordinates":[-95.9747,36.1258]}},{"type":"Feature","id":"custom/my-places/Gathering Place","properties":{"name":"Gathering Place","description":"Riverside park","address":"2650 S John Williams Way E, Tulsa, OK 74114","bearing":135,"fov":140},"geometry":{"type":"Point","coordinates":[-95.9791,36.1098]}},{"type":"Feature","id":"custom/my-places/Woodward Park","properties":{"name":"Woodward Park","description":"Rose garden","address":"2435 S Peoria Ave, Tulsa, OK 74114","bearing":10},"geometry":{"type":"Point","coordinates":[-95.9792,36.1334]}}]}
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user