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:
hermes-explorigin 2026-08-10 15:52:34 +00:00
parent 2e0269b865
commit 80d049eebf
4 changed files with 265 additions and 67 deletions

View File

@ -1,6 +1,5 @@
<script lang="ts">
import type { Map as LeafletMap } from 'leaflet';
import { onMount } from 'svelte';
import type { RouteGraph, AvoidRule, RouteResult, TurnStep } from '$lib/routing';
import type { Searchable, SearchResult } from '$lib/search';
import { search } from '$lib/search';
@ -36,6 +35,10 @@
savedPoints?: SavedPoint[];
/** Called when the user picks a saved point as a destination. */
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 {
@ -50,14 +53,12 @@
computing = false,
runRoute,
savedPoints = [],
onPickSavedPoint
onPickSavedPoint,
destination = null,
onDestinationChange
}: Props = $props();
// --- 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 ---
let destQuery = $state('');
@ -118,31 +119,13 @@
return min ? `${h} h ${min} min` : `${h} h`;
}
// --- destination by map click ---
function startPickingDest() {
pickingDest = true;
destHint = 'Click the map to choose the destination…';
}
function stopPickingDest() {
pickingDest = false;
destHint = '';
}
// --- destination (set via the context dialog or search/picked below) ---
function setDestination(lat: number, lon: number, label?: string) {
destination = { lat, lon, label };
pickingDest = false;
destHint = '';
onDestinationChange({ lat, lon, label });
result = null;
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 ---
async function inputDestSearch() {
destResultQuery = destQuery;
@ -258,13 +241,10 @@
<!-- Destination -->
{#if destination}
<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}
<div class="dest-pick">
<button class="pick" onclick={() => (pickingDest ? stopPickingDest() : startPickingDest())}>
{pickingDest ? 'Cancel map pick' : 'Pick destination on map'}
</button>
{#if destHint}<div class="hint">{destHint}</div>{/if}
<div class="dest-note">Click a point on the map, or search below.</div>
<div class="dest-search">
<input
type="search"
@ -296,7 +276,7 @@
<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' }; }}>
<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)}`}
</button>
{/each}
@ -381,11 +361,11 @@
.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.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;
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; }
.go { background: #0f172a; color: #fff; border-color: #0f172a; font-weight: 600; }
.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:hover { background: #f1f5f9; }
.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; }
.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; }

View 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>

View File

@ -5,9 +5,10 @@
import { search, type Searchable, type SearchResult } from '$lib/search';
import { geocode, type GeocodedPlace } from '$lib/geocode';
import DirectionsPanel from '$lib/components/DirectionsPanel.svelte';
import LocationDialog from '$lib/components/LocationDialog.svelte';
import { prefs } from '$lib/prefs';
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: string;
@ -63,6 +64,13 @@
// 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;
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 ---
let savedPointsList = $state<SavedPoint[]>([]);
let savedPointLayer: L.LayerGroup | null = null;
@ -262,6 +270,10 @@
}).addTo(savedPointLayer);
const nm = p.label || `${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}`;
mk.bindPopup(`<strong>${nm.replace(/</g, '&lt;')}</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.
@ -308,10 +320,60 @@
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) {
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) {
const g = layerGroups[name];
if (!g) return;
@ -376,13 +438,12 @@
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);
// 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 click (empty space, since feature clicks stop propagation) always
// opens a context dialog — whether or not the Directions panel is open.
// This is how the user picks destinations and saves points. No auto-save.
map.on('click', (e: L.LeafletMouseEvent) => {
if (showDirections) return; // direction dialog owns clicks while open
const sp = addSavedPoint({ lat: e.latlng.lat, lon: e.latlng.lng });
setOrigin(sp.lat, sp.lon, sp.label || 'Start');
if (computingRoute) return;
openContextAt(e.latlng, null);
});
try {
@ -433,6 +494,12 @@
totalFeatures++;
const props = (f.properties ?? {}) as Record<string, unknown>;
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;
// Build a searchable record. A single malformed feature
@ -542,11 +609,6 @@
</ul>
{/if}
</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">
⚙ Preferences
</button>
@ -573,6 +635,24 @@
</label>
</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}
<div class="dirs-wrap">
<DirectionsPanel
@ -585,6 +665,8 @@
searchables={searchables}
savedPoints={savedPointsList}
onPickSavedPoint={(p) => setSavedPointAsOrigin(p)}
destination={destination}
onDestinationChange={(d) => (destination = d)}
progress={routeProgress}
computing={computingRoute}
{runRoute}

View File

@ -19,7 +19,6 @@
loading = false;
});
const totalFeatures = $derived(areas.reduce((n, a) => n + a.featureCount, 0));
</script>
<svelte:head>
@ -27,14 +26,6 @@
</svelte:head>
<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}
<div class="error">
Could not load data manifest (<code>/data/_index.json</code>).<br />
@ -58,15 +49,6 @@
height: 100vh; height: 100dvh;
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 {
padding: 3rem 2rem; font: 0.95rem/1.5 system-ui, sans-serif; color: #b91c1c;
}