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 { search } from '$lib/search';
|
||||||
import { geocode, type GeocodedPlace } from '$lib/geocode';
|
import { geocode, type GeocodedPlace } from '$lib/geocode';
|
||||||
import { prefs } from '$lib/prefs';
|
import { prefs } from '$lib/prefs';
|
||||||
|
import type { SavedPoint } from '$lib/savedPoints';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
map: LeafletMap;
|
map: LeafletMap;
|
||||||
@ -29,6 +30,10 @@
|
|||||||
to: [number, number],
|
to: [number, number],
|
||||||
avoid: AvoidRule[]
|
avoid: AvoidRule[]
|
||||||
) => Promise<RouteResult>;
|
) => 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 {
|
let {
|
||||||
@ -40,7 +45,9 @@
|
|||||||
searchables = [],
|
searchables = [],
|
||||||
progress = 0,
|
progress = 0,
|
||||||
computing = false,
|
computing = false,
|
||||||
runRoute
|
runRoute,
|
||||||
|
savedPoints = [],
|
||||||
|
onPickSavedPoint
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// --- destination ---
|
// --- destination ---
|
||||||
@ -280,6 +287,16 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@ -401,6 +418,11 @@
|
|||||||
.warnline { color: #b45309; font-size: 0.72rem; margin-top: 4px; }
|
.warnline { color: #b45309; font-size: 0.72rem; margin-top: 4px; }
|
||||||
.mute { color: #94a3b8; font-size: 0.7rem; }
|
.mute { color: #94a3b8; font-size: 0.7rem; }
|
||||||
.warn { color: #b91c1c; }
|
.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; }
|
.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 { 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; }
|
.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 DirectionsPanel from '$lib/components/DirectionsPanel.svelte';
|
||||||
import { prefs } from '$lib/prefs';
|
import { prefs } from '$lib/prefs';
|
||||||
import type { RouteGraph, AvoidRule, RouteResult } from '$lib/routing';
|
import type { RouteGraph, AvoidRule, RouteResult } from '$lib/routing';
|
||||||
|
import { savedPoints, addSavedPoint, removeSavedPoint, type SavedPoint } from '$lib/savedPoints';
|
||||||
|
|
||||||
type FeatureCollection = {
|
type FeatureCollection = {
|
||||||
type: string;
|
type: string;
|
||||||
@ -62,9 +63,14 @@
|
|||||||
// Set by a search result or a direct map click; most recent wins.
|
// 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 origin = $state<{ lat: number; lon: number; label?: string } | null>(null);
|
||||||
let originLayer: L.Layer | 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 ---
|
// --- preferences dialog state ---
|
||||||
let showPrefs = $state(false);
|
let showPrefs = $state(false);
|
||||||
let computingRoute = $state(false);
|
let computingRoute = $state(false);
|
||||||
|
savedPoints.subscribe((pts) => { savedPointsList = pts; });
|
||||||
// --- route graph + POIs loaded from a dump layer (for directions) ---
|
// --- route graph + POIs loaded from a dump layer (for directions) ---
|
||||||
let routeGraph = $state<RouteGraph | null>(null);
|
let routeGraph = $state<RouteGraph | null>(null);
|
||||||
let routePois = $state<GeoJSON.Feature[]>([]);
|
let routePois = $state<GeoJSON.Feature[]>([]);
|
||||||
@ -239,6 +245,36 @@
|
|||||||
|
|
||||||
onDestroy(destroyWorker);
|
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) {
|
function toggle(name: string) {
|
||||||
const g = layerGroups[name];
|
const g = layerGroups[name];
|
||||||
if (!g) return;
|
if (!g) return;
|
||||||
@ -303,11 +339,13 @@
|
|||||||
attribution: '© OpenStreetMap contributors'
|
attribution: '© OpenStreetMap contributors'
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
// Click anywhere on the map to set the origin (start point), as long as
|
// Clicking an empty spot on the map creates a browser-local Saved Point
|
||||||
// we're not in the middle of picking a destination inside the panel.
|
// 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) => {
|
map.on('click', (e: L.LeafletMouseEvent) => {
|
||||||
if (showDirections) return; // direction dialog owns clicks while open
|
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 {
|
try {
|
||||||
@ -490,6 +528,12 @@
|
|||||||
<span class="count">{layer.featureCount}</span>
|
<span class="count">{layer.featureCount}</span>
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/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>
|
</div>
|
||||||
|
|
||||||
{#if showDirections && origin}
|
{#if showDirections && origin}
|
||||||
@ -501,6 +545,8 @@
|
|||||||
graphLabel={graphLabel}
|
graphLabel={graphLabel}
|
||||||
onLoadGraph={() => loadRouteGraph(true)}
|
onLoadGraph={() => loadRouteGraph(true)}
|
||||||
searchables={searchables}
|
searchables={searchables}
|
||||||
|
savedPoints={savedPointsList}
|
||||||
|
onPickSavedPoint={(p) => setSavedPointAsOrigin(p)}
|
||||||
progress={routeProgress}
|
progress={routeProgress}
|
||||||
computing={computingRoute}
|
computing={computingRoute}
|
||||||
{runRoute}
|
{runRoute}
|
||||||
@ -639,6 +685,9 @@
|
|||||||
background: #0f172a; color: #fff; font-weight: 600; cursor: pointer; font-size: 0.85rem;
|
background: #0f172a; color: #fff; font-weight: 600; cursor: pointer; font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.pref-close:hover { background: #1e293b; }
|
.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 */
|
/* origin pin */
|
||||||
:global(.origin-pin-wrap) { background: transparent; border: none; }
|
:global(.origin-pin-wrap) { background: transparent; border: none; }
|
||||||
:global(.origin-label) {
|
: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