All checks were successful
CI / test-and-build (push) Successful in 41s
514 lines
18 KiB
Svelte
514 lines
18 KiB
Svelte
<script lang="ts">
|
|
import 'leaflet/dist/leaflet.css';
|
|
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';
|
|
import DirectionsPanel from '$lib/components/DirectionsPanel.svelte';
|
|
import { prefs } from '$lib/prefs';
|
|
|
|
type FeatureCollection = {
|
|
type: string;
|
|
features: GeoJSON.Feature[];
|
|
};
|
|
|
|
export interface MapLayer {
|
|
name: string; // id
|
|
label: string; // display name
|
|
category: 'osm' | 'custom' | 'dump';
|
|
layerType: 'poi' | 'zone' | 'path' | 'mixed';
|
|
color: string;
|
|
featureCount: number;
|
|
path: string;
|
|
graphPath?: string;
|
|
poiPath?: string;
|
|
}
|
|
|
|
interface Props {
|
|
/** Layer definitions to load and render (from the data manifest). */
|
|
layers: MapLayer[];
|
|
/** Center [lat, lon]. */
|
|
center?: [number, number];
|
|
/** Initial zoom. */
|
|
zoom?: number;
|
|
/** Title shown in the header. */
|
|
title?: string;
|
|
}
|
|
|
|
let {
|
|
layers = [],
|
|
center = [36.1, -95.9],
|
|
zoom = 11,
|
|
title = 'Navigator'
|
|
}: Props = $props();
|
|
|
|
let container: HTMLDivElement;
|
|
let map: LeafletMap = $state() as LeafletMap;
|
|
let loading = $state(true);
|
|
let error = $state<string | null>(null);
|
|
const layerGroups: Record<string, L.LayerGroup> = {};
|
|
const featureLayers: Record<string, L.Layer & { bindPopup: (s: string) => void }> = {};
|
|
// Which layers are currently displayed on the map.
|
|
let visible = $state<Record<string, boolean>>({});
|
|
let totalFeatures = $state(0);
|
|
|
|
// --- directions state ---
|
|
const dumpLayers = $derived(
|
|
layers.filter((l) => l.category === 'dump' && l.graphPath && l.poiPath)
|
|
);
|
|
let showDirections = $state(false);
|
|
// --- preferences dialog state ---
|
|
let showPrefs = $state(false);
|
|
// --- search state ---
|
|
const searchables: Searchable[] = [];
|
|
let query = $state('');
|
|
const results = $derived(query.trim().length >= 2 ? search(query, searchables) : []);
|
|
let showResults = $state(false);
|
|
|
|
function focusOn(item: Searchable, openPopup = true) {
|
|
if (!map) return;
|
|
if (item.kind === 'zone' && item.bounds) {
|
|
map.fitBounds(item.bounds as L.LatLngBoundsExpression, { padding: [60, 60] });
|
|
} else {
|
|
map.setView([item.lat, item.lon], Math.max(map.getZoom(), 15));
|
|
}
|
|
if (openPopup) {
|
|
const fl = featureLayers[item.id];
|
|
if (fl && typeof fl.bindPopup === 'function') {
|
|
// openPopup is available on markers / path layers
|
|
(fl as unknown as { openPopup: () => void }).openPopup?.();
|
|
}
|
|
}
|
|
}
|
|
|
|
function onSelect(r: SearchResult) {
|
|
query = r.item.name;
|
|
showResults = false;
|
|
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;
|
|
visible[name] = !visible[name];
|
|
if (visible[name]) g.addTo(map);
|
|
else map.removeLayer(g);
|
|
}
|
|
|
|
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 };
|
|
}
|
|
return { color, weight: 2, fillColor: color, fillOpacity: 0.25 };
|
|
}
|
|
|
|
const COMPASS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
|
function compassPoint(deg: number): string {
|
|
const idx = Math.round((((deg % 360) + 360) % 360) / 22.5) % 16;
|
|
return COMPASS[idx];
|
|
}
|
|
|
|
function popupHtml(f: GeoJSON.Feature): string {
|
|
const props = (f.properties ?? {}) as Record<string, unknown>;
|
|
const name = (props.name as string | undefined) ?? String(f.id ?? '');
|
|
|
|
let extra = '';
|
|
if (props.address) extra += `<tr><th>Address</th><td>${String(props.address)}</td></tr>`;
|
|
if (props.bearing !== undefined) {
|
|
const b = Number(props.bearing);
|
|
extra += `<tr><th>Facing</th><td>${compassPoint(b)} (${b}°)</td></tr>`;
|
|
}
|
|
if (props.fov !== undefined) {
|
|
extra += `<tr><th>Field of view</th><td>${String(props.fov)}°</td></tr>`;
|
|
}
|
|
|
|
const rows = Object.entries(props)
|
|
.filter(([k]) => !['name', 'address', 'bearing', 'fov', 'description'].includes(k) && !k.startsWith('_'))
|
|
.map(([k, v]) => `<tr><th>${k}</th><td>${String(v)}</td></tr>`)
|
|
.join('');
|
|
|
|
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>`;
|
|
}
|
|
|
|
onMount(async () => {
|
|
let L;
|
|
try {
|
|
L = (await import('leaflet')).default;
|
|
} catch (e) {
|
|
error = `Failed to load Leaflet: ${(e as Error).message}`;
|
|
loading = false;
|
|
return;
|
|
}
|
|
|
|
map = L.map(container).setView(center, zoom);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors'
|
|
}).addTo(map);
|
|
|
|
try {
|
|
for (const layer of layers) {
|
|
try {
|
|
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 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, {
|
|
pointToLayer: (_f, latlng) => {
|
|
const props = (_f.properties ?? {}) as Record<string, unknown>;
|
|
// POIs with a bearing get a directional arrow marker.
|
|
if (isPoi && props.bearing !== undefined) {
|
|
const deg = Number(props.bearing);
|
|
return L.marker(latlng, {
|
|
icon: L.divIcon({
|
|
className: 'dir-marker-wrap',
|
|
html: `<div class="dir-arrow" style="transform: rotate(${deg}deg); border-bottom-color:${color}">▲</div>`,
|
|
iconSize: [28, 28],
|
|
iconAnchor: [14, 14]
|
|
})
|
|
});
|
|
}
|
|
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) => {
|
|
totalFeatures++;
|
|
const props = (f.properties ?? {}) as Record<string, unknown>;
|
|
lay.bindPopup(popupHtml(f));
|
|
featureLayers[f.id as string] = lay;
|
|
|
|
// Build a searchable record. A single malformed feature
|
|
// must not break the whole layer load.
|
|
try {
|
|
let lat, lon, bounds;
|
|
if (f.geometry.type === 'Point') {
|
|
[lon, lat] = f.geometry.coordinates as [number, number];
|
|
} 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' : (layer.layerType === 'path' ? 'path' : 'poi');
|
|
searchables.push({
|
|
id: f.id as string,
|
|
name: (props.name as string) ?? String(f.id ?? ''),
|
|
label: (props.name as string) ?? String(f.id ?? ''),
|
|
kind,
|
|
layerName: layer.name,
|
|
layerLabel: layer.label,
|
|
layerColor: layer.color,
|
|
description: props.description as string | undefined,
|
|
address: props.address as string | undefined,
|
|
bearing: props.bearing as number | undefined,
|
|
fov: props.fov as number | undefined,
|
|
lat: lat as number, lon: lon as number,
|
|
bounds,
|
|
feature: f
|
|
});
|
|
} catch (e) {
|
|
console.warn('skip bad feature', f.id, e);
|
|
}
|
|
}
|
|
});
|
|
|
|
layerGroups[layer.name] = geo;
|
|
// Default to none of the layers shown on load; the user toggles
|
|
// them via the legend. (Nothing is added to the map here.)
|
|
visible[layer.name] = false;
|
|
} catch (e) {
|
|
console.warn('layer load failed:', layer.name, e);
|
|
}
|
|
}
|
|
|
|
const all: L.Layer[] = Object.values(layerGroups);
|
|
// Auto-fit only if the user has at least one layer switched on.
|
|
const anyVisible = Object.values(visible).some(Boolean);
|
|
if (all.length && anyVisible) {
|
|
const group = L.featureGroup(all);
|
|
if (typeof group.getBounds === 'function' && group.getBounds().isValid()) {
|
|
map.fitBounds(group.getBounds(), { padding: [40, 40] });
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('overall load error:', e);
|
|
}
|
|
loading = false;
|
|
});
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{title}</title>
|
|
</svelte:head>
|
|
|
|
<div class="wrap">
|
|
<header class="topbar">
|
|
<h1>{title}</h1>
|
|
<div class="search" role="search">
|
|
<input
|
|
type="search"
|
|
placeholder="Search data or any location…"
|
|
bind:value={query}
|
|
onfocus={() => (showResults = true)}
|
|
oninput={() => (showResults = true)}
|
|
/>
|
|
{#if (results.length > 0 || geoLoading || geoResults.length > 0) && showResults}
|
|
<ul class="results">
|
|
{#each results as r (r.item.id)}
|
|
<li>
|
|
<button type="button" onmouseenter={() => focusOn(r.item, false)} onclick={() => onSelect(r)}>
|
|
<span class="dot" style="background:{r.item.layerColor}"></span>
|
|
<span class="rname">{r.item.label}</span>
|
|
<span class="rtype">{r.item.kind}</span>
|
|
{#if r.item.address}<span class="raddr">{r.item.address}</span>{/if}
|
|
</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>
|
|
{#if dumpLayers.length}
|
|
<button class="dirbtn" onclick={() => (showDirections = !showDirections)}>
|
|
{showDirections ? 'Hide Directions' : 'Directions'}
|
|
</button>
|
|
{/if}
|
|
<button class="dirbtn" onclick={() => (showPrefs = !showPrefs)} aria-haspopup="dialog">
|
|
⚙ Preferences
|
|
</button>
|
|
<span class="meta">{totalFeatures} features</span>
|
|
</header>
|
|
|
|
{#if loading}<div class="notice">Loading map & data…</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>
|
|
|
|
{#if showDirections && dumpLayers.length}
|
|
<div class="dirs-wrap">
|
|
<DirectionsPanel {map} dumpLayers={dumpLayers as { name: string; label: string; graphPath: string; poiPath: string }[]} />
|
|
</div>
|
|
{/if}
|
|
|
|
{#if showPrefs}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -- keyboard handled by onkeydown -->
|
|
<div class="pref-backdrop" role="button" tabindex="0" aria-label="Close preferences"
|
|
onclick={() => (showPrefs = false)}
|
|
onkeydown={(e) => { if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); showPrefs = false; } }}>
|
|
<div class="pref-dialog" role="dialog" aria-label="Preferences" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
|
<div class="pref-title">Preferences</div>
|
|
<div class="pref-row">
|
|
<span class="pref-label">Unit system</span>
|
|
<select bind:value={$prefs.system}>
|
|
<option value="metric">Metric (m / km)</option>
|
|
<option value="imperial">Imperial (ft / mi)</option>
|
|
</select>
|
|
</div>
|
|
<div class="pref-hint">Distances use m/ft under a km/mile, otherwise km/mi. Saved to this browser (localStorage).</div>
|
|
<button class="pref-close" onclick={() => (showPrefs = false)}>Done</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="map" bind:this={container}></div>
|
|
</div>
|
|
|
|
<style>
|
|
.wrap {
|
|
display: grid; grid-template-rows: auto 1fr;
|
|
height: 100%; min-height: 0;
|
|
position: relative; overflow: hidden;
|
|
}
|
|
.topbar {
|
|
display: flex; align-items: center; gap: 1.5rem;
|
|
padding: 0 1.25rem; height: 56px;
|
|
background: #0f172a; color: #fff; font-family: system-ui, sans-serif;
|
|
}
|
|
.topbar h1 { font-size: 1.15rem; margin: 0; font-weight: 600; }
|
|
.meta { font-size: 0.8rem; color: #94a3b8; margin-left: auto; }
|
|
|
|
.search { position: relative; flex: 0 1 380px; }
|
|
.search input {
|
|
width: 100%; padding: 8px 12px; border-radius: 8px;
|
|
border: 1px solid #475569; background: #1e293b; color: #fff;
|
|
font-size: 0.85rem; outline: none;
|
|
}
|
|
.search input:focus { border-color: #38bdf8; }
|
|
.results {
|
|
position: absolute; top: calc(100% + 6px); left: 0; right: 0;
|
|
background: #fff; border-radius: 8px; box-shadow: 0 6px 20px rgba(0,0,0,.3);
|
|
list-style: none; margin: 0; padding: 6px; max-height: 320px; overflow: auto;
|
|
z-index: 2000;
|
|
}
|
|
.results li button {
|
|
display: flex; align-items: center; gap: 8px; width: 100%;
|
|
background: none; border: none; cursor: pointer; text-align: left;
|
|
padding: 7px 8px; border-radius: 6px; font: 0.8rem system-ui, sans-serif; color: #0f172a;
|
|
}
|
|
.results li button:hover { background: #f1f5f9; }
|
|
.dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
|
|
.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%; min-height: 0; z-index: 0; }
|
|
.notice {
|
|
position: absolute; top: 66px; left: 50%; transform: translateX(-50%);
|
|
z-index: 1000; background: #0f172a; color: #fff;
|
|
padding: 6px 14px; border-radius: 999px; font: 0.8rem system-ui, sans-serif;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,.3);
|
|
}
|
|
.notice.error { background: #b91c1c; }
|
|
|
|
.dirbtn {
|
|
padding: 6px 14px; border-radius: 8px; border: 1px solid #475569;
|
|
background: #1e293b; color: #e2e8f0; font-size: 0.8rem; cursor: pointer;
|
|
}
|
|
.dirbtn:hover { background: #334155; }
|
|
.dirs-wrap {
|
|
position: absolute; top: 64px; bottom: 14px; left: 12px; z-index: 1500;
|
|
max-height: calc(100% - 78px); width: 268px; overflow: hidden;
|
|
}
|
|
|
|
.legend {
|
|
position: absolute; top: 66px; right: 12px; z-index: 1500;
|
|
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 th) { text-align: left; padding-right: 10px; color: #64748b; font-weight: 600; }
|
|
:global(.popup td) { padding: 1px 0; }
|
|
:global(.popup-desc) { color: #475569; margin-top: 2px; font-size: 0.75rem; }
|
|
:global(.dir-marker-wrap) { background: none; border: none; }
|
|
:global(.dir-arrow) {
|
|
width: 0; height: 0;
|
|
border-left: 8px solid transparent;
|
|
border-right: 8px solid transparent;
|
|
border-bottom: 16px solid #e11d48;
|
|
filter: drop-shadow(0 0 1px rgba(0,0,0,.6));
|
|
}
|
|
|
|
:global(.leaflet-container) { font: inherit; }
|
|
|
|
.pref-backdrop {
|
|
position: fixed; inset: 0; z-index: 3000;
|
|
background: rgba(15, 23, 42, .45); display: flex; align-items: center; justify-content: center;
|
|
border: none; padding: 0; margin: 0; width: 100vw; height: 100vh; cursor: default;
|
|
}
|
|
.pref-dialog {
|
|
background: #fff; color: #0f172a; border-radius: 12px;
|
|
box-shadow: 0 12px 40px rgba(0,0,0,.35); width: 300px; max-width: 90vw;
|
|
padding: 18px 20px; font-family: system-ui, sans-serif;
|
|
}
|
|
.pref-title { font-weight: 700; font-size: 1rem; margin-bottom: 12px; }
|
|
.pref-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 10px 0; }
|
|
.pref-label { font-size: 0.82rem; color: #475569; }
|
|
.pref-row select {
|
|
padding: 6px 8px; border-radius: 6px; border: 1px solid #cbd5e1; font-size: 0.8rem; background: #fff;
|
|
}
|
|
.pref-hint { color: #94a3b8; font-size: 0.7rem; margin-top: 8px; }
|
|
.pref-close {
|
|
width: 100%; margin-top: 12px; padding: 8px; border: none; border-radius: 8px;
|
|
background: #0f172a; color: #fff; font-weight: 600; cursor: pointer; font-size: 0.85rem;
|
|
}
|
|
.pref-close:hover { background: #1e293b; }
|
|
</style> |