Add custom zones & POI layers with user-editable config and example data
- scripts/custom-layers.mjs: hand-authored, self-documenting config for custom zones (polygons) and points of interest, shipped with example Tulsa POIs and district zones plus a copy-paste template - osm-data.mjs: merges custom layers into static/data/custom/<id>.geojson and includes them in the _index.json manifest with category, layerType, and color metadata - MapView.svelte: renders each manifest entry as an independent toggleable Leaflet layer with per-layer styling and an on-map legend panel - +page.svelte: loads all data sources (OSM + custom) from the manifest
This commit is contained in:
parent
3413b7053f
commit
347e9b2a0a
59
README.md
59
README.md
@ -5,9 +5,12 @@ process that:
|
|||||||
|
|
||||||
1. **Pulls** OSM data from the [Overpass API](https://overpass-api.de/) for
|
1. **Pulls** OSM data from the [Overpass API](https://overpass-api.de/) for
|
||||||
configurable geographic areas and feature types.
|
configurable geographic areas and feature types.
|
||||||
2. **Converts** it to static **GeoJSON** files.
|
2. **Merges** hand-authored custom layers — **zones** (polygons) and **points of
|
||||||
3. **Builds** a **SvelteKit** frontend (with the static adapter) into a plain
|
interest** — from `scripts/custom-layers.mjs`.
|
||||||
static site that renders the data on an interactive **Leaflet** map.
|
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
|
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,
|
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/
|
static/data/
|
||||||
├── _index.json # Manifest of all built areas (drives the UI dropdown)
|
├── _index.json # Manifest of all built layers (drives the UI)
|
||||||
└── <area>.geojson # One FeatureCollection per configured area
|
├── <area>.geojson # One FeatureCollection per Overpass area
|
||||||
|
└── custom/
|
||||||
|
└── <layer>.geojson # One FeatureCollection per custom zone/POI layer
|
||||||
```
|
```
|
||||||
|
|
||||||
The frontend loads `/data/_index.json`, then fetches the GeoJSON file(s) for the
|
The frontend loads `/data/_index.json`, then fetches each layer's GeoJSON and
|
||||||
selected area(s) and renders them on the map with popups.
|
renders it as a toggleable overlay with a legend (top-right). Every layer can be
|
||||||
|
shown/hidden independently.
|
||||||
|
|
||||||
## Configuring Areas & Queries
|
## Configuring Areas & Queries
|
||||||
|
|
||||||
@ -83,6 +89,45 @@ 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)
|
||||||
|
|
||||||
|
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
|
## Deployment
|
||||||
|
|
||||||
The `build/` directory is entirely static. Serve it directly:
|
The `build/` directory is entirely static. Serve it directly:
|
||||||
|
|||||||
92
scripts/custom-layers.mjs
Normal file
92
scripts/custom-layers.mjs
Normal file
@ -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]] }
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
];
|
||||||
@ -3,9 +3,10 @@
|
|||||||
* OSM Data Build Script
|
* OSM Data Build Script
|
||||||
* ---------------------
|
* ---------------------
|
||||||
* Queries the OpenStreetMap Overpass API for one or more configurable areas,
|
* Queries the OpenStreetMap Overpass API for one or more configurable areas,
|
||||||
* fetches the requested feature types, and converts the results into static
|
* fetches the requested feature types, AND merges any hand-authored custom
|
||||||
* GeoJSON files under static/data/. These are bundled into the SvelteKit
|
* zones / points of interest from custom-layers.mjs. Everything is converted
|
||||||
* build and served as plain static files by the frontend.
|
* into static GeoJSON files under static/data/ and bundled into the SvelteKit
|
||||||
|
* build as plain static files.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* node scripts/osm-data.mjs # build all areas
|
* node scripts/osm-data.mjs # build all areas
|
||||||
@ -20,6 +21,7 @@
|
|||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { CUSTOM_LAYERS } from './custom-layers.mjs';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = resolve(__dirname, '..');
|
const ROOT = resolve(__dirname, '..');
|
||||||
@ -163,7 +165,68 @@ async function buildArea(name, cfg, signal) {
|
|||||||
const outPath = resolve(OUT_DIR, `${name}.geojson`);
|
const outPath = resolve(OUT_DIR, `${name}.geojson`);
|
||||||
await writeFile(outPath, JSON.stringify(fc));
|
await writeFile(outPath, JSON.stringify(fc));
|
||||||
console.log(` wrote ${features.length} features -> ${outPath}`);
|
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));
|
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));
|
await writeFile(resolve(OUT_DIR, '_index.json'), JSON.stringify(manifest, null, 2));
|
||||||
console.log(`\nWrote manifest -> ${resolve(OUT_DIR, '_index.json')}`);
|
console.log(`\nWrote manifest -> ${resolve(OUT_DIR, '_index.json')}`);
|
||||||
console.log('Done.');
|
console.log('Done.');
|
||||||
|
|||||||
@ -3,21 +3,24 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { Map as LeafletMap } from 'leaflet';
|
import type { Map as LeafletMap } from 'leaflet';
|
||||||
|
|
||||||
type Feature = {
|
|
||||||
type: string;
|
|
||||||
id: string;
|
|
||||||
properties: Record<string, unknown>;
|
|
||||||
geometry: { type: string; coordinates: unknown };
|
|
||||||
};
|
|
||||||
|
|
||||||
type FeatureCollection = {
|
type FeatureCollection = {
|
||||||
type: string;
|
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 {
|
interface Props {
|
||||||
/** URL(s) to one or more static GeoJSON files to load. */
|
/** Layer definitions to load and render (from the data manifest). */
|
||||||
dataUrls: string[];
|
layers: MapLayer[];
|
||||||
/** Center [lat, lon]. */
|
/** Center [lat, lon]. */
|
||||||
center?: [number, number];
|
center?: [number, number];
|
||||||
/** Initial zoom. */
|
/** Initial zoom. */
|
||||||
@ -27,17 +30,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
dataUrls = [],
|
layers = [],
|
||||||
center = [36.1, -95.9],
|
center = [36.1, -95.9],
|
||||||
zoom = 12,
|
zoom = 11,
|
||||||
title = 'Navigator'
|
title = 'Navigator'
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let map: LeafletMap = $state() as LeafletMap;
|
let map: LeafletMap = $state() as LeafletMap;
|
||||||
let markers = $state(0);
|
|
||||||
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> = {};
|
||||||
|
// Which layers are currently displayed on the map.
|
||||||
|
let visible = $state<Record<string, boolean>>({});
|
||||||
|
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<string, unknown>;
|
||||||
|
const name = (props.name as string | undefined) ?? String(f.id ?? '');
|
||||||
|
const rows = Object.entries(props)
|
||||||
|
.filter(([k]) => !k.startsWith('_'))
|
||||||
|
.map(([k, v]) => `<tr><th>${k}</th><td>${String(v)}</td></tr>`)
|
||||||
|
.join('');
|
||||||
|
return `\n\t\t\t<strong>${name}</strong>\n\t\t\t<table class="popup">${rows}</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
let L;
|
let L;
|
||||||
@ -54,36 +85,50 @@
|
|||||||
attribution: '© OpenStreetMap contributors'
|
attribution: '© OpenStreetMap contributors'
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
const allLayers: L.Layer[] = [];
|
|
||||||
try {
|
try {
|
||||||
for (const url of dataUrls) {
|
for (const layer of layers) {
|
||||||
const res = await fetch(url);
|
const res = await fetch(layer.path);
|
||||||
if (!res.ok) throw new Error(`fetch ${url}: 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 layer = L.geoJSON(fc as unknown as GeoJSON.GeoJsonObject, {
|
|
||||||
pointToLayer: (_f, latlng) => L.circleMarker(latlng, {
|
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,
|
radius: 6,
|
||||||
fillColor: '#e11d48',
|
fillColor: color,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
weight: 1,
|
weight: 1,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
fillOpacity: 0.8
|
fillOpacity: 0.9
|
||||||
}),
|
});
|
||||||
style: {
|
}
|
||||||
color: '#0284c7',
|
return L.circleMarker(latlng, {
|
||||||
weight: 2,
|
radius: 4,
|
||||||
fillColor: '#38bdf8',
|
fillColor: color,
|
||||||
fillOpacity: 0.25
|
color: '#fff',
|
||||||
|
weight: 1,
|
||||||
|
fillOpacity: 0.85
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
style: styleFor(layer.layerType, color),
|
||||||
onEachFeature: (f, lay) => {
|
onEachFeature: (f, lay) => {
|
||||||
markers++;
|
totalFeatures++;
|
||||||
lay.bindPopup(popupHtml(f));
|
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] });
|
map.fitBounds(group.getBounds(), { padding: [40, 40] });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -91,18 +136,6 @@
|
|||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
function popupHtml(f: GeoJSON.Feature): string {
|
|
||||||
const props = (f.properties ?? {}) as Record<string, unknown>;
|
|
||||||
const name = (props.name as string | undefined) ?? String(f.id ?? '');
|
|
||||||
const rows = Object.entries(props)
|
|
||||||
.filter(([k]) => !k.startsWith('_'))
|
|
||||||
.map(([k, v]) => `<tr><th>${k}</th><td>${String(v)}</td></tr>`)
|
|
||||||
.join('');
|
|
||||||
return `
|
|
||||||
<strong>${name}</strong>
|
|
||||||
<table class="popup">${rows}</table>`;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@ -112,22 +145,28 @@
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<span class="meta">
|
<span class="meta">{totalFeatures} features loaded</span>
|
||||||
{#if markers > 0}
|
|
||||||
{markers} features loaded
|
|
||||||
{:else}OSM data
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if loading}<div class="notice">Loading map & data…</div>{/if}
|
{#if loading}<div class="notice">Loading map & data…</div>{/if}
|
||||||
{#if error}<div class="notice error">Error: {error}</div>{/if}
|
{#if error}<div class="notice error">Error: {error}</div>{/if}
|
||||||
|
|
||||||
|
<div class="legend" aria-label="Layer controls">
|
||||||
|
{#each layers as layer (layer.name)}
|
||||||
|
<label class="row">
|
||||||
|
<input type="checkbox" checked={visible[layer.name]} onchange={() => toggle(layer.name)} />
|
||||||
|
<span class="swatch" style="background:{layer.color}"></span>
|
||||||
|
<span class="lbl">{layer.label}</span>
|
||||||
|
<span class="count">{layer.featureCount}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="map" bind:this={container}></div>
|
<div class="map" bind:this={container}></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.wrap { display: grid; grid-template-rows: auto 1fr; height: 100vh; }
|
.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; justify-content: space-between;
|
||||||
padding: 0 1.25rem; height: 56px;
|
padding: 0 1.25rem; height: 56px;
|
||||||
@ -143,7 +182,20 @@
|
|||||||
box-shadow: 0 2px 8px rgba(0,0,0,.3);
|
box-shadow: 0 2px 8px rgba(0,0,0,.3);
|
||||||
}
|
}
|
||||||
.notice.error { background: #b91c1c; }
|
.notice.error { background: #b91c1c; }
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
position: absolute; top: 66px; right: 12px; z-index: 1000;
|
||||||
|
background: #fff; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,.25);
|
||||||
|
padding: 10px; min-width: 170px;
|
||||||
|
font: 0.8rem system-ui, sans-serif; color: #0f172a;
|
||||||
|
}
|
||||||
|
.row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; }
|
||||||
|
.row input { accent-color: #0f172a; margin: 0; }
|
||||||
|
.swatch { display: inline-block; width: 12px; height: 12px; border-radius: 3px; border: 1px solid #cbd5e1; }
|
||||||
|
.lbl { flex: 1; }
|
||||||
|
.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; }
|
||||||
:global(.popup td) { padding: 1px 0; }
|
:global(.popup td) { padding: 1px 0; }
|
||||||
|
:global(.leaflet-container) { font: inherit; }
|
||||||
</style>
|
</style>
|
||||||
@ -1,43 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import MapView from '$lib/components/MapView.svelte';
|
import MapView, { type MapLayer } from '$lib/components/MapView.svelte';
|
||||||
|
|
||||||
type AreaManifest = {
|
type AreaManifest = MapLayer;
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
bbox: number[];
|
|
||||||
featureCount: number;
|
|
||||||
path: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let areas = $state<AreaManifest[]>([]);
|
let areas = $state<MapLayer[]>([]);
|
||||||
let selected = $state('all');
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let dataUrls = $state<string[]>([]);
|
|
||||||
let manifestError = $state<string | null>(null);
|
let manifestError = $state<string | null>(null);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/data/_index.json');
|
const res = await fetch('/data/_index.json');
|
||||||
if (!res.ok) throw new Error(`manifest HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`manifest HTTP ${res.status}`);
|
||||||
areas = await res.json();
|
areas = (await res.json()) as MapLayer[];
|
||||||
// Default view: all areas on the map.
|
|
||||||
dataUrls = areas.map((a) => a.path);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
manifestError = (e as Error).message;
|
manifestError = (e as Error).message;
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
function onSelect() {
|
|
||||||
if (selected === 'all') {
|
|
||||||
dataUrls = areas.map((a) => a.path);
|
|
||||||
} else {
|
|
||||||
const found = areas.find((a) => a.name === selected);
|
|
||||||
dataUrls = found ? [found.path] : [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalFeatures = $derived(areas.reduce((n, a) => n + a.featureCount, 0));
|
const totalFeatures = $derived(areas.reduce((n, a) => n + a.featureCount, 0));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -47,30 +28,27 @@
|
|||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<label>
|
<strong>({areas.length}) data sources</strong>
|
||||||
Area
|
{#if totalFeatures > 0}
|
||||||
<select bind:value={selected} onchange={() => onSelect()}>
|
<span class="stats">{totalFeatures} features</span>
|
||||||
<option value="all">All areas</option>
|
|
||||||
{#each areas as a (a.name)}
|
|
||||||
<option value={a.name}>{a.label}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{#if areas.length}
|
|
||||||
<span class="stats">{totalFeatures} total features across {areas.length} area(s)</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<span class="hint">Toggle layers on the map (top-right).</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if manifestError}
|
{#if manifestError}
|
||||||
<div class="error">
|
<div class="error">
|
||||||
Could not load data manifest (<code>/data/_index.json</code>).<br />
|
Could not load data manifest (<code>/data/_index.json</code>).<br />
|
||||||
Run <code>npm run build:data</code> to fetch OSM data, then rebuild.
|
Run <code>npm run build:data</code> to fetch OSM data and merge custom
|
||||||
|
layers, then rebuild.
|
||||||
<p class="detail">{manifestError}</p>
|
<p class="detail">{manifestError}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if dataUrls.length}
|
{:else if areas.length}
|
||||||
<MapView {dataUrls} title="Navigator" />
|
<MapView layers={areas} title="Navigator" />
|
||||||
{:else if !loading}
|
{:else if !loading}
|
||||||
<div class="error">No built data areas found.</div>
|
<div class="error">
|
||||||
|
No built data found. Run <code>npm run build:data</code> to fetch OSM
|
||||||
|
data and/or add zones & POIs in <code>scripts/custom-layers.mjs</code>.
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -82,12 +60,9 @@
|
|||||||
background: #1e293b; color: #e2e8f0;
|
background: #1e293b; color: #e2e8f0;
|
||||||
font: 0.85rem system-ui, sans-serif; z-index: 500;
|
font: 0.85rem system-ui, sans-serif; z-index: 500;
|
||||||
}
|
}
|
||||||
.controls label { display: flex; align-items: center; gap: 0.5rem; }
|
.controls strong { color: #fff; }
|
||||||
select {
|
.stats { color: #94a3b8; }
|
||||||
background: #0f172a; color: #fff; border: 1px solid #475569;
|
.hint { color: #64748b; margin-left: auto; }
|
||||||
border-radius: 6px; padding: 4px 8px; font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.stats { color: #94a3b8; margin-left: auto; }
|
|
||||||
.error {
|
.error {
|
||||||
padding: 3rem 2rem; font: 0.95rem/1.5 system-ui, sans-serif; color: #b91c1c;
|
padding: 3rem 2rem; font: 0.95rem/1.5 system-ui, sans-serif; color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tulsa",
|
"name": "tulsa",
|
||||||
"label": "Tulsa, Oklahoma",
|
"label": "Tulsa, Oklahoma",
|
||||||
|
"category": "osm",
|
||||||
"bbox": [
|
"bbox": [
|
||||||
35.9,
|
35.9,
|
||||||
-96.1,
|
-96.1,
|
||||||
@ -10,5 +11,23 @@
|
|||||||
],
|
],
|
||||||
"featureCount": 512,
|
"featureCount": 512,
|
||||||
"path": "/data/tulsa.geojson"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
1
static/data/custom/districts.geojson
Normal file
1
static/data/custom/districts.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/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]]]}}]}
|
||||||
1
static/data/custom/my-places.geojson
Normal file
1
static/data/custom/my-places.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/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]}}]}
|
||||||
Loading…
x
Reference in New Issue
Block a user