navigator/src/lib/components/MapView.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, '&lt;') : '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, '&lt;')}</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}&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);
// 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 &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}
<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>