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
|
- `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:
|
||||||
|
|||||||
@ -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]] }
|
||||||
// ]
|
// ]
|
||||||
// }
|
// }
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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}°)</td></tr>`;
|
||||||
|
}
|
||||||
|
if (props.fov !== undefined) {
|
||||||
|
extra += `<tr><th>Field of view</th><td>${String(props.fov)}°</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 & 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 & data…</div>{/if}
|
{#if loading}<div class="notice">Loading map & 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
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,
|
36.3,
|
||||||
-95.7
|
-95.7
|
||||||
],
|
],
|
||||||
"featureCount": 512,
|
"featureCount": 462,
|
||||||
"path": "/data/tulsa.geojson"
|
"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