All checks were successful
CI / test-and-build (push) Successful in 44s
164 lines
6.2 KiB
Svelte
164 lines
6.2 KiB
Svelte
<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 ?? []);
|
||
|
||
// Position the dialog so its 16x16 arrow points at the click.
|
||
// Shift 8px left (arrow center lands on the click X via the 50% translate)
|
||
// and 16px down (bubble body sits under the arrow tip at the click Y).
|
||
const ARROW_X = 8;
|
||
const ARROW_Y = 16;
|
||
const LEFT = $derived(Math.min(Math.max(x - ARROW_X, 8), window.innerWidth - 320));
|
||
const TOP = $derived(Math.min(Math.max(y + ARROW_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>
|
||
<button class="ctx-x" aria-label="Close" onclick={() => onClose()}>×</button>
|
||
|
||
{#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: fixed; 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-x {
|
||
position: absolute; top: 6px; right: 8px; width: 24px; height: 24px;
|
||
border: none; background: none; cursor: pointer; font-size: 18px; line-height: 1;
|
||
color: #64748b; border-radius: 6px; text-align: center; padding: 0;
|
||
}
|
||
.ctx-x:hover { background: #f1f5f9; color: #0f172a; }
|
||
.ctx-title { font-weight: 700; font-size: 0.9rem; margin-bottom: 2px; padding-right: 20px; }
|
||
.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> |