From fe072c8d858d2efec8ea449dddf06105d9d7c628 Mon Sep 17 00:00:00 2001 From: hermes-explorigin Date: Mon, 10 Aug 2026 15:26:30 +0000 Subject: [PATCH] feat(directions): browser-local Saved Points layer (click empty space), toggleable, selectable as origin/destination --- src/lib/components/DirectionsPanel.svelte | 24 ++++- src/lib/components/MapView.svelte | 55 +++++++++- src/lib/savedPoints.ts | 120 ++++++++++++++++++++++ 3 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 src/lib/savedPoints.ts diff --git a/src/lib/components/DirectionsPanel.svelte b/src/lib/components/DirectionsPanel.svelte index ec81e48..345005d 100644 --- a/src/lib/components/DirectionsPanel.svelte +++ b/src/lib/components/DirectionsPanel.svelte @@ -6,6 +6,7 @@ import { search } from '$lib/search'; import { geocode, type GeocodedPlace } from '$lib/geocode'; import { prefs } from '$lib/prefs'; + import type { SavedPoint } from '$lib/savedPoints'; interface Props { map: LeafletMap; @@ -29,6 +30,10 @@ to: [number, number], avoid: AvoidRule[] ) => Promise; + /** Browser-local saved points (selectable as destination). */ + savedPoints?: SavedPoint[]; + /** Called when the user picks a saved point as a destination. */ + onPickSavedPoint?: (p: SavedPoint) => void; } let { @@ -40,7 +45,9 @@ searchables = [], progress = 0, computing = false, - runRoute + runRoute, + savedPoints = [], + onPickSavedPoint }: Props = $props(); // --- destination --- @@ -280,6 +287,16 @@ {/if} + {#if savedPoints.length} +
+ — or a saved point — + {#each savedPoints as p (p.id)} + + {/each} +
+ {/if} {/if} @@ -401,6 +418,11 @@ .warnline { color: #b45309; font-size: 0.72rem; margin-top: 4px; } .mute { color: #94a3b8; font-size: 0.7rem; } .warn { color: #b91c1c; } + .dest-saved { margin-top: 6px; border-top: 1px solid #e2e8f0; padding-top: 6px; display: grid; gap: 3px; } + .sept-label { font-size: 0.68rem; color: #94a3b8; } + .saved-opt { width: 100%; text-align: left; background: none; border: none; cursor: pointer; padding: 4px 6px; border-radius: 4px; font-size: 0.76rem; color: #0f172a; display: flex; align-items: center; gap: 6px; } + .saved-opt:hover { background: #f1f5f9; } + .saved-dot { width: 8px; height: 8px; border-radius: 50%; background: #8b5cf6; flex: none; } .hint.small { font-size: 0.7rem; margin-top: 6px; color: #94a3b8; } .turns { margin-top: 8px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 8px; } .turns-head { font-weight: 700; font-size: 0.72rem; color: #475569; margin-bottom: 4px; } diff --git a/src/lib/components/MapView.svelte b/src/lib/components/MapView.svelte index c757869..e6f13b0 100644 --- a/src/lib/components/MapView.svelte +++ b/src/lib/components/MapView.svelte @@ -7,6 +7,7 @@ import DirectionsPanel from '$lib/components/DirectionsPanel.svelte'; import { prefs } from '$lib/prefs'; import type { RouteGraph, AvoidRule, RouteResult } from '$lib/routing'; + import { savedPoints, addSavedPoint, removeSavedPoint, type SavedPoint } from '$lib/savedPoints'; type FeatureCollection = { type: string; @@ -62,9 +63,14 @@ // 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([]); + 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(null); let routePois = $state([]); @@ -239,6 +245,36 @@ 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: `
`, + iconSize: [0, 0] + }) + }).addTo(savedPointLayer); + const nm = p.label || `${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}`; + mk.bindPopup(`${nm.replace(/
Saved point`); + } + } + // Redraw saved points when the underlying list changes while visible. + $effect(() => { savedPointsList; if (savedPointsVisible && map) void drawSavedPoints(); }); + + function toggleSavedPoints() { + savedPointsVisible = !savedPointsVisible; + void drawSavedPoints(); + } + function setSavedPointAsOrigin(p: SavedPoint) { + setOrigin(p.lat, p.lon, p.label || 'Saved point'); + } + function toggle(name: string) { const g = layerGroups[name]; if (!g) return; @@ -303,11 +339,13 @@ attribution: '© OpenStreetMap contributors' }).addTo(map); - // Click anywhere on the map to set the origin (start point), as long as - // we're not in the middle of picking a destination inside the panel. + // 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 - setOrigin(e.latlng.lat, e.latlng.lng); + const sp = addSavedPoint({ lat: e.latlng.lat, lon: e.latlng.lng }); + setOrigin(sp.lat, sp.lon, sp.label || 'Start'); }); try { @@ -490,6 +528,12 @@ {layer.featureCount} {/each} + {#if showDirections && origin} @@ -501,6 +545,8 @@ graphLabel={graphLabel} onLoadGraph={() => loadRouteGraph(true)} searchables={searchables} + savedPoints={savedPointsList} + onPickSavedPoint={(p) => setSavedPointAsOrigin(p)} progress={routeProgress} computing={computingRoute} {runRoute} @@ -639,6 +685,9 @@ background: #0f172a; color: #fff; font-weight: 600; cursor: pointer; font-size: 0.85rem; } .pref-close:hover { background: #1e293b; } + /* 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) { diff --git a/src/lib/savedPoints.ts b/src/lib/savedPoints.ts new file mode 100644 index 0000000..40a590a --- /dev/null +++ b/src/lib/savedPoints.ts @@ -0,0 +1,120 @@ +/** + * savedPoints.ts — browser-local "Saved Points" store. + * ---------------------------------------------------------------------- + * A Saved Point is a user-clicked location that isn't a known POI. Points are + * persisted to localStorage so they survive reloads, and are rendered as a + * toggleable "Saved Points" layer. They can also serve as route origins / + * destinations. + * + * The store is a Svelte writable store; subscribe to it to re-render the layer + * when points change. + */ + +import { writable } from 'svelte/store'; + +export interface SavedPoint { + id: string; + lat: number; + lon: number; + label?: string; + created: number; +} + +const KEY = 'navigator.savedPoints.v1'; + +function load(): SavedPoint[] { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((p) => p && typeof p.lat === 'number' && typeof p.lon === 'number') + .map((p) => ({ + id: String(p.id ?? `${p.lat},${p.lon}`), + lat: p.lat, + lon: p.lon, + label: typeof p.label === 'string' ? p.label : undefined, + created: typeof p.created === 'number' ? p.created : Date.now() + })); + } catch { + return []; + } +} + +function createStore() { + const store = writable(load()); + store.subscribe((points) => { + try { + localStorage.setItem(KEY, JSON.stringify(points)); + } catch { + /* storage unavailable — ignore */ + } + }); + return store; +} + +export const savedPoints = createStore(); + +export function addSavedPoint(point: Omit): SavedPoint { + const sp: SavedPoint = { ...point, id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`, created: Date.now() }; + savedPoints.update((pts) => [...pts, sp]); + return sp; +} + +export function removeSavedPoint(id: string): void { + savedPoints.update((pts) => pts.filter((p) => p.id !== id)); +} + +export function clearSavedPoints(): void { + savedPoints.set([]); +} + +/** + * Serialize the current points to a JSON string (for export / download). + */ +export function exportSavedPoints(): string { + let pts: SavedPoint[] = []; + savedPoints.subscribe((p) => (pts = p))(); + return JSON.stringify({ type: 'navigator.savedPoints', version: 1, points: pts }, null, 2); +} + +/** + * Merge imported points into the store, de-duplicating by id (existing ids are + * kept; new ids are added). Returns the number of points added. + */ +export function importSavedPoints(json: string): { added: number; skipped: number } { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throw new Error('Invalid JSON file.'); + } + const list = (parsed as { points?: unknown[] })?.points; + if (!Array.isArray(list)) throw new Error('Unrecognized file format (expected { points: [...] }).'); + + const valid: SavedPoint[] = list + .filter((p): p is SavedPoint => !!p && typeof (p as SavedPoint).lat === 'number' && typeof (p as SavedPoint).lon === 'number') + .map((p) => ({ + id: String(p.id ?? `${p.lat},${p.lon}`), + lat: p.lat, + lon: p.lon, + label: typeof p.label === 'string' ? p.label : undefined, + created: typeof p.created === 'number' ? p.created : Date.now() + })); + + let added = 0; + let skipped = 0; + savedPoints.update((pts) => { + const existing = new Set(pts.map((p) => p.id)); + const out = [...pts]; + for (const p of valid) { + if (existing.has(p.id)) { skipped++; continue; } + out.push(p); + existing.add(p.id); + added++; + } + return out; + }); + return { added, skipped }; +} \ No newline at end of file