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:
hermes-explorigin 2026-08-08 17:01:10 +00:00
parent 347e9b2a0a
commit 63f1b75456
8 changed files with 457 additions and 24 deletions

View File

@ -103,12 +103,27 @@ Each layer has:
- `color` — hex color used for markers/fill - `color` — hex color used for markers/fill
- `features` — array of feature objects - `features` — array of feature objects
POI (point): POI (point) — optional `address`, `bearing`, and `fov` fields:
```js ```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): Zone (polygon — the ring is closed for you):
```js ```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 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. `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 ## Deployment
The `build/` directory is entirely static. Serve it directly: The `build/` directory is entirely static. Serve it directly:

View File

@ -17,12 +17,20 @@
* *
* Feature format (by layerType): * Feature format (by layerType):
* POI (point): * 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): * 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). * 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 = [ export const CUSTOM_LAYERS = [
@ -36,10 +44,37 @@ export const CUSTOM_LAYERS = [
layerType: 'poi', layerType: 'poi',
color: '#e11d48', color: '#e11d48',
features: [ 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: 'BOK Center',
{ name: 'Gathering Place', desc: 'Riverside park', lon: -95.9791, lat: 36.1098 }, desc: 'Arena & events',
{ name: 'Woodward Park', desc: 'Rose garden', lon: -95.9792, lat: 36.1334 } 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' // layerType: 'poi', // or 'zone'
// color: '#22c55e', // color: '#22c55e',
// features: [ // 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]] } // { name: 'Example zone', desc: '...', polygon: [[-95.7, 36.1], [-95.6, 36.1], [-95.6, 36.2], [-95.7, 36.2]] }
// ] // ]
// } // }

View File

@ -177,6 +177,9 @@ function customFeatureToGeoJSON(f, layerId) {
// Point of interest // Point of interest
const props = { name: f.name }; const props = { name: f.name };
if (f.desc) props.description = f.desc; 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 { return {
type: 'Feature', type: 'Feature',
id: `custom/${layerId}/${f.name}`, id: `custom/${layerId}/${f.name}`,
@ -192,6 +195,7 @@ function customFeatureToGeoJSON(f, layerId) {
} }
const props = { name: f.name }; const props = { name: f.name };
if (f.desc) props.description = f.desc; if (f.desc) props.description = f.desc;
if (f.address) props.address = f.address;
return { return {
type: 'Feature', type: 'Feature',
id: `custom/${layerId}/${f.name}`, 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)`); 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() { async function buildCustomLayers() {
const entries = []; const entries = [];
for (const layer of CUSTOM_LAYERS) { for (const layer of CUSTOM_LAYERS) {

View File

@ -2,6 +2,7 @@
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { Map as LeafletMap } from 'leaflet'; import type { Map as LeafletMap } from 'leaflet';
import { search, type Searchable, type SearchResult } from '$lib/search';
type FeatureCollection = { type FeatureCollection = {
type: string; type: string;
@ -41,15 +42,37 @@
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
const layerGroups: Record<string, L.LayerGroup> = {}; const layerGroups: Record<string, L.LayerGroup> = {};
const featureLayers: Record<string, L.Layer & { bindPopup: (s: string) => void }> = {};
// Which layers are currently displayed on the map. // Which layers are currently displayed on the map.
let visible = $state<Record<string, boolean>>({}); let visible = $state<Record<string, boolean>>({});
let totalFeatures = $state(0); let totalFeatures = $state(0);
function styleFor(type: MapLayer['layerType'], color: string): L.PathOptions { // --- search state ---
if (type === 'zone') { const searchables: Searchable[] = [];
return { color, weight: 2, fillColor: color, fillOpacity: 0.2 }; 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) { function toggle(name: string) {
@ -60,14 +83,42 @@
else map.removeLayer(g); 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 { function popupHtml(f: GeoJSON.Feature): string {
const props = (f.properties ?? {}) as Record<string, unknown>; const props = (f.properties ?? {}) as Record<string, unknown>;
const name = (props.name as string | undefined) ?? String(f.id ?? ''); 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}&deg;)</td></tr>`;
}
if (props.fov !== undefined) {
extra += `<tr><th>Field of view</th><td>${String(props.fov)}&deg;</td></tr>`;
}
const rows = Object.entries(props) 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>`) .map(([k, v]) => `<tr><th>${k}</th><td>${String(v)}</td></tr>`)
.join(''); .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 () => { onMount(async () => {
@ -117,7 +168,39 @@
style: styleFor(layer.layerType, color), style: styleFor(layer.layerType, color),
onEachFeature: (f, lay) => { onEachFeature: (f, lay) => {
totalFeatures++; totalFeatures++;
const props = (f.properties ?? {}) as Record<string, unknown>;
lay.bindPopup(popupHtml(f)); 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"> <div class="wrap">
<header class="topbar"> <header class="topbar">
<h1>{title}</h1> <h1>{title}</h1>
<span class="meta">{totalFeatures} features loaded</span> <div class="search" role="search">
<input
type="search"
placeholder="Search POIs &amp; 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> </header>
{#if loading}<div class="notice">Loading map &amp; data…</div>{/if} {#if loading}<div class="notice">Loading map &amp; data…</div>{/if}
@ -168,12 +274,37 @@
<style> <style>
.wrap { display: grid; grid-template-rows: auto 1fr; height: 100vh; position: relative; } .wrap { display: grid; grid-template-rows: auto 1fr; height: 100vh; position: relative; }
.topbar { .topbar {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; gap: 1.5rem;
padding: 0 1.25rem; height: 56px; padding: 0 1.25rem; height: 56px;
background: #0f172a; color: #fff; font-family: system-ui, sans-serif; background: #0f172a; color: #fff; font-family: system-ui, sans-serif;
} }
.topbar h1 { font-size: 1.15rem; margin: 0; font-weight: 600; } .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; } .map { width: 100%; height: 100%; z-index: 0; }
.notice { .notice {
position: absolute; top: 66px; left: 50%; transform: translateX(-50%); position: absolute; top: 66px; left: 50%; transform: translateX(-50%);
@ -184,7 +315,7 @@
.notice.error { background: #b91c1c; } .notice.error { background: #b91c1c; }
.legend { .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); background: #fff; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,.25);
padding: 10px; min-width: 170px; padding: 10px; min-width: 170px;
font: 0.8rem system-ui, sans-serif; color: #0f172a; font: 0.8rem system-ui, sans-serif; color: #0f172a;
@ -195,7 +326,8 @@
.lbl { flex: 1; } .lbl { flex: 1; }
.count { color: #94a3b8; font-size: 0.7rem; } .count { color: #94a3b8; font-size: 0.7rem; }
:global(.popup) { border-collapse: collapse; margin-top: 4px; font-size: 0.75rem; } :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 td) { padding: 1px 0; }
:global(.popup-desc) { color: #475569; margin-top: 2px; font-size: 0.75rem; }
:global(.leaflet-container) { font: inherit; } :global(.leaflet-container) { font: inherit; }
</style> </style>

208
src/lib/search.ts Normal file
View 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);
}

View File

@ -9,7 +9,7 @@
36.3, 36.3,
-95.7 -95.7
], ],
"featureCount": 512, "featureCount": 462,
"path": "/data/tulsa.geojson" "path": "/data/tulsa.geojson"
}, },
{ {

View File

@ -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