diff --git a/README.md b/README.md index dd088eb..4462b4c 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,12 @@ process that: 1. **Pulls** OSM data from the [Overpass API](https://overpass-api.de/) for configurable geographic areas and feature types. -2. **Converts** it to static **GeoJSON** files. -3. **Builds** a **SvelteKit** frontend (with the static adapter) into a plain - static site that renders the data on an interactive **Leaflet** map. +2. **Merges** hand-authored custom layers — **zones** (polygons) and **points of + interest** — from `scripts/custom-layers.mjs`. +3. **Converts** everything to static **GeoJSON** files. +4. **Builds** a **SvelteKit** frontend (with the static adapter) into a plain + static site that renders each data source as a toggleable layer on an + interactive **Leaflet** map. The final output in `build/` is a fully self-contained static site — no server runtime required. You can serve it with any static HTTP server (nginx, Apache, @@ -54,12 +57,15 @@ npm run check # Type + Svelte checks ``` static/data/ -├── _index.json # Manifest of all built areas (drives the UI dropdown) -└── .geojson # One FeatureCollection per configured area +├── _index.json # Manifest of all built layers (drives the UI) +├── .geojson # One FeatureCollection per Overpass area +└── custom/ + └── .geojson # One FeatureCollection per custom zone/POI layer ``` -The frontend loads `/data/_index.json`, then fetches the GeoJSON file(s) for the -selected area(s) and renders them on the map with popups. +The frontend loads `/data/_index.json`, then fetches each layer's GeoJSON and +renders it as a toggleable overlay with a legend (top-right). Every layer can be +shown/hidden independently. ## Configuring Areas & Queries @@ -83,6 +89,45 @@ 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) + +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. + +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 +- `features` — array of feature objects + +POI (point): + +```js +{ name: 'BOK Center', desc: 'Arena & events', lon: -95.9966, lat: 36.1498 } +``` + +Zone (polygon — the ring is closed for you): + +```js +{ + name: 'Downtown Tulsa', + desc: 'Rough downtown core', + polygon: [ + [-96.0000, 36.1300], + [-95.9900, 36.1300], + [-95.9900, 36.1600], + [-96.0000, 36.1600] + ] +} +``` + +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. + ## Deployment The `build/` directory is entirely static. Serve it directly: diff --git a/scripts/custom-layers.mjs b/scripts/custom-layers.mjs new file mode 100644 index 0000000..c2b13e1 --- /dev/null +++ b/scripts/custom-layers.mjs @@ -0,0 +1,92 @@ +/** + * 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. + * + * 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. + * + * 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 + * - features array of feature objects (see below) + * + * Feature format (by layerType): + * POI (point): + * { name: 'Label', desc: 'optional description', lon: -95.7, lat: 36.1 } + * + * Zone (polygon — list the corner points; it is closed automatically): + * { name: 'Label', desc: '...', polygon: [[lon,lat], [lon,lat], ...] } + * + * Coordinates are [longitude, latitude] (lon first, like GeoJSON). + */ + +export const CUSTOM_LAYERS = [ + // ------------------------------------------------------------------------- + // EXAMPLE — Points of Interest + // A few spots around downtown Tulsa. Replace / extend as you like. + // ------------------------------------------------------------------------- + { + id: 'my-places', + name: 'My Places', + layerType: 'poi', + color: '#e11d48', + features: [ + { name: 'BOK Center', desc: 'Arena & events', lon: -95.9966, lat: 36.1498 }, + { name: 'Philbrook Museum', desc: 'Art museum & gardens', lon: -95.9747, lat: 36.1258 }, + { name: 'Gathering Place', desc: 'Riverside park', lon: -95.9791, lat: 36.1098 }, + { name: 'Woodward Park', desc: 'Rose garden', lon: -95.9792, lat: 36.1334 } + ] + }, + + // ------------------------------------------------------------------------- + // EXAMPLE — Zones + // A few neighborhood / district boundaries. Add your own as polygons. + // ------------------------------------------------------------------------- + { + id: 'districts', + name: 'Districts', + layerType: 'zone', + color: '#0ea5e9', + features: [ + { + name: 'Downtown Tulsa', + desc: 'Rough downtown core', + polygon: [ + [-96.0000, 36.1300], + [-95.9900, 36.1300], + [-95.9900, 36.1600], + [-96.0000, 36.1600] + ] + }, + { + name: 'Cherry Street', + desc: 'Shopping & dining district', + polygon: [ + [-95.9850, 36.1200], + [-95.9750, 36.1200], + [-95.9750, 36.1280], + [-95.9850, 36.1280] + ] + } + ] + }, + + // ------------------------------------------------------------------------- + // TEMPLATE — copy this block to add a new layer + // ------------------------------------------------------------------------- + // { + // id: 'your-layer', + // name: 'Your Layer', + // layerType: 'poi', // or 'zone' + // color: '#22c55e', + // features: [ + // { name: 'Example point', desc: '...', 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]] } + // ] + // } +]; diff --git a/scripts/osm-data.mjs b/scripts/osm-data.mjs index 3223a32..7f73683 100644 --- a/scripts/osm-data.mjs +++ b/scripts/osm-data.mjs @@ -3,9 +3,10 @@ * OSM Data Build Script * --------------------- * Queries the OpenStreetMap Overpass API for one or more configurable areas, - * fetches the requested feature types, and converts the results into static - * GeoJSON files under static/data/. These are bundled into the SvelteKit - * build and served as plain static files by the frontend. + * fetches the requested feature types, AND merges any hand-authored custom + * zones / points of interest from custom-layers.mjs. Everything is converted + * into static GeoJSON files under static/data/ and bundled into the SvelteKit + * build as plain static files. * * Usage: * node scripts/osm-data.mjs # build all areas @@ -20,6 +21,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { CUSTOM_LAYERS } from './custom-layers.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); @@ -163,7 +165,68 @@ async function buildArea(name, cfg, signal) { const outPath = resolve(OUT_DIR, `${name}.geojson`); await writeFile(outPath, JSON.stringify(fc)); console.log(` wrote ${features.length} features -> ${outPath}`); - return { name, label: cfg.label, bbox: cfg.bbox, featureCount: features.length, path: `/data/${name}.geojson` }; + return { name, label: cfg.label, category: 'osm', bbox: cfg.bbox, featureCount: features.length, path: `/data/${name}.geojson` }; +} + + +// --------------------------------------------------------------------------- +// Custom layers: convert hand-authored zones / POIs to static GeoJSON. +// --------------------------------------------------------------------------- +function customFeatureToGeoJSON(f, layerId) { + if (f.lon !== undefined && f.lat !== undefined) { + // Point of interest + const props = { name: f.name }; + if (f.desc) props.description = f.desc; + return { + type: 'Feature', + id: `custom/${layerId}/${f.name}`, + properties: props, + geometry: { type: 'Point', coordinates: [f.lon, f.lat] } + }; + } + 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]); + if (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1]) { + ring.push([...ring[0]]); + } + const props = { name: f.name }; + if (f.desc) props.description = f.desc; + return { + type: 'Feature', + id: `custom/${layerId}/${f.name}`, + properties: props, + geometry: { type: 'Polygon', coordinates: [ring] } + }; + } + throw new Error(`Feature "${f.name}" in layer "${layerId}" is missing lon/lat (POI) or polygon[>=3] (zone)`); +} + +async function buildCustomLayers() { + const entries = []; + for (const layer of CUSTOM_LAYERS) { + if (!layer?.id || !layer?.name) { + console.warn(' [warn] skipping custom layer: missing id or name'); + continue; + } + const features = (layer.features || []).map((f) => customFeatureToGeoJSON(f, layer.id)); + const fc = { type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features }; + const dir = resolve(OUT_DIR, 'custom'); + await mkdir(dir, { recursive: true }); + const outPath = resolve(dir, `${layer.id}.geojson`); + await writeFile(outPath, JSON.stringify(fc)); + entries.push({ + name: layer.id, + label: layer.name, + category: 'custom', + layerType: layer.layerType === 'zone' ? 'zone' : 'poi', + color: layer.color || '#e11d48', + featureCount: features.length, + path: `/data/custom/${layer.id}.geojson` + }); + console.log(` custom layer "${layer.name}" -> ${features.length} features -> ${outPath}`); + } + return entries; } // --------------------------------------------------------------------------- @@ -192,6 +255,9 @@ async function main() { manifest.push(await buildArea(name, cfg, signal)); } + console.log('Merging custom zones / POIs...'); + manifest.push(...await buildCustomLayers()); + await writeFile(resolve(OUT_DIR, '_index.json'), JSON.stringify(manifest, null, 2)); console.log(`\nWrote manifest -> ${resolve(OUT_DIR, '_index.json')}`); console.log('Done.'); diff --git a/src/lib/components/MapView.svelte b/src/lib/components/MapView.svelte index e1ee4db..3587f7a 100644 --- a/src/lib/components/MapView.svelte +++ b/src/lib/components/MapView.svelte @@ -3,21 +3,24 @@ import { onMount } from 'svelte'; import type { Map as LeafletMap } from 'leaflet'; - type Feature = { - type: string; - id: string; - properties: Record; - geometry: { type: string; coordinates: unknown }; - }; - type FeatureCollection = { type: string; - features: Feature[]; + features: GeoJSON.Feature[]; }; + export interface MapLayer { + name: string; // id + label: string; // display name + category: 'osm' | 'custom'; + layerType: 'poi' | 'zone' | 'mixed'; + color: string; + featureCount: number; + path: string; + } + interface Props { - /** URL(s) to one or more static GeoJSON files to load. */ - dataUrls: string[]; + /** Layer definitions to load and render (from the data manifest). */ + layers: MapLayer[]; /** Center [lat, lon]. */ center?: [number, number]; /** Initial zoom. */ @@ -27,17 +30,45 @@ } let { - dataUrls = [], + layers = [], center = [36.1, -95.9], - zoom = 12, + zoom = 11, title = 'Navigator' }: Props = $props(); let container: HTMLDivElement; let map: LeafletMap = $state() as LeafletMap; - let markers = $state(0); let loading = $state(true); let error = $state(null); + const layerGroups: Record = {}; + // Which layers are currently displayed on the map. + let visible = $state>({}); + let totalFeatures = $state(0); + + function styleFor(type: MapLayer['layerType'], color: string): L.PathOptions { + if (type === 'zone') { + return { color, weight: 2, fillColor: color, fillOpacity: 0.2 }; + } + return { color, weight: 2, fillColor: color, fillOpacity: 0.25 }; + } + + function toggle(name: string) { + const g = layerGroups[name]; + if (!g) return; + visible[name] = !visible[name]; + if (visible[name]) g.addTo(map); + else map.removeLayer(g); + } + + function popupHtml(f: GeoJSON.Feature): string { + const props = (f.properties ?? {}) as Record; + const name = (props.name as string | undefined) ?? String(f.id ?? ''); + const rows = Object.entries(props) + .filter(([k]) => !k.startsWith('_')) + .map(([k, v]) => `${k}${String(v)}`) + .join(''); + return `\n\t\t\t${name}\n\t\t\t${rows}`; + } onMount(async () => { let L; @@ -54,36 +85,50 @@ attribution: '© OpenStreetMap contributors' }).addTo(map); - const allLayers: L.Layer[] = []; try { - for (const url of dataUrls) { - const res = await fetch(url); - if (!res.ok) throw new Error(`fetch ${url}: HTTP ${res.status}`); + for (const layer of layers) { + const res = await fetch(layer.path); + if (!res.ok) throw new Error(`fetch ${layer.path}: HTTP ${res.status}`); const fc: FeatureCollection = await res.json(); - const layer = L.geoJSON(fc as unknown as GeoJSON.GeoJsonObject, { - pointToLayer: (_f, latlng) => L.circleMarker(latlng, { - radius: 6, - fillColor: '#e11d48', - color: '#fff', - weight: 1, - opacity: 1, - fillOpacity: 0.8 - }), - style: { - color: '#0284c7', - weight: 2, - fillColor: '#38bdf8', - fillOpacity: 0.25 + + const isPoi = layer.layerType === 'poi' || (layer.category === 'custom' && layer.layerType !== 'zone'); + const color = layer.color || '#e11d48'; + + const geo = L.geoJSON(fc as unknown as GeoJSON.GeoJsonObject, { + pointToLayer: (_f, latlng) => { + if (isPoi) { + return L.circleMarker(latlng, { + radius: 6, + fillColor: color, + color: '#fff', + weight: 1, + opacity: 1, + fillOpacity: 0.9 + }); + } + return L.circleMarker(latlng, { + radius: 4, + fillColor: color, + color: '#fff', + weight: 1, + fillOpacity: 0.85 + }); }, + style: styleFor(layer.layerType, color), onEachFeature: (f, lay) => { - markers++; + totalFeatures++; lay.bindPopup(popupHtml(f)); } - }).addTo(map); - allLayers.push(layer); + }); + + layerGroups[layer.name] = geo; + visible[layer.name] = true; + geo.addTo(map); } - if (allLayers.length) { - const group = L.featureGroup(allLayers); + + const all: L.Layer[] = Object.values(layerGroups); + if (all.length) { + const group = L.featureGroup(all); map.fitBounds(group.getBounds(), { padding: [40, 40] }); } } catch (e) { @@ -91,18 +136,6 @@ } loading = false; }); - - function popupHtml(f: GeoJSON.Feature): string { - const props = (f.properties ?? {}) as Record; - const name = (props.name as string | undefined) ?? String(f.id ?? ''); - const rows = Object.entries(props) - .filter(([k]) => !k.startsWith('_')) - .map(([k, v]) => `${k}${String(v)}`) - .join(''); - return ` - ${name} - ${rows}`; - } @@ -112,22 +145,28 @@

{title}

- - {#if markers > 0} - {markers} features loaded - {:else}OSM data - {/if} - + {totalFeatures} features loaded
{#if loading}
Loading map & data…
{/if} {#if error}
Error: {error}
{/if} +
+ {#each layers as layer (layer.name)} + + {/each} +
+
\ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c9f8b02..d9e40f0 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,43 +1,24 @@ @@ -47,30 +28,27 @@
- - {#if areas.length} - {totalFeatures} total features across {areas.length} area(s) + ({areas.length}) data sources + {#if totalFeatures > 0} + {totalFeatures} features {/if} + Toggle layers on the map (top-right).
{#if manifestError}
Could not load data manifest (/data/_index.json).
- Run npm run build:data to fetch OSM data, then rebuild. + Run npm run build:data to fetch OSM data and merge custom + layers, then rebuild.

{manifestError}

- {:else if dataUrls.length} - + {:else if areas.length} + {:else if !loading} -
No built data areas found.
+
+ No built data found. Run npm run build:data to fetch OSM + data and/or add zones & POIs in scripts/custom-layers.mjs. +
{/if}
@@ -82,12 +60,9 @@ background: #1e293b; color: #e2e8f0; font: 0.85rem system-ui, sans-serif; z-index: 500; } - .controls label { display: flex; align-items: center; gap: 0.5rem; } - select { - background: #0f172a; color: #fff; border: 1px solid #475569; - border-radius: 6px; padding: 4px 8px; font-size: 0.85rem; - } - .stats { color: #94a3b8; margin-left: auto; } + .controls strong { color: #fff; } + .stats { color: #94a3b8; } + .hint { color: #64748b; margin-left: auto; } .error { padding: 3rem 2rem; font: 0.95rem/1.5 system-ui, sans-serif; color: #b91c1c; } diff --git a/static/data/_index.json b/static/data/_index.json index db11ad4..5591923 100644 --- a/static/data/_index.json +++ b/static/data/_index.json @@ -2,6 +2,7 @@ { "name": "tulsa", "label": "Tulsa, Oklahoma", + "category": "osm", "bbox": [ 35.9, -96.1, @@ -10,5 +11,23 @@ ], "featureCount": 512, "path": "/data/tulsa.geojson" + }, + { + "name": "my-places", + "label": "My Places", + "category": "custom", + "layerType": "poi", + "color": "#e11d48", + "featureCount": 4, + "path": "/data/custom/my-places.geojson" + }, + { + "name": "districts", + "label": "Districts", + "category": "custom", + "layerType": "zone", + "color": "#0ea5e9", + "featureCount": 2, + "path": "/data/custom/districts.geojson" } ] \ No newline at end of file diff --git a/static/data/custom/districts.geojson b/static/data/custom/districts.geojson new file mode 100644 index 0000000..f65ec83 --- /dev/null +++ b/static/data/custom/districts.geojson @@ -0,0 +1 @@ +{"type":"FeatureCollection","crs":{"type":"name","properties":{"name":"urn:ogc:def:crs:OGC:1.3:CRS84"}},"features":[{"type":"Feature","id":"custom/districts/Downtown Tulsa","properties":{"name":"Downtown Tulsa","description":"Rough downtown core"},"geometry":{"type":"Polygon","coordinates":[[[-96,36.13],[-95.99,36.13],[-95.99,36.16],[-96,36.16],[-96,36.13]]]}},{"type":"Feature","id":"custom/districts/Cherry Street","properties":{"name":"Cherry Street","description":"Shopping & dining district"},"geometry":{"type":"Polygon","coordinates":[[[-95.985,36.12],[-95.975,36.12],[-95.975,36.128],[-95.985,36.128],[-95.985,36.12]]]}}]} \ No newline at end of file diff --git a/static/data/custom/my-places.geojson b/static/data/custom/my-places.geojson new file mode 100644 index 0000000..b1d6f54 --- /dev/null +++ b/static/data/custom/my-places.geojson @@ -0,0 +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]}}]} \ No newline at end of file