feat(directions): browser-local Saved Points layer (click empty space), toggleable, selectable as origin/destination
This commit is contained in:
parent
591fafc5de
commit
fe072c8d85
@ -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<RouteResult>;
|
||||
/** 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 @@
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{#if savedPoints.length}
|
||||
<div class="dest-saved">
|
||||
<span class="sept-label">— or a saved point —</span>
|
||||
{#each savedPoints as p (p.id)}
|
||||
<button type="button" class="saved-opt" onclick={() => { onPickSavedPoint?.(p); destination = { lat: p.lat, lon: p.lon, label: p.label || 'Saved point' }; }}>
|
||||
<span class="saved-dot"></span>{p.label || `${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}`}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/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; }
|
||||
|
||||
@ -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<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[]>([]);
|
||||
@ -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: `<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();
|
||||
}
|
||||
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 @@
|
||||
<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}
|
||||
@ -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) {
|
||||
|
||||
120
src/lib/savedPoints.ts
Normal file
120
src/lib/savedPoints.ts
Normal file
@ -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<SavedPoint[]>(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, 'id' | 'created'>): 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 };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user