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:
hermes-explorigin 2026-08-08 16:09:39 +00:00
parent 3413b7053f
commit 347e9b2a0a
8 changed files with 361 additions and 110 deletions

View File

@ -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
View 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]] }
// ]
// }
];

View File

@ -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.');

View File

@ -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: '&copy; OpenStreetMap contributors' attribution: '&copy; 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');
radius: 6, const color = layer.color || '#e11d48';
fillColor: '#e11d48',
color: '#fff', const geo = L.geoJSON(fc as unknown as GeoJSON.GeoJsonObject, {
weight: 1, pointToLayer: (_f, latlng) => {
opacity: 1, if (isPoi) {
fillOpacity: 0.8 return L.circleMarker(latlng, {
}), radius: 6,
style: { fillColor: color,
color: '#0284c7', color: '#fff',
weight: 2, weight: 1,
fillColor: '#38bdf8', opacity: 1,
fillOpacity: 0.25 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) => { 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 &amp; data…</div>{/if} {#if loading}<div class="notice">Loading map &amp; 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>

View File

@ -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 &amp; 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;
} }

View File

@ -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"
} }
] ]

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

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