feat(ui): context dialog on map click (no auto-save); save-point form; 'Directions from/to here'; remove Directions button + Data sources bar
This commit is contained in:
parent
2e0269b865
commit
80d049eebf
@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Map as LeafletMap } from 'leaflet';
|
import type { Map as LeafletMap } from 'leaflet';
|
||||||
import { onMount } from 'svelte';
|
import type { RouteGraph, AvoidRule, RouteResult, TurnStep } from '$lib/routing';
|
||||||
import type { RouteGraph, AvoidRule, RouteResult, TurnStep } from '$lib/routing';
|
|
||||||
import type { Searchable, SearchResult } from '$lib/search';
|
import type { Searchable, SearchResult } from '$lib/search';
|
||||||
import { search } from '$lib/search';
|
import { search } from '$lib/search';
|
||||||
import { geocode, type GeocodedPlace } from '$lib/geocode';
|
import { geocode, type GeocodedPlace } from '$lib/geocode';
|
||||||
@ -36,6 +35,10 @@
|
|||||||
savedPoints?: SavedPoint[];
|
savedPoints?: SavedPoint[];
|
||||||
/** Called when the user picks a saved point as a destination. */
|
/** Called when the user picks a saved point as a destination. */
|
||||||
onPickSavedPoint?: (p: SavedPoint) => void;
|
onPickSavedPoint?: (p: SavedPoint) => void;
|
||||||
|
/** Controlled destination (set via the context dialog or the picker). */
|
||||||
|
destination: { lat: number; lon: number; label?: string } | null;
|
||||||
|
/** Called whenever the destination should change. */
|
||||||
|
onDestinationChange: (d: { lat: number; lon: number; label?: string } | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@ -50,14 +53,12 @@
|
|||||||
computing = false,
|
computing = false,
|
||||||
runRoute,
|
runRoute,
|
||||||
savedPoints = [],
|
savedPoints = [],
|
||||||
onPickSavedPoint
|
onPickSavedPoint,
|
||||||
|
destination = null,
|
||||||
|
onDestinationChange
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// --- destination ---
|
// --- destination ---
|
||||||
let destination = $state<{ lat: number; lon: number; label?: string } | null>(null);
|
|
||||||
let pickingDest = $state(false);
|
|
||||||
// broadcast message while waiting for the destination click
|
|
||||||
let destHint = $state<string>('');
|
|
||||||
|
|
||||||
// --- search for destination ---
|
// --- search for destination ---
|
||||||
let destQuery = $state('');
|
let destQuery = $state('');
|
||||||
@ -118,31 +119,13 @@
|
|||||||
return min ? `${h} h ${min} min` : `${h} h`;
|
return min ? `${h} h ${min} min` : `${h} h`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- destination by map click ---
|
// --- destination (set via the context dialog or search/picked below) ---
|
||||||
function startPickingDest() {
|
|
||||||
pickingDest = true;
|
|
||||||
destHint = 'Click the map to choose the destination…';
|
|
||||||
}
|
|
||||||
function stopPickingDest() {
|
|
||||||
pickingDest = false;
|
|
||||||
destHint = '';
|
|
||||||
}
|
|
||||||
function setDestination(lat: number, lon: number, label?: string) {
|
function setDestination(lat: number, lon: number, label?: string) {
|
||||||
destination = { lat, lon, label };
|
onDestinationChange({ lat, lon, label });
|
||||||
pickingDest = false;
|
result = null;
|
||||||
destHint = '';
|
status = 'idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map clicks while the panel is open belong to destination selection.
|
|
||||||
onMount(() => {
|
|
||||||
if (!map) return;
|
|
||||||
const onClick = (e: L.LeafletMouseEvent) => {
|
|
||||||
if (pickingDest) setDestination(e.latlng.lat, e.latlng.lng);
|
|
||||||
};
|
|
||||||
map.on('click', onClick);
|
|
||||||
return () => map.off('click', onClick);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- destination by search ---
|
// --- destination by search ---
|
||||||
async function inputDestSearch() {
|
async function inputDestSearch() {
|
||||||
destResultQuery = destQuery;
|
destResultQuery = destQuery;
|
||||||
@ -258,13 +241,10 @@
|
|||||||
<!-- Destination -->
|
<!-- Destination -->
|
||||||
{#if destination}
|
{#if destination}
|
||||||
<div class="point"><span class="dot b"></span> B · {destination.label || `${destination.lat.toFixed(4)}, ${destination.lon.toFixed(4)}`}</div>
|
<div class="point"><span class="dot b"></span> B · {destination.label || `${destination.lat.toFixed(4)}, ${destination.lon.toFixed(4)}`}</div>
|
||||||
<button class="reset-dest" onclick={() => (destination = null)}>Change destination</button>
|
<button class="reset-dest" onclick={() => { onDestinationChange(null); result = null; status = 'idle'; }}>Change destination</button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="dest-pick">
|
<div class="dest-pick">
|
||||||
<button class="pick" onclick={() => (pickingDest ? stopPickingDest() : startPickingDest())}>
|
<div class="dest-note">Click a point on the map, or search below.</div>
|
||||||
{pickingDest ? 'Cancel map pick' : 'Pick destination on map'}
|
|
||||||
</button>
|
|
||||||
{#if destHint}<div class="hint">{destHint}</div>{/if}
|
|
||||||
<div class="dest-search">
|
<div class="dest-search">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
@ -296,7 +276,7 @@
|
|||||||
<div class="dest-saved">
|
<div class="dest-saved">
|
||||||
<span class="sept-label">— or a saved point —</span>
|
<span class="sept-label">— or a saved point —</span>
|
||||||
{#each savedPoints as p (p.id)}
|
{#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' }; }}>
|
<button type="button" class="saved-opt" onclick={() => { onPickSavedPoint?.(p); setDestination(p.lat, p.lon, p.label || 'Saved point'); }}>
|
||||||
<span class="saved-dot"></span>{p.label || `${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}`}
|
<span class="saved-dot"></span>{p.label || `${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}`}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@ -381,11 +361,11 @@
|
|||||||
.point { padding: 6px 8px; background: #f1f5f9; border-radius: 6px; margin: 4px 0; display: flex; align-items: center; gap: 6px; }
|
.point { padding: 6px 8px; background: #f1f5f9; border-radius: 6px; margin: 4px 0; display: flex; align-items: center; gap: 6px; }
|
||||||
.dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
|
.dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
|
||||||
.dot.a { background: #16a34a; } .dot.b { background: #dc2626; }
|
.dot.a { background: #16a34a; } .dot.b { background: #dc2626; }
|
||||||
.pick, .go, .clear, .cta-btn, .reset-dest, .opt-toggle {
|
.go, .clear, .cta-btn, .reset-dest, .opt-toggle {
|
||||||
width: 100%; padding: 7px; border-radius: 6px; border: 1px solid #cbd5e1;
|
width: 100%; padding: 7px; border-radius: 6px; border: 1px solid #cbd5e1;
|
||||||
background: #fff; cursor: pointer; margin: 4px 0; font-size: 0.8rem;
|
background: #fff; cursor: pointer; margin: 4px 0; font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.pick:hover, .go:hover, .clear:hover, .reset-dest:hover, .opt-toggle:hover { background: #f1f5f9; }
|
.go:hover, .clear:hover, .reset-dest:hover, .opt-toggle:hover { background: #f1f5f9; }
|
||||||
.reset-dest { color: #475569; }
|
.reset-dest { color: #475569; }
|
||||||
.go { background: #0f172a; color: #fff; border-color: #0f172a; font-weight: 600; }
|
.go { background: #0f172a; color: #fff; border-color: #0f172a; font-weight: 600; }
|
||||||
.go:disabled { opacity: 0.5; cursor: default; }
|
.go:disabled { opacity: 0.5; cursor: default; }
|
||||||
@ -428,6 +408,7 @@
|
|||||||
.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 { 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-opt:hover { background: #f1f5f9; }
|
||||||
.saved-dot { width: 8px; height: 8px; border-radius: 50%; background: #8b5cf6; flex: none; }
|
.saved-dot { width: 8px; height: 8px; border-radius: 50%; background: #8b5cf6; flex: none; }
|
||||||
|
.dest-note { color: #64748b; font-size: 0.75rem; margin: 4px 0; }
|
||||||
.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; }
|
||||||
|
|||||||
153
src/lib/components/LocationDialog.svelte
Normal file
153
src/lib/components/LocationDialog.svelte
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { SavedPoint } from '$lib/savedPoints';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Click location in map-container pixel coordinates (anchoring the dialog). */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** The clicked coordinates. */
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
/** If the click landed on an existing feature (POI/zone/path). */
|
||||||
|
feature?: { name: string; rows: [string, string][] } | null;
|
||||||
|
/** If the click landed on a saved point. */
|
||||||
|
savedPoint?: SavedPoint | null;
|
||||||
|
/** Whether a route origin has been set (controls whether "to here" is shown). */
|
||||||
|
hasOrigin: boolean;
|
||||||
|
/** Whether this point is currently the route origin. */
|
||||||
|
isOrigin: boolean;
|
||||||
|
onDirectionsFrom: (lat: number, lon: number, label?: string) => void;
|
||||||
|
onDirectionsTo: (lat: number, lon: number, label?: string) => void;
|
||||||
|
onSavePoint: (label?: string) => void;
|
||||||
|
onDeleteSavedPoint: (id: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
feature,
|
||||||
|
savedPoint,
|
||||||
|
hasOrigin,
|
||||||
|
isOrigin,
|
||||||
|
onDirectionsFrom,
|
||||||
|
onDirectionsTo,
|
||||||
|
onSavePoint,
|
||||||
|
onDeleteSavedPoint,
|
||||||
|
onClose
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let label = $state('');
|
||||||
|
|
||||||
|
const isPoint = $derived(!!feature || !!savedPoint);
|
||||||
|
const title = $derived(feature?.name ?? savedPoint?.label ?? 'New location');
|
||||||
|
const rows = $derived(feature?.rows ?? []);
|
||||||
|
|
||||||
|
// Clamp the dialog within 40px of the map edges.
|
||||||
|
const LEFT = $derived(Math.min(Math.max(x, 8), window.innerWidth - 320));
|
||||||
|
const TOP = $derived(Math.min(Math.max(y, 8), window.innerHeight - 260));
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
onSavePoint(label.trim() ? label.trim() : undefined);
|
||||||
|
saving = false;
|
||||||
|
label = '';
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class="ctx-backdrop" aria-label="Close" onclick={() => onClose()}></button>
|
||||||
|
<div class="ctx" role="dialog" aria-label="Location" tabindex="-1" style="left:{LEFT}px; top:{TOP}px" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
||||||
|
<div class="ctx-arrow" style="left:{Math.min(Math.max(x - LEFT, 10), 268)}px"></div>
|
||||||
|
|
||||||
|
{#if isPoint}
|
||||||
|
<div class="ctx-title">{title}</div>
|
||||||
|
<div class="ctx-coords">{lat.toFixed(5)}, {lon.toFixed(5)}</div>
|
||||||
|
{#if rows.length}
|
||||||
|
<table class="ctx-rows">
|
||||||
|
<tbody>
|
||||||
|
{#each rows as [k, v] (k)}
|
||||||
|
<tr><th>{k}</th><td>{v}</td></tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
{#if savedPoint}
|
||||||
|
<div class="ctx-id">Saved point</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="ctx-actions">
|
||||||
|
<button type="button" class="ctx-btn primary" onclick={() => onDirectionsFrom(lat, lon, title)}>
|
||||||
|
Directions from here
|
||||||
|
</button>
|
||||||
|
{#if hasOrigin && !isOrigin}
|
||||||
|
<button type="button" class="ctx-btn" onclick={() => onDirectionsTo(lat, lon, title)}>
|
||||||
|
Directions to here
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if savedPoint}
|
||||||
|
<button type="button" class="ctx-btn danger" onclick={() => onDeleteSavedPoint(savedPoint.id)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="ctx-title">New location</div>
|
||||||
|
<div class="ctx-coords">{lat.toFixed(5)}, {lon.toFixed(5)}</div>
|
||||||
|
|
||||||
|
{#if saving}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="ctx-input"
|
||||||
|
placeholder="Label (optional)"
|
||||||
|
bind:value={label}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { saving = false; onClose(); } }}
|
||||||
|
/>
|
||||||
|
<div class="ctx-actions">
|
||||||
|
<button type="button" class="ctx-btn primary" onclick={() => save()}>Save point</button>
|
||||||
|
<button type="button" class="ctx-btn" onclick={() => { saving = false; onClose(); }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="ctx-note">This isn't a known point. Save it to use later.</div>
|
||||||
|
<div class="ctx-actions">
|
||||||
|
<button type="button" class="ctx-btn primary" onclick={() => (saving = true)}>Save point…</button>
|
||||||
|
<button type="button" class="ctx-btn" onclick={() => onDirectionsFrom(lat, lon)}>Directions from here</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ctx-backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: 2500; background: transparent; border: none; padding: 0; margin: 0; width: 100vw; height: 100vh; cursor: default;
|
||||||
|
}
|
||||||
|
.ctx {
|
||||||
|
position: absolute; z-index: 2600; width: 300px; background: #fff; color: #0f172a;
|
||||||
|
border-radius: 10px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3); padding: 12px 14px;
|
||||||
|
font: 0.8rem system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.ctx-arrow {
|
||||||
|
position: absolute; top: -8px; width: 16px; height: 16px; background: #fff;
|
||||||
|
transform: translateX(-50%) rotate(45deg); border-radius: 3px; box-shadow: -2px -2px 6px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
.ctx-title { font-weight: 700; font-size: 0.9rem; margin-bottom: 2px; }
|
||||||
|
.ctx-coords { color: #94a3b8; font-size: 0.72rem; margin-bottom: 8px; }
|
||||||
|
.ctx-id { color: #8b5cf6; font-size: 0.7rem; font-weight: 600; margin-bottom: 6px; }
|
||||||
|
.ctx-note { color: #64748b; font-size: 0.75rem; margin-bottom: 8px; }
|
||||||
|
.ctx-input { width: 100%; box-sizing: border-box; padding: 7px 9px; border-radius: 6px; border: 1px solid #cbd5e1; font-size: 0.82rem; margin-bottom: 8px; }
|
||||||
|
.ctx-rows { border-collapse: collapse; margin: 6px 0; width: 100%; font-size: 0.76rem; }
|
||||||
|
.ctx-rows th { text-align: left; padding: 2px 8px 2px 0; color: #64748b; font-weight: 600; vertical-align: top; }
|
||||||
|
.ctx-rows td { padding: 2px 0; color: #0f172a; }
|
||||||
|
.ctx-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||||
|
.ctx-btn {
|
||||||
|
padding: 6px 10px; border-radius: 6px; border: 1px solid #cbd5e1; background: #fff;
|
||||||
|
cursor: pointer; font-size: 0.76rem; color: #0f172a;
|
||||||
|
}
|
||||||
|
.ctx-btn:hover { background: #f1f5f9; }
|
||||||
|
.ctx-btn.primary { background: #0f172a; color: #fff; border-color: #0f172a; font-weight: 600; }
|
||||||
|
.ctx-btn.primary:hover { background: #1e293b; }
|
||||||
|
.ctx-btn.danger { color: #b91c1c; border-color: #fecaca; }
|
||||||
|
.ctx-btn.danger:hover { background: #fef2f2; }
|
||||||
|
</style>
|
||||||
@ -5,9 +5,10 @@
|
|||||||
import { search, type Searchable, type SearchResult } from '$lib/search';
|
import { search, type Searchable, type SearchResult } from '$lib/search';
|
||||||
import { geocode, type GeocodedPlace } from '$lib/geocode';
|
import { geocode, type GeocodedPlace } from '$lib/geocode';
|
||||||
import DirectionsPanel from '$lib/components/DirectionsPanel.svelte';
|
import DirectionsPanel from '$lib/components/DirectionsPanel.svelte';
|
||||||
|
import LocationDialog from '$lib/components/LocationDialog.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, exportSavedPoints, importSavedPoints, type SavedPoint } from '$lib/savedPoints';
|
import { savedPoints, addSavedPoint, removeSavedPoint, exportSavedPoints, importSavedPoints, type SavedPoint } from '$lib/savedPoints';
|
||||||
|
|
||||||
type FeatureCollection = {
|
type FeatureCollection = {
|
||||||
type: string;
|
type: string;
|
||||||
@ -63,6 +64,13 @@
|
|||||||
// 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;
|
||||||
|
let destination = $state<{ lat: number; lon: number; label?: string } | null>(null);
|
||||||
|
// --- context dialog (shown on map click; no auto-save) ---
|
||||||
|
let ctx = $state<{
|
||||||
|
x: number; y: number; lat: number; lon: number;
|
||||||
|
feature?: { name: string; rows: [string, string][] } | null;
|
||||||
|
savedPoint?: SavedPoint | null;
|
||||||
|
} | null>(null);
|
||||||
// --- Saved Points (browser-local) layer ---
|
// --- Saved Points (browser-local) layer ---
|
||||||
let savedPointsList = $state<SavedPoint[]>([]);
|
let savedPointsList = $state<SavedPoint[]>([]);
|
||||||
let savedPointLayer: L.LayerGroup | null = null;
|
let savedPointLayer: L.LayerGroup | null = null;
|
||||||
@ -262,6 +270,10 @@
|
|||||||
}).addTo(savedPointLayer);
|
}).addTo(savedPointLayer);
|
||||||
const nm = p.label || `${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}`;
|
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>`);
|
mk.bindPopup(`<strong>${nm.replace(/</g, '<')}</strong><br><em class="popup-desc">Saved point</em>`);
|
||||||
|
mk.on('click', (e: L.LeafletMouseEvent) => {
|
||||||
|
L.DomEvent.stopPropagation(e.originalEvent);
|
||||||
|
openContextAt(e.latlng, null);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Redraw saved points when the underlying list changes while visible.
|
// Redraw saved points when the underlying list changes while visible.
|
||||||
@ -308,10 +320,60 @@
|
|||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open the context dialog at a lat/lon. `feature` null => treat as a bare
|
||||||
|
// map spot (offer save). We also check whether this spot is a saved point.
|
||||||
|
function openContextAt(latlng: L.LatLng, featureInfo: { name: string; rows: [string, string][] } | null) {
|
||||||
|
const xy = map.latLngToContainerPoint(latlng);
|
||||||
|
const sp = findSavedPointNear(latlng.lat, latlng.lng);
|
||||||
|
ctx = {
|
||||||
|
x: xy.x, y: xy.y, lat: latlng.lat, lon: latlng.lng,
|
||||||
|
feature: featureInfo,
|
||||||
|
savedPoint: sp?.id ? sp : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Return a saved point within ~25m of the click, if any.
|
||||||
|
function findSavedPointNear(lat: number, lon: number): SavedPoint | null {
|
||||||
|
const dLat = 25 / 111320;
|
||||||
|
const dLon = 25 / (111320 * Math.cos((lat * Math.PI) / 180));
|
||||||
|
for (const p of savedPointsList) {
|
||||||
|
if (Math.abs(p.lat - lat) <= dLat && Math.abs(p.lon - lon) <= dLon) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function featureInfoFor(f: GeoJSON.Feature): { name: string; rows: [string, string][] } {
|
||||||
|
const props = (f.properties ?? {}) as Record<string, unknown>;
|
||||||
|
const name = (props.name as string) ?? String(f.id ?? 'Point');
|
||||||
|
const rows: [string, string][] = Object.entries(props)
|
||||||
|
.filter(([k]) => !k.startsWith('_') && k !== 'name')
|
||||||
|
.map(([k, v]) => [k, String(v)]);
|
||||||
|
return { name, rows };
|
||||||
|
}
|
||||||
|
function closeContext() { ctx = null; }
|
||||||
|
|
||||||
function setSavedPointAsOrigin(p: SavedPoint) {
|
function setSavedPointAsOrigin(p: SavedPoint) {
|
||||||
setOrigin(p.lat, p.lon, p.label || 'Saved point');
|
setOrigin(p.lat, p.lon, p.label || 'Saved point');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- context dialog actions ---
|
||||||
|
function ctxDirectionsFrom(lat: number, lon: number, label?: string) {
|
||||||
|
setOrigin(lat, lon, label || 'Start');
|
||||||
|
ctx = null;
|
||||||
|
// Ensure the destination picker opens if the road data layer is available.
|
||||||
|
if (dumpLayers.length) { showDirections = true; void loadRouteGraph(); }
|
||||||
|
}
|
||||||
|
function ctxDirectionsTo(lat: number, lon: number, label?: string) {
|
||||||
|
destination = { lat, lon, label };
|
||||||
|
ctx = null;
|
||||||
|
}
|
||||||
|
function ctxSavePoint(label?: string) {
|
||||||
|
const sp = addSavedPoint({ lat: ctx!.lat, lon: ctx!.lon, label });
|
||||||
|
ctx = null;
|
||||||
|
}
|
||||||
|
function ctxDeleteSavedPoint(id: string) {
|
||||||
|
removeSavedPoint(id);
|
||||||
|
ctx = null;
|
||||||
|
}
|
||||||
|
|
||||||
function toggle(name: string) {
|
function toggle(name: string) {
|
||||||
const g = layerGroups[name];
|
const g = layerGroups[name];
|
||||||
if (!g) return;
|
if (!g) return;
|
||||||
@ -376,13 +438,12 @@
|
|||||||
attribution: '© OpenStreetMap contributors'
|
attribution: '© OpenStreetMap contributors'
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
// Clicking an empty spot on the map creates a browser-local Saved Point
|
// Map click (empty space, since feature clicks stop propagation) always
|
||||||
// and sets it as the origin (start point). POI clicks are handled by the
|
// opens a context dialog — whether or not the Directions panel is open.
|
||||||
// individual feature popups, so a bare map click = empty space.
|
// This is how the user picks destinations and saves points. No auto-save.
|
||||||
map.on('click', (e: L.LeafletMouseEvent) => {
|
map.on('click', (e: L.LeafletMouseEvent) => {
|
||||||
if (showDirections) return; // direction dialog owns clicks while open
|
if (computingRoute) return;
|
||||||
const sp = addSavedPoint({ lat: e.latlng.lat, lon: e.latlng.lng });
|
openContextAt(e.latlng, null);
|
||||||
setOrigin(sp.lat, sp.lon, sp.label || 'Start');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -433,6 +494,12 @@
|
|||||||
totalFeatures++;
|
totalFeatures++;
|
||||||
const props = (f.properties ?? {}) as Record<string, unknown>;
|
const props = (f.properties ?? {}) as Record<string, unknown>;
|
||||||
lay.bindPopup(popupHtml(f));
|
lay.bindPopup(popupHtml(f));
|
||||||
|
// Clicking a feature opens the context dialog with its info and
|
||||||
|
// stops propagation so the map's empty-click handler doesn't fire.
|
||||||
|
lay.on('click', (e: L.LeafletMouseEvent) => {
|
||||||
|
L.DomEvent.stopPropagation(e.originalEvent);
|
||||||
|
openContextAt(e.latlng, featureInfoFor(f));
|
||||||
|
});
|
||||||
featureLayers[f.id as string] = lay;
|
featureLayers[f.id as string] = lay;
|
||||||
|
|
||||||
// Build a searchable record. A single malformed feature
|
// Build a searchable record. A single malformed feature
|
||||||
@ -542,11 +609,6 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<button class="dirbtn" onclick={() => (showPrefs = !showPrefs)} aria-haspopup="dialog">
|
||||||
⚙ Preferences
|
⚙ Preferences
|
||||||
</button>
|
</button>
|
||||||
@ -573,6 +635,24 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if ctx}
|
||||||
|
<LocationDialog
|
||||||
|
x={ctx.x}
|
||||||
|
y={ctx.y}
|
||||||
|
lat={ctx.lat}
|
||||||
|
lon={ctx.lon}
|
||||||
|
feature={ctx.feature}
|
||||||
|
savedPoint={ctx.savedPoint}
|
||||||
|
hasOrigin={!!origin}
|
||||||
|
isOrigin={!!origin && Math.abs(origin.lat - ctx.lat) < 1e-6 && Math.abs(origin.lon - ctx.lon) < 1e-6}
|
||||||
|
onDirectionsFrom={ctxDirectionsFrom}
|
||||||
|
onDirectionsTo={ctxDirectionsTo}
|
||||||
|
onSavePoint={ctxSavePoint}
|
||||||
|
onDeleteSavedPoint={ctxDeleteSavedPoint}
|
||||||
|
onClose={closeContext}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showDirections && origin}
|
{#if showDirections && origin}
|
||||||
<div class="dirs-wrap">
|
<div class="dirs-wrap">
|
||||||
<DirectionsPanel
|
<DirectionsPanel
|
||||||
@ -585,6 +665,8 @@
|
|||||||
searchables={searchables}
|
searchables={searchables}
|
||||||
savedPoints={savedPointsList}
|
savedPoints={savedPointsList}
|
||||||
onPickSavedPoint={(p) => setSavedPointAsOrigin(p)}
|
onPickSavedPoint={(p) => setSavedPointAsOrigin(p)}
|
||||||
|
destination={destination}
|
||||||
|
onDestinationChange={(d) => (destination = d)}
|
||||||
progress={routeProgress}
|
progress={routeProgress}
|
||||||
computing={computingRoute}
|
computing={computingRoute}
|
||||||
{runRoute}
|
{runRoute}
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalFeatures = $derived(areas.reduce((n, a) => n + a.featureCount, 0));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@ -27,14 +26,6 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<div class="controls">
|
|
||||||
<strong>({areas.length}) data sources</strong>
|
|
||||||
{#if totalFeatures > 0}
|
|
||||||
<span class="stats">{totalFeatures} features</span>
|
|
||||||
{/if}
|
|
||||||
<span class="hint">Toggle layers on the map (top-right).</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if manifestError}
|
{#if manifestError}
|
||||||
<div class="error">
|
<div class="error">
|
||||||
Could not load data manifest (<code>/data/_index.json</code>).<br />
|
Could not load data manifest (<code>/data/_index.json</code>).<br />
|
||||||
@ -58,15 +49,6 @@
|
|||||||
height: 100vh; height: 100dvh;
|
height: 100vh; height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.controls {
|
|
||||||
display: flex; align-items: center; gap: 1.25rem;
|
|
||||||
padding: 0.55rem 1.25rem;
|
|
||||||
background: #1e293b; color: #e2e8f0;
|
|
||||||
font: 0.85rem system-ui, sans-serif; z-index: 500;
|
|
||||||
}
|
|
||||||
.controls strong { color: #fff; }
|
|
||||||
.stats { color: #94a3b8; }
|
|
||||||
.hint { color: #64748b; margin-left: auto; }
|
|
||||||
.error {
|
.error {
|
||||||
padding: 3rem 2rem; font: 0.95rem/1.5 system-ui, sans-serif; color: #b91c1c;
|
padding: 3rem 2rem; font: 0.95rem/1.5 system-ui, sans-serif; color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user