Add path/route layer type + online geocoding + Rogers County example data
- custom-layers.mjs: new 'path' layerType (LineStrings like GPX tracks/trails) with a 'line' feature format, an example trails layer, and a seeded Rogers County POI layer with real geocoded coordinates (museums, courthouse, university, towns) - osm-data.mjs: converts path features to GeoJSON LineStrings and tags layerType='path' in the manifest; fixes kind detection for all three types - MapView.svelte: styles path layers as dashed polylines, computes bounds for LineStrings, supports 'path' in search results, and adds a Nominatim 'Places (online)' geocoding fallback section to the search box (debounced, fit-to-bounds on select) - geocode.ts: dependency-free Nominatim client - README: documents path layers and online location search
This commit is contained in:
parent
63f1b75456
commit
4e3fef6559
37
README.md
37
README.md
@ -89,18 +89,19 @@ parks: `
|
|||||||
List configured queries with `npm run data -- --queries`, or build a single area
|
List configured queries with `npm run data -- --queries`, or build a single area
|
||||||
with `npm run data -- --area=tulsa`.
|
with `npm run data -- --area=tulsa`.
|
||||||
|
|
||||||
## Custom Layers (Zones & Points of Interest)
|
## Custom Layers (Zones, Points of Interest & Paths)
|
||||||
|
|
||||||
Besides Overpass-fetched data, you can define your own layers by hand in
|
Besides Overpass-fetched data, you can define your own layers by hand in
|
||||||
**`scripts/custom-layers.mjs`**. This file ships with a couple of example layers
|
**`scripts/custom-layers.mjs`**. It ships with several example layers
|
||||||
(`my-places` POIs and `districts` zones) that you can extend or replace.
|
(`my-places` POIs, `districts` zones, `trails` path, and `rogers-county` local
|
||||||
|
POIs) that you can extend or replace.
|
||||||
|
|
||||||
Each layer has:
|
Each layer has:
|
||||||
|
|
||||||
- `id` — unique slug (becomes the output filename)
|
- `id` — unique slug (becomes the output filename)
|
||||||
- `name` — label shown in the UI legend
|
- `name` — label shown in the UI legend
|
||||||
- `layerType` — `'zone'` (polygon) or `'poi'` (point)
|
- `layerType` — `'poi'` (point), `'zone'` (polygon), or `'path'` (LineString)
|
||||||
- `color` — hex color used for markers/fill
|
- `color` — hex color used for markers/fill/lines
|
||||||
- `features` — array of feature objects
|
- `features` — array of feature objects
|
||||||
|
|
||||||
POI (point) — optional `address`, `bearing`, and `fov` fields:
|
POI (point) — optional `address`, `bearing`, and `fov` fields:
|
||||||
@ -139,15 +140,35 @@ Zone (polygon — the ring is closed for you):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Path / route (LineString — an ordered list of points like a GPX track):
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
name: 'Claremore Lake Loop',
|
||||||
|
desc: 'Example walking route around the lake',
|
||||||
|
line: [
|
||||||
|
[-95.5649, 36.3415],
|
||||||
|
[-95.5700, 36.3430],
|
||||||
|
[-95.5750, 36.3420],
|
||||||
|
[-95.5720, 36.3360]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Paths render as dashed polylines and are searchable by name.
|
||||||
|
|
||||||
Coordinates are `[longitude, latitude]` (lon first, like GeoJSON). The file also
|
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
|
## Search
|
||||||
|
|
||||||
The webapp includes a **fuzzy search** in the top bar that matches POIs and
|
The webapp includes a **fuzzy search** in the top bar. It matches your POIs,
|
||||||
zones by **name or address**. Typing at least two characters shows ranked
|
zones, and paths by **name or address** — and, as a fallback, queries the
|
||||||
results; selecting one pans/zooms the map to it and opens its popup.
|
[Nominatim](https://nominatim.openstreetmap.org/) OSM geocoder so you can also
|
||||||
|
search for **any location by name** (cities, landmarks, addresses anywhere). A
|
||||||
|
"Places (online)" section appears beneath the local matches; selecting a result
|
||||||
|
pans/zooms the map to it (fit-to-bounds for places with a bounding box).
|
||||||
|
|
||||||
The matcher:
|
The matcher:
|
||||||
- Normalizes text (case, punctuation, and common address words — `st`→`street`,
|
- Normalizes text (case, punctuation, and common address words — `st`→`street`,
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Custom Layers Configuration
|
* Custom Layers Configuration
|
||||||
* ===========================
|
* ===========================
|
||||||
* Define your own map layers here — **zones** (polygons/areas) and
|
* Define your own map layers here — **zones** (polygons/areas),
|
||||||
* **points of interest** (POIs). These are bundled into the static build
|
* **points of interest** (POIs), and **paths/routes** (LineStrings like GPX
|
||||||
* alongside the Overpass OSM data.
|
* tracks & trails). These are bundled into the static build alongside the
|
||||||
|
* Overpass OSM data.
|
||||||
*
|
*
|
||||||
* This file is meant to be edited by hand. To add features, just append
|
* This file is meant to be edited by hand. To add features, just append
|
||||||
* to the `features` array of an existing layer, or add a whole new layer.
|
* to the `features` array of an existing layer, or add a whole new layer.
|
||||||
@ -11,8 +12,8 @@
|
|||||||
* Each layer needs:
|
* Each layer needs:
|
||||||
* - id unique slug (used for the output filename)
|
* - id unique slug (used for the output filename)
|
||||||
* - name human-readable name shown in the UI
|
* - name human-readable name shown in the UI
|
||||||
* - layerType 'zone' or 'poi'
|
* - layerType 'poi' | 'zone' | 'path'
|
||||||
* - color hex color used for the marker/fill
|
* - color hex color used for the marker/fill/line
|
||||||
* - features array of feature objects (see below)
|
* - features array of feature objects (see below)
|
||||||
*
|
*
|
||||||
* Feature format (by layerType):
|
* Feature format (by layerType):
|
||||||
@ -22,8 +23,12 @@
|
|||||||
* Zone (polygon — list the corner points; it is closed automatically):
|
* Zone (polygon — list the corner points; it is closed automatically):
|
||||||
* { name, desc?, polygon: [[lon,lat], [lon,lat], ...] }
|
* { name, desc?, polygon: [[lon,lat], [lon,lat], ...] }
|
||||||
*
|
*
|
||||||
|
* Path / route (LineString — an ordered list of points, like a track):
|
||||||
|
* { name, desc?, line: [[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.
|
* All fields except the geometry (`lon`/`lat`, `polygon`, or `line`) and
|
||||||
|
* `name` are optional.
|
||||||
*
|
*
|
||||||
* Optional POI fields:
|
* Optional POI fields:
|
||||||
* - address street address / place description
|
* - address street address / place description
|
||||||
@ -111,18 +116,100 @@ export const CUSTOM_LAYERS = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// EXAMPLE — Path / route
|
||||||
|
// A LineString track, like a GPX route or trail. Each feature uses `line`.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 'trails',
|
||||||
|
name: 'Trails & Routes',
|
||||||
|
layerType: 'path',
|
||||||
|
color: '#22c55e',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
name: 'Claremore Lake Loop',
|
||||||
|
desc: 'Example walking route around the lake',
|
||||||
|
line: [
|
||||||
|
[-95.5649, 36.3415],
|
||||||
|
[-95.5700, 36.3430],
|
||||||
|
[-95.5750, 36.3420],
|
||||||
|
[-95.5720, 36.3360],
|
||||||
|
[-95.5660, 36.3365]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// EXAMPLE — Rogers County local layer
|
||||||
|
// Real places around your area (coordinates via Nominatim). Add your own
|
||||||
|
// home / property / haunts here.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
id: 'rogers-county',
|
||||||
|
name: 'Rogers County',
|
||||||
|
layerType: 'poi',
|
||||||
|
color: '#f59e0b',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
name: 'Claremore Museum of History',
|
||||||
|
desc: 'Local museum',
|
||||||
|
address: '121 N Weenonah Ave, Claremore, OK 74017',
|
||||||
|
lon: -95.6112936,
|
||||||
|
lat: 36.3114856
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Rogers County Courthouse',
|
||||||
|
desc: 'County seat',
|
||||||
|
lon: -95.6164140,
|
||||||
|
lat: 36.3110143
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Claremore Indian Hospital',
|
||||||
|
desc: 'Health center',
|
||||||
|
lon: -95.6292597,
|
||||||
|
lat: 36.3162771
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Rogers State University',
|
||||||
|
desc: 'University',
|
||||||
|
address: '1701 W Will Rogers Blvd, Claremore, OK 74017',
|
||||||
|
lon: -95.6361137,
|
||||||
|
lat: 36.3186099
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Lake Claremore',
|
||||||
|
lon: -95.5649169,
|
||||||
|
lat: 36.3414586
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Oologah',
|
||||||
|
desc: 'Town',
|
||||||
|
lon: -95.7083151,
|
||||||
|
lat: 36.4470387
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Verdigris',
|
||||||
|
desc: 'Town',
|
||||||
|
lon: -95.6910927,
|
||||||
|
lat: 36.2348197
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// TEMPLATE — copy this block to add a new layer
|
// TEMPLATE — copy this block to add a new layer
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// {
|
// {
|
||||||
// id: 'your-layer',
|
// id: 'your-layer',
|
||||||
// name: 'Your Layer',
|
// name: 'Your Layer',
|
||||||
// layerType: 'poi', // or 'zone'
|
// layerType: 'poi', // or 'zone' | 'path'
|
||||||
// color: '#22c55e',
|
// color: '#22c55e',
|
||||||
// features: [
|
// features: [
|
||||||
// { name: 'Example point', desc: '...', address: '1 Main St',
|
// { name: 'Example point', desc: '...', address: '1 Main St',
|
||||||
// bearing: 'N', fov: 90, lon: -95.7, lat: 36.1 },
|
// 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]] },
|
||||||
|
// { name: 'Example path', desc: '...', line: [[-95.7, 36.1], [-95.6, 36.1], [-95.6, 36.2]] }
|
||||||
// ]
|
// ]
|
||||||
// }
|
// }
|
||||||
];
|
];
|
||||||
@ -170,7 +170,7 @@ async function buildArea(name, cfg, signal) {
|
|||||||
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Custom layers: convert hand-authored zones / POIs to static GeoJSON.
|
// Custom layers: convert hand-authored POIs / zones / paths to GeoJSON.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function customFeatureToGeoJSON(f, layerId) {
|
function customFeatureToGeoJSON(f, layerId) {
|
||||||
if (f.lon !== undefined && f.lat !== undefined) {
|
if (f.lon !== undefined && f.lat !== undefined) {
|
||||||
@ -187,6 +187,19 @@ function customFeatureToGeoJSON(f, layerId) {
|
|||||||
geometry: { type: 'Point', coordinates: [f.lon, f.lat] }
|
geometry: { type: 'Point', coordinates: [f.lon, f.lat] }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(f.line) && f.line.length >= 2) {
|
||||||
|
// Path / route — LineString.
|
||||||
|
const coords = f.line.map(([lon, lat]) => [lon, lat]);
|
||||||
|
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}`,
|
||||||
|
properties: props,
|
||||||
|
geometry: { type: 'LineString', coordinates: coords }
|
||||||
|
};
|
||||||
|
}
|
||||||
if (Array.isArray(f.polygon) && f.polygon.length >= 3) {
|
if (Array.isArray(f.polygon) && f.polygon.length >= 3) {
|
||||||
// Polygon / zone: close the ring automatically if not closed.
|
// Polygon / zone: close the ring automatically if not closed.
|
||||||
const ring = f.polygon.map(([lon, lat]) => [lon, lat]);
|
const ring = f.polygon.map(([lon, lat]) => [lon, lat]);
|
||||||
@ -203,7 +216,7 @@ function customFeatureToGeoJSON(f, layerId) {
|
|||||||
geometry: { type: 'Polygon', coordinates: [ring] }
|
geometry: { type: 'Polygon', coordinates: [ring] }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
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), polygon[>=3] (zone), or line[>=2] (path)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize a compass/bearing value to a canonical numeric bearing in degrees
|
// Normalize a compass/bearing value to a canonical numeric bearing in degrees
|
||||||
@ -247,7 +260,7 @@ async function buildCustomLayers() {
|
|||||||
name: layer.id,
|
name: layer.id,
|
||||||
label: layer.name,
|
label: layer.name,
|
||||||
category: 'custom',
|
category: 'custom',
|
||||||
layerType: layer.layerType === 'zone' ? 'zone' : 'poi',
|
layerType: layer.layerType === 'path' ? 'path' : (layer.layerType === 'zone' ? 'zone' : 'poi'),
|
||||||
color: layer.color || '#e11d48',
|
color: layer.color || '#e11d48',
|
||||||
featureCount: features.length,
|
featureCount: features.length,
|
||||||
path: `/data/custom/${layer.id}.geojson`
|
path: `/data/custom/${layer.id}.geojson`
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
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';
|
import { search, type Searchable, type SearchResult } from '$lib/search';
|
||||||
|
import { geocode, type GeocodedPlace } from '$lib/geocode';
|
||||||
|
|
||||||
type FeatureCollection = {
|
type FeatureCollection = {
|
||||||
type: string;
|
type: string;
|
||||||
@ -13,7 +14,7 @@
|
|||||||
name: string; // id
|
name: string; // id
|
||||||
label: string; // display name
|
label: string; // display name
|
||||||
category: 'osm' | 'custom';
|
category: 'osm' | 'custom';
|
||||||
layerType: 'poi' | 'zone' | 'mixed';
|
layerType: 'poi' | 'zone' | 'path' | 'mixed';
|
||||||
color: string;
|
color: string;
|
||||||
featureCount: number;
|
featureCount: number;
|
||||||
path: string;
|
path: string;
|
||||||
@ -75,6 +76,47 @@
|
|||||||
focusOn(r.item);
|
focusOn(r.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- online geocoding (Nominatim) as a fallback section ---
|
||||||
|
let geoResults = $state<GeocodedPlace[]>([]);
|
||||||
|
let geoLoading = $state(false);
|
||||||
|
let geoDone = $state(false);
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
$effect(() => {
|
||||||
|
const q = query.trim();
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (q.length < 3) {
|
||||||
|
geoResults = [];
|
||||||
|
geoLoading = false;
|
||||||
|
geoDone = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
geoLoading = true;
|
||||||
|
geoDone = false;
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
geoResults = await geocode(q, { countrycodes: 'us' });
|
||||||
|
} catch {
|
||||||
|
geoResults = [];
|
||||||
|
}
|
||||||
|
geoLoading = false;
|
||||||
|
geoDone = true;
|
||||||
|
}, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
function onGeoSelect(g: GeocodedPlace) {
|
||||||
|
query = g.displayName;
|
||||||
|
showResults = false;
|
||||||
|
if (g.boundingbox) {
|
||||||
|
map.fitBounds([
|
||||||
|
[g.boundingbox[0], g.boundingbox[2]],
|
||||||
|
[g.boundingbox[1], g.boundingbox[3]]
|
||||||
|
] as L.LatLngBoundsExpression, { padding: [40, 40] });
|
||||||
|
} else {
|
||||||
|
map.setView([g.lat, g.lon], 14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggle(name: string) {
|
function toggle(name: string) {
|
||||||
const g = layerGroups[name];
|
const g = layerGroups[name];
|
||||||
if (!g) return;
|
if (!g) return;
|
||||||
@ -84,6 +126,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function styleFor(type: MapLayer['layerType'], color: string): L.PathOptions {
|
function styleFor(type: MapLayer['layerType'], color: string): L.PathOptions {
|
||||||
|
if (type === 'path') {
|
||||||
|
return { color, weight: 3, dashArray: '6 4', opacity: 0.9, fill: false };
|
||||||
|
}
|
||||||
if (type === 'zone') {
|
if (type === 'zone') {
|
||||||
return { color, weight: 2, fillColor: color, fillOpacity: 0.2 };
|
return { color, weight: 2, fillColor: color, fillOpacity: 0.2 };
|
||||||
}
|
}
|
||||||
@ -118,7 +163,7 @@
|
|||||||
let desc = '';
|
let desc = '';
|
||||||
if (props.description) desc = `<div class="popup-desc">${String(props.description)}</div>`;
|
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>`;
|
return `<strong>${name}</strong>${desc}<table class="popup">${extra}${rows}</table>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@ -142,7 +187,7 @@
|
|||||||
if (!res.ok) throw new Error(`fetch ${layer.path}: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`fetch ${layer.path}: HTTP ${res.status}`);
|
||||||
const fc: FeatureCollection = await res.json();
|
const fc: FeatureCollection = await res.json();
|
||||||
|
|
||||||
const isPoi = layer.layerType === 'poi' || (layer.category === 'custom' && layer.layerType !== 'zone');
|
const isPoi = layer.layerType === 'poi' || (layer.category === 'custom' && layer.layerType !== 'zone' && layer.layerType !== 'path');
|
||||||
const color = layer.color || '#e11d48';
|
const color = layer.color || '#e11d48';
|
||||||
|
|
||||||
const geo = L.geoJSON(fc as unknown as GeoJSON.GeoJsonObject, {
|
const geo = L.geoJSON(fc as unknown as GeoJSON.GeoJsonObject, {
|
||||||
@ -176,15 +221,17 @@
|
|||||||
let lat, lon, bounds;
|
let lat, lon, bounds;
|
||||||
if (f.geometry.type === 'Point') {
|
if (f.geometry.type === 'Point') {
|
||||||
[lon, lat] = f.geometry.coordinates as [number, number];
|
[lon, lat] = f.geometry.coordinates as [number, number];
|
||||||
} else if (f.geometry.type === 'Polygon') {
|
} else if (f.geometry.type === 'Polygon' || f.geometry.type === 'LineString') {
|
||||||
const ring = (f.geometry.coordinates as number[][][])[0];
|
const coords = (f.geometry.type === 'Polygon'
|
||||||
const lats = ring.map((c) => c[1]);
|
? (f.geometry.coordinates as number[][][])[0]
|
||||||
const lons = ring.map((c) => c[0]);
|
: f.geometry.coordinates as number[][]);
|
||||||
|
const lats = coords.map((c) => c[1]);
|
||||||
|
const lons = coords.map((c) => c[0]);
|
||||||
lat = (Math.min(...lats) + Math.max(...lats)) / 2;
|
lat = (Math.min(...lats) + Math.max(...lats)) / 2;
|
||||||
lon = (Math.min(...lons) + Math.max(...lons)) / 2;
|
lon = (Math.min(...lons) + Math.max(...lons)) / 2;
|
||||||
bounds = [[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]];
|
bounds = [[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]];
|
||||||
}
|
}
|
||||||
const kind = layer.layerType === 'zone' ? 'zone' : 'poi';
|
const kind = layer.layerType === 'zone' ? 'zone' : (layer.layerType === 'path' ? 'path' : 'poi');
|
||||||
searchables.push({
|
searchables.push({
|
||||||
id: f.id as string,
|
id: f.id as string,
|
||||||
name: (props.name as string) ?? String(f.id ?? ''),
|
name: (props.name as string) ?? String(f.id ?? ''),
|
||||||
@ -231,12 +278,12 @@
|
|||||||
<div class="search" role="search">
|
<div class="search" role="search">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search POIs & zones by name or address…"
|
placeholder="Search data or any location…"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
onfocus={() => (showResults = true)}
|
onfocus={() => (showResults = true)}
|
||||||
oninput={() => (showResults = true)}
|
oninput={() => (showResults = true)}
|
||||||
/>
|
/>
|
||||||
{#if results.length > 0 && showResults}
|
{#if (results.length > 0 || geoLoading || geoResults.length > 0) && showResults}
|
||||||
<ul class="results">
|
<ul class="results">
|
||||||
{#each results as r (r.item.id)}
|
{#each results as r (r.item.id)}
|
||||||
<li>
|
<li>
|
||||||
@ -248,6 +295,19 @@
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if geoLoading}
|
||||||
|
<li class="geoload">Searching the map for locations…</li>
|
||||||
|
{:else if geoResults.length > 0}
|
||||||
|
<li class="geosep">Places (online)</li>
|
||||||
|
{#each geoResults as g, i (g.displayName + i)}
|
||||||
|
<li>
|
||||||
|
<button type="button" onmouseenter={() => onGeoSelect(g)} onclick={() => onGeoSelect(g)}>
|
||||||
|
<span class="dot" style="background:#6366f1"></span>
|
||||||
|
<span class="rname gname">{g.displayName}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@ -304,6 +364,9 @@
|
|||||||
.rname { font-weight: 600; }
|
.rname { font-weight: 600; }
|
||||||
.rtype { text-transform: uppercase; font-size: 0.62rem; padding: 1px 5px; border-radius: 4px; background: #e2e8f0; color: #475569; }
|
.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; }
|
.raddr { color: #64748b; font-size: 0.7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.geosep { padding: 6px 8px 2px; font-size: 0.62rem; text-transform: uppercase; letter-spacing: .05em; color: #94a3b8; }
|
||||||
|
.geoload { padding: 8px; font-size: 0.75rem; color: #64748b; font-style: italic; }
|
||||||
|
.gname { font-weight: 500; font-size: 0.78rem; line-height: 1.3; }
|
||||||
|
|
||||||
.map { width: 100%; height: 100%; z-index: 0; }
|
.map { width: 100%; height: 100%; z-index: 0; }
|
||||||
.notice {
|
.notice {
|
||||||
|
|||||||
76
src/lib/geocode.ts
Normal file
76
src/lib/geocode.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* geocode.ts — search for arbitrary places via the Nominatim OSM geocoder.
|
||||||
|
* Union with search.ts so the user can look up places that aren't in the
|
||||||
|
* static data (cities, addresses, POIs anywhere in the world).
|
||||||
|
*
|
||||||
|
* Nominatim usage policy: https://operations.osmfoundation.org/policies/nominatim/
|
||||||
|
* A descriptive User-Agent / Referer must be set. This is a client-side call,
|
||||||
|
* so we set the EA (email) and accept it may be rate-limited. For production
|
||||||
|
* you can point this at a hosted fallback or add a small server-side proxy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface GeocodedPlace {
|
||||||
|
displayName: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
boundingbox?: [number, number, number, number]; // south, north, west, east
|
||||||
|
osmType: string;
|
||||||
|
osmId: number;
|
||||||
|
category: string;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOMINATIM_ENDPOINTS = [
|
||||||
|
'https://nominatim.openstreetmap.org/search',
|
||||||
|
'https://nominatim.openstreetmap.org/search'
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function geocode(
|
||||||
|
query: string,
|
||||||
|
opts: { limit?: number; countrycodes?: string } = {}
|
||||||
|
): Promise<GeocodedPlace[]> {
|
||||||
|
const limit = opts.limit ?? 5;
|
||||||
|
const url = new URL(NOMINATIM_ENDPOINTS[0]);
|
||||||
|
url.searchParams.set('q', query);
|
||||||
|
url.searchParams.set('format', 'jsonv2');
|
||||||
|
url.searchParams.set('limit', String(limit));
|
||||||
|
if (opts.countrycodes) url.searchParams.set('countrycodes', opts.countrycodes);
|
||||||
|
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (const endpoint of NOMINATIM_ENDPOINTS) {
|
||||||
|
try {
|
||||||
|
const u = new URL(endpoint);
|
||||||
|
u.search = url.search;
|
||||||
|
const res = await fetch(u.toString(), {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
// Identify the client; replace with your own contact if you use this at scale.
|
||||||
|
'User-Agent': 'NavigatorOSM/0.1 (OSS map webapp; contact: local dev)'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Nominatim HTTP ${res.status}`);
|
||||||
|
const data: unknown[] = await res.json();
|
||||||
|
return data
|
||||||
|
.filter((r) => r && typeof r === 'object' && 'lat' in r && 'lon' in r)
|
||||||
|
.map((r) => {
|
||||||
|
const rec = r as Record<string, unknown>;
|
||||||
|
const bb = rec.boundingbox as string[] | undefined;
|
||||||
|
return {
|
||||||
|
displayName: (rec.display_name as string) ?? '',
|
||||||
|
lat: parseFloat(rec.lat as string),
|
||||||
|
lon: parseFloat(rec.lon as string),
|
||||||
|
boundingbox: bb
|
||||||
|
? [parseFloat(bb[0]), parseFloat(bb[1]), parseFloat(bb[2]), parseFloat(bb[3])]
|
||||||
|
: undefined,
|
||||||
|
osmType: (rec.osm_type as string) ?? '',
|
||||||
|
osmId: Number(rec.osm_id ?? 0),
|
||||||
|
category: (rec.category as string) ?? '',
|
||||||
|
kind: (rec.type as string) ?? ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr ?? new Error('Nominatim unavailable');
|
||||||
|
}
|
||||||
@ -21,7 +21,7 @@ export interface Searchable {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
label: string; // human label in results
|
label: string; // human label in results
|
||||||
kind: 'poi' | 'zone';
|
kind: 'poi' | 'zone' | 'path';
|
||||||
layerName: string;
|
layerName: string;
|
||||||
layerLabel: string;
|
layerLabel: string;
|
||||||
layerColor: string;
|
layerColor: string;
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
36.3,
|
36.3,
|
||||||
-95.7
|
-95.7
|
||||||
],
|
],
|
||||||
"featureCount": 462,
|
"featureCount": 512,
|
||||||
"path": "/data/tulsa.geojson"
|
"path": "/data/tulsa.geojson"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -29,5 +29,23 @@
|
|||||||
"color": "#0ea5e9",
|
"color": "#0ea5e9",
|
||||||
"featureCount": 2,
|
"featureCount": 2,
|
||||||
"path": "/data/custom/districts.geojson"
|
"path": "/data/custom/districts.geojson"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trails",
|
||||||
|
"label": "Trails & Routes",
|
||||||
|
"category": "custom",
|
||||||
|
"layerType": "path",
|
||||||
|
"color": "#22c55e",
|
||||||
|
"featureCount": 1,
|
||||||
|
"path": "/data/custom/trails.geojson"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rogers-county",
|
||||||
|
"label": "Rogers County",
|
||||||
|
"category": "custom",
|
||||||
|
"layerType": "poi",
|
||||||
|
"color": "#f59e0b",
|
||||||
|
"featureCount": 7,
|
||||||
|
"path": "/data/custom/rogers-county.geojson"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
1
static/data/custom/rogers-county.geojson
Normal file
1
static/data/custom/rogers-county.geojson
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"type":"FeatureCollection","crs":{"type":"name","properties":{"name":"urn:ogc:def:crs:OGC:1.3:CRS84"}},"features":[{"type":"Feature","id":"custom/rogers-county/Claremore Museum of History","properties":{"name":"Claremore Museum of History","description":"Local museum","address":"121 N Weenonah Ave, Claremore, OK 74017"},"geometry":{"type":"Point","coordinates":[-95.6112936,36.3114856]}},{"type":"Feature","id":"custom/rogers-county/Rogers County Courthouse","properties":{"name":"Rogers County Courthouse","description":"County seat"},"geometry":{"type":"Point","coordinates":[-95.616414,36.3110143]}},{"type":"Feature","id":"custom/rogers-county/Claremore Indian Hospital","properties":{"name":"Claremore Indian Hospital","description":"Health center"},"geometry":{"type":"Point","coordinates":[-95.6292597,36.3162771]}},{"type":"Feature","id":"custom/rogers-county/Rogers State University","properties":{"name":"Rogers State University","description":"University","address":"1701 W Will Rogers Blvd, Claremore, OK 74017"},"geometry":{"type":"Point","coordinates":[-95.6361137,36.3186099]}},{"type":"Feature","id":"custom/rogers-county/Lake Claremore","properties":{"name":"Lake Claremore"},"geometry":{"type":"Point","coordinates":[-95.5649169,36.3414586]}},{"type":"Feature","id":"custom/rogers-county/Oologah","properties":{"name":"Oologah","description":"Town"},"geometry":{"type":"Point","coordinates":[-95.7083151,36.4470387]}},{"type":"Feature","id":"custom/rogers-county/Verdigris","properties":{"name":"Verdigris","description":"Town"},"geometry":{"type":"Point","coordinates":[-95.6910927,36.2348197]}}]}
|
||||||
1
static/data/custom/trails.geojson
Normal file
1
static/data/custom/trails.geojson
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"type":"FeatureCollection","crs":{"type":"name","properties":{"name":"urn:ogc:def:crs:OGC:1.3:CRS84"}},"features":[{"type":"Feature","id":"custom/trails/Claremore Lake Loop","properties":{"name":"Claremore Lake Loop","description":"Example walking route around the lake"},"geometry":{"type":"LineString","coordinates":[[-95.5649,36.3415],[-95.57,36.343],[-95.575,36.342],[-95.572,36.336],[-95.566,36.3365]]}}]}
|
||||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user