120 lines
3.4 KiB
TypeScript
120 lines
3.4 KiB
TypeScript
/**
|
|
* 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 };
|
|
} |