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:
hermes-explorigin 2026-08-08 18:26:48 +00:00
parent 63f1b75456
commit 4e3fef6559
10 changed files with 312 additions and 32 deletions

View File

@ -89,18 +89,19 @@ parks: `
List configured queries with `npm run data -- --queries`, or build a single area
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
**`scripts/custom-layers.mjs`**. This file ships with a couple of example layers
(`my-places` POIs and `districts` zones) that you can extend or replace.
**`scripts/custom-layers.mjs`**. It ships with several example layers
(`my-places` POIs, `districts` zones, `trails` path, and `rogers-county` local
POIs) that you can extend or replace.
Each layer has:
- `id` — unique slug (becomes the output filename)
- `name` — label shown in the UI legend
- `layerType``'zone'` (polygon) or `'poi'` (point)
- `color` — hex color used for markers/fill
- `layerType``'poi'` (point), `'zone'` (polygon), or `'path'` (LineString)
- `color` — hex color used for markers/fill/lines
- `features` — array of feature objects
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
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 webapp includes a **fuzzy search** in the top bar. It matches your POIs,
zones, and paths by **name or address** — and, as a fallback, queries the
[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:
- Normalizes text (case, punctuation, and common address words — `st``street`,

View File

@ -1,9 +1,10 @@
/**
* Custom Layers Configuration
* ===========================
* Define your own map layers here **zones** (polygons/areas) and
* **points of interest** (POIs). These are bundled into the static build
* alongside the Overpass OSM data.
* Define your own map layers here **zones** (polygons/areas),
* **points of interest** (POIs), and **paths/routes** (LineStrings like GPX
* 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
* to the `features` array of an existing layer, or add a whole new layer.
@ -11,8 +12,8 @@
* Each layer needs:
* - id unique slug (used for the output filename)
* - name human-readable name shown in the UI
* - layerType 'zone' or 'poi'
* - color hex color used for the marker/fill
* - layerType 'poi' | 'zone' | 'path'
* - color hex color used for the marker/fill/line
* - features array of feature objects (see below)
*
* Feature format (by layerType):
@ -22,8 +23,12 @@
* Zone (polygon list the corner points; it is closed automatically):
* { 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).
* 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:
* - 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
// -------------------------------------------------------------------------
// {
// id: 'your-layer',
// name: 'Your Layer',
// layerType: 'poi', // or 'zone'
// layerType: 'poi', // or 'zone' | 'path'
// color: '#22c55e',
// features: [
// { 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]] },
// { name: 'Example path', desc: '...', line: [[-95.7, 36.1], [-95.6, 36.1], [-95.6, 36.2]] }
// ]
// }
];

View File

@ -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) {
if (f.lon !== undefined && f.lat !== undefined) {
@ -187,6 +187,19 @@ function customFeatureToGeoJSON(f, layerId) {
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) {
// Polygon / zone: close the ring automatically if not closed.
const ring = f.polygon.map(([lon, lat]) => [lon, lat]);
@ -203,7 +216,7 @@ function customFeatureToGeoJSON(f, layerId) {
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
@ -247,7 +260,7 @@ async function buildCustomLayers() {
name: layer.id,
label: layer.name,
category: 'custom',
layerType: layer.layerType === 'zone' ? 'zone' : 'poi',
layerType: layer.layerType === 'path' ? 'path' : (layer.layerType === 'zone' ? 'zone' : 'poi'),
color: layer.color || '#e11d48',
featureCount: features.length,
path: `/data/custom/${layer.id}.geojson`

View File

@ -3,6 +3,7 @@
import { onMount } from 'svelte';
import type { Map as LeafletMap } from 'leaflet';
import { search, type Searchable, type SearchResult } from '$lib/search';
import { geocode, type GeocodedPlace } from '$lib/geocode';
type FeatureCollection = {
type: string;
@ -13,7 +14,7 @@
name: string; // id
label: string; // display name
category: 'osm' | 'custom';
layerType: 'poi' | 'zone' | 'mixed';
layerType: 'poi' | 'zone' | 'path' | 'mixed';
color: string;
featureCount: number;
path: string;
@ -75,6 +76,47 @@
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) {
const g = layerGroups[name];
if (!g) return;
@ -84,6 +126,9 @@
}
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') {
return { color, weight: 2, fillColor: color, fillOpacity: 0.2 };
}
@ -118,7 +163,7 @@
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>`;
return `<strong>${name}</strong>${desc}<table class="popup">${extra}${rows}</table>`;
}
onMount(async () => {
@ -142,7 +187,7 @@
if (!res.ok) throw new Error(`fetch ${layer.path}: HTTP ${res.status}`);
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 geo = L.geoJSON(fc as unknown as GeoJSON.GeoJsonObject, {
@ -176,15 +221,17 @@
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]);
} else if (f.geometry.type === 'Polygon' || f.geometry.type === 'LineString') {
const coords = (f.geometry.type === 'Polygon'
? (f.geometry.coordinates as number[][][])[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;
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';
const kind = layer.layerType === 'zone' ? 'zone' : (layer.layerType === 'path' ? 'path' : 'poi');
searchables.push({
id: f.id as string,
name: (props.name as string) ?? String(f.id ?? ''),
@ -231,12 +278,12 @@
<div class="search" role="search">
<input
type="search"
placeholder="Search POIs &amp; zones by name or address…"
placeholder="Search data or any location…"
bind:value={query}
onfocus={() => (showResults = true)}
oninput={() => (showResults = true)}
/>
{#if results.length > 0 && showResults}
{#if (results.length > 0 || geoLoading || geoResults.length > 0) && showResults}
<ul class="results">
{#each results as r (r.item.id)}
<li>
@ -248,6 +295,19 @@
</button>
</li>
{/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>
{/if}
</div>
@ -304,6 +364,9 @@
.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; }
.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; }
.notice {

76
src/lib/geocode.ts Normal file
View 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');
}

View File

@ -21,7 +21,7 @@ export interface Searchable {
id: string;
name: string;
label: string; // human label in results
kind: 'poi' | 'zone';
kind: 'poi' | 'zone' | 'path';
layerName: string;
layerLabel: string;
layerColor: string;

View File

@ -9,7 +9,7 @@
36.3,
-95.7
],
"featureCount": 462,
"featureCount": 512,
"path": "/data/tulsa.geojson"
},
{
@ -29,5 +29,23 @@
"color": "#0ea5e9",
"featureCount": 2,
"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"
}
]

View 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]}}]}

View 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