navigator/src/lib/components/MapView.svelte
2026-08-10 14:22:31 +00:00

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}&deg;)</td></tr>`;
}
if (props.fov !== undefined) {
extra += `<tr><th>Field of view</th><td>${String(props.fov)}&deg;</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: '&copy; 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 &amp; 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>