761 lines
28 KiB
Svelte
761 lines
28 KiB
Svelte
<script lang="ts">
|
|
import 'leaflet/dist/leaflet.css';
|
|
import { onMount, onDestroy } 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';
|
|
import type { RouteGraph, AvoidRule, RouteResult } from '$lib/routing';
|
|
import { savedPoints, addSavedPoint, exportSavedPoints, importSavedPoints, type SavedPoint } from '$lib/savedPoints';
|
|
|
|
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);
|
|
// --- origin (start point) for directions ---
|
|
// Set by a search result or a direct map click; most recent wins.
|
|
let origin = $state<{ lat: number; lon: number; label?: string } | null>(null);
|
|
let originLayer: L.Layer | null = null;
|
|
// --- Saved Points (browser-local) layer ---
|
|
let savedPointsList = $state<SavedPoint[]>([]);
|
|
let savedPointLayer: L.LayerGroup | null = null;
|
|
let savedPointsVisible = $state(false);
|
|
// --- preferences dialog state ---
|
|
let showPrefs = $state(false);
|
|
let computingRoute = $state(false);
|
|
savedPoints.subscribe((pts) => { savedPointsList = pts; });
|
|
// --- route graph + POIs loaded from a dump layer (for directions) ---
|
|
let routeGraph = $state<RouteGraph | null>(null);
|
|
let routePois = $state<GeoJSON.Feature[]>([]);
|
|
let routeLoading = $state(false);
|
|
let routeProgress = $state(0);
|
|
let graphLabel = $derived(dumpLayers[0]?.label ?? '');
|
|
// --- Web Worker for route computation (off the UI thread) ---
|
|
let routeWorker: Worker | null = null;
|
|
let routeToken = 0;
|
|
let pendingResolve: ((r: RouteResult) => void) | null = null;
|
|
let pendingReject: ((e: Error) => void) | null = null;
|
|
// --- 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?.();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw a pin for the origin (start point) and store it.
|
|
async function setOrigin(lat: number, lon: number, label?: string) {
|
|
origin = { lat, lon, label };
|
|
const L = (await import('leaflet')).default;
|
|
if (originLayer) originLayer.remove();
|
|
originLayer = L.marker([lat, lon], {
|
|
icon: L.divIcon({
|
|
className: 'origin-pin-wrap',
|
|
html: `<div class="origin-pin" style="border-left-color:#16a34a"></div><span class="origin-label">${label ? label.replace(/</g, '<') : 'Start'}</span>`,
|
|
iconSize: [0, 0]
|
|
})
|
|
}).addTo(map);
|
|
}
|
|
|
|
function onSelect(r: SearchResult) {
|
|
query = r.item.name;
|
|
showResults = false;
|
|
// Selecting a search result sets the origin (the start point).
|
|
setOrigin(r.item.lat, r.item.lon, r.item.name);
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Load the routable graph + POIs from the first dump layer on demand.
|
|
async function loadRouteGraph(fresh = false) {
|
|
if ((routeGraph && !fresh) || routeLoading) return;
|
|
const layer = dumpLayers[0];
|
|
if (!layer || !layer.graphPath || !layer.poiPath) { routeGraph = null; return; }
|
|
routeLoading = true;
|
|
try {
|
|
const [g, p] = await Promise.all([fetch(layer.graphPath), fetch(layer.poiPath)]);
|
|
if (!g.ok) throw new Error(`graph HTTP ${g.status}`);
|
|
if (!p.ok) throw new Error(`poi HTTP ${p.status}`);
|
|
routeGraph = await g.json();
|
|
const pc: { features: GeoJSON.Feature[] } = await p.json();
|
|
routePois = pc.features;
|
|
} catch (e) {
|
|
routeGraph = null;
|
|
console.warn('route graph load failed:', e);
|
|
} finally {
|
|
routeLoading = false;
|
|
}
|
|
}
|
|
|
|
// Lazily create the routing worker. It is recreated on demand and kept for
|
|
// the component lifetime (destroyed on unmount).
|
|
function ensureWorker(): Worker {
|
|
if (routeWorker) return routeWorker;
|
|
const w = new Worker(new URL('../routing.worker.ts', import.meta.url), { type: 'module' });
|
|
w.onmessage = (e: MessageEvent) => {
|
|
const msg = e.data as { type: string; pct?: number; result?: RouteResult; error?: string; message?: string; token?: number };
|
|
if (msg.type === 'progress' && msg.pct !== undefined) {
|
|
routeProgress = msg.pct;
|
|
return;
|
|
}
|
|
if (msg.type === 'done' && pendingResolve) {
|
|
const r = pendingResolve; pendingResolve = null; pendingReject = null;
|
|
computingRoute = false;
|
|
r(msg.result as RouteResult);
|
|
} else if (msg.type === 'error' && pendingReject) {
|
|
const rj = pendingReject; pendingResolve = null; pendingReject = null;
|
|
computingRoute = false;
|
|
rj(new Error((msg as { message?: string }).message ?? 'Route computation failed.'));
|
|
}
|
|
};
|
|
routeWorker = w;
|
|
return w;
|
|
}
|
|
function destroyWorker() {
|
|
if (routeWorker) { routeWorker.terminate(); routeWorker = null; }
|
|
}
|
|
|
|
// Run a route in the Web Worker, reporting progress back to the panel.
|
|
async function runRoute(
|
|
from: [number, number],
|
|
to: [number, number],
|
|
avoid: AvoidRule[]
|
|
): Promise<RouteResult> {
|
|
if (!routeGraph) await loadRouteGraph();
|
|
if (!routeGraph) throw new Error('Road data layer is not available.');
|
|
const w = ensureWorker();
|
|
computingRoute = true;
|
|
routeProgress = 0;
|
|
const token = ++routeToken;
|
|
return await new Promise<RouteResult>((resolve, reject) => {
|
|
pendingResolve = resolve;
|
|
pendingReject = reject;
|
|
w.postMessage({
|
|
type: 'route',
|
|
token,
|
|
graph: routeGraph,
|
|
pois: routePois,
|
|
from,
|
|
to,
|
|
avoid
|
|
});
|
|
});
|
|
}
|
|
|
|
onDestroy(destroyWorker);
|
|
|
|
// (Re)draw the Saved Points layer on the map when visible.
|
|
async function drawSavedPoints() {
|
|
if (!map) return;
|
|
const L = (await import('leaflet')).default;
|
|
if (savedPointLayer) { savedPointLayer.remove(); savedPointLayer = null; }
|
|
if (!savedPointsVisible || !savedPointsList.length) return;
|
|
savedPointLayer = L.layerGroup().addTo(map);
|
|
for (const p of savedPointsList) {
|
|
const mk = L.marker([p.lat, p.lon], {
|
|
icon: L.divIcon({
|
|
className: 'saved-pin-wrap',
|
|
html: `<div class="saved-pin" style="border-left-color:#8b5cf6"></div>`,
|
|
iconSize: [0, 0]
|
|
})
|
|
}).addTo(savedPointLayer);
|
|
const nm = p.label || `${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}`;
|
|
mk.bindPopup(`<strong>${nm.replace(/</g, '<')}</strong><br><em class="popup-desc">Saved point</em>`);
|
|
}
|
|
}
|
|
// Redraw saved points when the underlying list changes while visible.
|
|
$effect(() => { savedPointsList; if (savedPointsVisible && map) void drawSavedPoints(); });
|
|
|
|
function toggleSavedPoints() {
|
|
savedPointsVisible = !savedPointsVisible;
|
|
void drawSavedPoints();
|
|
}
|
|
let importMsg = $state<string | null>(null);
|
|
|
|
// Download the current saved points as JSON.
|
|
function exportSavedPointsToFile() {
|
|
const json = exportSavedPoints();
|
|
const blob = new Blob([json], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `navigator-saved-points-${new Date().toISOString().slice(0, 10)}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// Read a selected file and merge its saved points into the store.
|
|
function importSavedPointsFromFile(file: File | null) {
|
|
importMsg = null;
|
|
if (!file) return;
|
|
if (file.type && file.type !== 'application/json' && !file.name.endsWith('.json')) {
|
|
importMsg = 'Please choose a .json file.';
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
try {
|
|
const { added, skipped } = importSavedPoints(String(reader.result));
|
|
importMsg = `Imported ${added} point${added === 1 ? '' : 's'}${skipped ? `, skipped ${skipped} existing` : ''}.`;
|
|
} catch (e) {
|
|
importMsg = e instanceof Error ? e.message : 'Import failed.';
|
|
}
|
|
};
|
|
reader.onerror = () => { importMsg = 'Could not read the file.'; };
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
function setSavedPointAsOrigin(p: SavedPoint) {
|
|
setOrigin(p.lat, p.lon, p.label || 'Saved point');
|
|
}
|
|
|
|
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);
|
|
|
|
// Clicking an empty spot on the map creates a browser-local Saved Point
|
|
// and sets it as the origin (start point). POI clicks are handled by the
|
|
// individual feature popups, so a bare map click = empty space.
|
|
map.on('click', (e: L.LeafletMouseEvent) => {
|
|
if (showDirections) return; // direction dialog owns clicks while open
|
|
const sp = addSavedPoint({ lat: e.latlng.lat, lon: e.latlng.lng });
|
|
setOrigin(sp.lat, sp.lon, sp.label || 'Start');
|
|
});
|
|
|
|
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 origin}
|
|
<button class="dirbtn" onclick={() => (showDirections = !showDirections)} disabled={computingRoute}>
|
|
{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}
|
|
<label class="row">
|
|
<input type="checkbox" checked={savedPointsVisible} onchange={() => toggleSavedPoints()} />
|
|
<span class="swatch" style="background:#8b5cf6"></span>
|
|
<span class="lbl">Saved Points</span>
|
|
<span class="count">{savedPointsList.length}</span>
|
|
</label>
|
|
</div>
|
|
|
|
{#if showDirections && origin}
|
|
<div class="dirs-wrap">
|
|
<DirectionsPanel
|
|
{map}
|
|
origin={origin}
|
|
hasGraph={!!routeGraph}
|
|
graphLabel={graphLabel}
|
|
onLoadGraph={() => loadRouteGraph(true)}
|
|
graphLoading={routeLoading}
|
|
searchables={searchables}
|
|
savedPoints={savedPointsList}
|
|
onPickSavedPoint={(p) => setSavedPointAsOrigin(p)}
|
|
progress={routeProgress}
|
|
computing={computingRoute}
|
|
{runRoute}
|
|
/>
|
|
</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>
|
|
|
|
<div class="pref-rule"></div>
|
|
<div class="pref-title small">Saved Points ({savedPointsList.length})</div>
|
|
<div class="pref-row">
|
|
<span class="pref-label">Back up / restore your saved points</span>
|
|
</div>
|
|
<div class="pref-btns">
|
|
<button class="pref-small-btn" onclick={() => exportSavedPointsToFile()} disabled={!savedPointsList.length}>Export</button>
|
|
<label class="pref-file">
|
|
Import…
|
|
<input type="file" accept="application/json,.json" hidden onchange={(e) => importSavedPointsFromFile((e.target as HTMLInputElement).files?.[0] ?? null)} />
|
|
</label>
|
|
</div>
|
|
{#if importMsg}<div class="pref-import-msg">{importMsg}</div>{/if}
|
|
|
|
<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; }
|
|
.pref-rule { border-top: 1px solid #e2e8f0; margin: 14px 0 8px; }
|
|
.pref-title.small { font-size: 0.82rem; margin-bottom: 6px; }
|
|
.pref-btns { display: flex; gap: 8px; margin-top: 4px; }
|
|
.pref-small-btn, .pref-file {
|
|
display: inline-flex; align-items: center; padding: 6px 12px; border-radius: 6px;
|
|
border: 1px solid #cbd5e1; background: #fff; cursor: pointer; font-size: 0.78rem; color: #0f172a;
|
|
}
|
|
.pref-small-btn:hover, .pref-file:hover { background: #f1f5f9; }
|
|
.pref-small-btn:disabled { opacity: .5; cursor: default; }
|
|
.pref-import-msg { font-size: 0.72rem; color: #475569; margin-top: 8px; }
|
|
/* saved points pin */
|
|
:global(.saved-pin-wrap) { background: transparent; border: none; }
|
|
:global(.saved-pin) { width: 0; height: 0; border: 10px solid transparent; border-bottom: 0; border-left-color: #8b5cf6; }
|
|
/* origin pin */
|
|
:global(.origin-pin-wrap) { background: transparent; border: none; }
|
|
:global(.origin-label) {
|
|
position: absolute; top: -30px; left: -34px; width: 68px; text-align: center;
|
|
font: 700 0.72rem system-ui, sans-serif; color: #16a34a;
|
|
text-shadow: 0 1px 2px #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
</style> |