Improve trail planning: option to re-order route anchors (#1007)
* trail anchor list added * add search location card to extend route endpoint in drawing mode * add POI popup endpoint action in route drawing mode * fix anchor stats, improve anchor list entries and refine route marker behavior * fix map marker spinner * docs updated * fix spinner when delete an anchor * further improvements * fix adding first anchor from POI * optimize drag handle area --------- Co-authored-by: Flomp <Flomp@users.noreply.github.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
value: any;
|
||||
icon?: string;
|
||||
separator?: boolean;
|
||||
danger?: boolean;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -138,6 +139,8 @@
|
||||
{:else}
|
||||
<li
|
||||
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||
class:hover:text-red-500={item.danger}
|
||||
class:focus:text-red-500={item.danger}
|
||||
role="presentation"
|
||||
onclick={(e) => handleItemClick(e, item as { text: string; value: any })}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { ClusterLayer } from "$lib/vendor/maplibre-layer-manager/cluster-layer";
|
||||
import { baseMapStyles } from "$lib/vendor/maplibre-layer-manager/layers";
|
||||
import { LayerManager } from "$lib/vendor/maplibre-layer-manager/maplibre-layer-manager";
|
||||
import type { OverpassPopupActionFactory } from "$lib/vendor/maplibre-layer-manager/overpass-layer";
|
||||
import { PreviewLayer } from "$lib/vendor/maplibre-layer-manager/preview-layer";
|
||||
import { TerrainLayer } from "$lib/vendor/maplibre-layer-manager/terrain-layer";
|
||||
import { TrailLayer } from "$lib/vendor/maplibre-layer-manager/trail-layer";
|
||||
@@ -69,6 +70,7 @@
|
||||
) => void;
|
||||
oninit?: (map: M.Map) => void;
|
||||
autoGeolocateOnDrawing?: boolean;
|
||||
buildPoiAnchorAction?: OverpassPopupActionFactory;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -100,6 +102,7 @@
|
||||
onUnclusteredClick,
|
||||
oninit,
|
||||
autoGeolocateOnDrawing = false,
|
||||
buildPoiAnchorAction = undefined,
|
||||
}: Props = $props();
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
@@ -809,7 +812,7 @@
|
||||
};
|
||||
map = new M.Map(finalMapOptions);
|
||||
|
||||
layerManager = new LayerManager(map);
|
||||
layerManager = new LayerManager(map, { overpassActionFactory: buildPoiAnchorAction });
|
||||
|
||||
elevationMarker = new FontawesomeMarker(
|
||||
{
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
onRecalculateElevationData: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
resetLabel?: string;
|
||||
resetAriaLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -35,6 +37,8 @@
|
||||
onRecalculateElevationData,
|
||||
onUndo,
|
||||
onRedo,
|
||||
resetLabel = "reset",
|
||||
resetAriaLabel = "reset-route",
|
||||
}: Props = $props();
|
||||
|
||||
const modesOfTransport: SelectItem[] = [
|
||||
@@ -145,6 +149,13 @@
|
||||
aria-label="recalculate elevation data"
|
||||
onclick={async () => await togglePanels(false, false, !recalculateElevationData)}><i class="fa fa-mountain text-sm"></i></button
|
||||
>
|
||||
<button
|
||||
class="btn-icon tooltip hover:text-red-500"
|
||||
type="button"
|
||||
onclick={() => onReset()}
|
||||
aria-label={$_(resetAriaLabel)}
|
||||
data-title={$_(resetLabel)}><i class="fa fa-trash text-sm"></i></button
|
||||
>
|
||||
<button
|
||||
class="btn-icon"
|
||||
class:text-gray-500={valhallaStore.undoStack.length == 0}
|
||||
@@ -182,13 +193,6 @@
|
||||
data-title={$_("reverse-direction")}
|
||||
><i class="fa fa-arrow-right-arrow-left"></i></button
|
||||
>
|
||||
<button
|
||||
class="btn-icon tooltip"
|
||||
type="button"
|
||||
onclick={() => onReset()}
|
||||
aria-label="Reset route"
|
||||
data-title={$_("reset")}><i class="fa fa-trash"></i></button
|
||||
>
|
||||
<button
|
||||
class="btn-icon tooltip"
|
||||
type="button"
|
||||
|
||||
702
web/src/lib/components/trail/trail_anchor_list.svelte
Normal file
702
web/src/lib/components/trail/trail_anchor_list.svelte
Normal file
@@ -0,0 +1,702 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import GpxMetricsComputation from "$lib/models/gpx/gpx-metrics-computation";
|
||||
import type TrackSegment from "$lib/models/gpx/track-segment";
|
||||
import type { ValhallaAnchor } from "$lib/models/valhalla";
|
||||
import {
|
||||
searchLocationReverseStructured,
|
||||
type ReverseLocationResult,
|
||||
} from "$lib/stores/search_store";
|
||||
import {
|
||||
formatDistance,
|
||||
formatElevation,
|
||||
} from "$lib/util/format_util";
|
||||
import { valhallaAnchorDisplay, valhallaAnchorTitle } from "$lib/util/valhalla_anchor_util";
|
||||
|
||||
interface Props {
|
||||
anchors: ValhallaAnchor[];
|
||||
segments?: TrackSegment[];
|
||||
disabled?: boolean;
|
||||
onMove: (fromIndex: number, toIndex: number) => void | Promise<void>;
|
||||
onDelete: (index: number) => void;
|
||||
onHover?: (index: number | null) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
anchors,
|
||||
segments = [],
|
||||
disabled = false,
|
||||
onMove,
|
||||
onDelete,
|
||||
onHover,
|
||||
}: Props = $props();
|
||||
|
||||
const anchorCoordinates = (anchor: ValhallaAnchor) =>
|
||||
`${anchor.lat.toFixed(4)}, ${anchor.lon.toFixed(4)}`;
|
||||
|
||||
function fallbackAnchorTitle(index: number) {
|
||||
return valhallaAnchorTitle(index, anchors.length, $_);
|
||||
}
|
||||
|
||||
const locationCache = new Map<string, ReverseLocationResult>();
|
||||
const pendingLocationRequests = new Set<string>();
|
||||
let locations = $state<Record<string, ReverseLocationResult>>({});
|
||||
let locationAbortController: AbortController | null = null;
|
||||
|
||||
const commonAnchorCountry = $derived.by(() => {
|
||||
const countries = anchors
|
||||
.map((anchor) => locations[locationCacheKey(anchor)]?.country)
|
||||
.filter((country): country is string => Boolean(country));
|
||||
|
||||
if (countries.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [country] = countries;
|
||||
return countries.every((nextCountry) => nextCountry === country) ? country : null;
|
||||
});
|
||||
|
||||
function locationCacheKey(anchor: ValhallaAnchor) {
|
||||
return `${anchor.lat.toFixed(5)},${anchor.lon.toFixed(5)}`;
|
||||
}
|
||||
|
||||
async function loadAnchorLocation(anchor: ValhallaAnchor, signal: AbortSignal) {
|
||||
const key = locationCacheKey(anchor);
|
||||
if (locations[key] || pendingLocationRequests.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = locationCache.get(key);
|
||||
if (cached) {
|
||||
locations = { ...locations, [key]: cached };
|
||||
return;
|
||||
}
|
||||
|
||||
pendingLocationRequests.add(key);
|
||||
try {
|
||||
const location = await searchLocationReverseStructured(anchor.lat, anchor.lon, {
|
||||
includeRoad: true,
|
||||
signal,
|
||||
});
|
||||
if (location) {
|
||||
locationCache.set(key, location);
|
||||
locations = { ...locations, [key]: location };
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
||||
console.error("Failed to resolve anchor location", error);
|
||||
}
|
||||
} finally {
|
||||
pendingLocationRequests.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnchorLocations(nextAnchors: ValhallaAnchor[]) {
|
||||
locationAbortController?.abort();
|
||||
const controller = new AbortController();
|
||||
locationAbortController = controller;
|
||||
|
||||
for (const anchor of nextAnchors) {
|
||||
if (controller.signal.aborted) return;
|
||||
await loadAnchorLocation(anchor, controller.signal);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void loadAnchorLocations(anchors);
|
||||
return () => locationAbortController?.abort();
|
||||
});
|
||||
|
||||
function anchorTitle(anchor: ValhallaAnchor, index: number) {
|
||||
const location = locations[locationCacheKey(anchor)];
|
||||
if (!location) {
|
||||
return fallbackAnchorTitle(index);
|
||||
}
|
||||
|
||||
return commonAnchorCountry && location.country === commonAnchorCountry
|
||||
? location.label
|
||||
: location.fullLabel;
|
||||
}
|
||||
|
||||
function updateOverflowingStats(element: HTMLElement) {
|
||||
requestAnimationFrame(() => {
|
||||
const stats = element.querySelectorAll<HTMLElement>(".stats-viewport");
|
||||
for (const stat of stats) {
|
||||
const content = stat.querySelector<HTMLElement>(".stats-content");
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overflow = Math.max(0, content.scrollWidth - stat.clientWidth);
|
||||
if (overflow <= 0) {
|
||||
stat.classList.remove("is-overflowing");
|
||||
stat.style.removeProperty("--stats-scroll-distance");
|
||||
stat.style.removeProperty("--stats-scroll-duration");
|
||||
continue;
|
||||
}
|
||||
|
||||
const duration = Math.min(5, Math.max(1.6, overflow / 28));
|
||||
stat.style.setProperty("--stats-scroll-distance", `-${overflow}px`);
|
||||
stat.style.setProperty("--stats-scroll-duration", `${duration}s`);
|
||||
stat.classList.add("is-overflowing");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleItemMouseEnter(e: MouseEvent, index: number) {
|
||||
onHover?.(index);
|
||||
const element = e.currentTarget as HTMLElement;
|
||||
const title = element.querySelector<HTMLElement>(".anchor-title-viewport");
|
||||
const text = element.querySelector<HTMLElement>(".anchor-title-text");
|
||||
if (!title || !text) {
|
||||
updateOverflowingStats(element);
|
||||
return;
|
||||
}
|
||||
|
||||
text.style.maxWidth = "none";
|
||||
text.style.overflow = "visible";
|
||||
text.style.textOverflow = "clip";
|
||||
|
||||
const overflow = Math.max(0, text.scrollWidth - title.clientWidth);
|
||||
|
||||
text.style.removeProperty("max-width");
|
||||
text.style.removeProperty("overflow");
|
||||
text.style.removeProperty("text-overflow");
|
||||
|
||||
if (overflow <= 0) {
|
||||
title.classList.remove("is-overflowing");
|
||||
title.style.removeProperty("--title-scroll-distance");
|
||||
title.style.removeProperty("--title-scroll-duration");
|
||||
updateOverflowingStats(element);
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = Math.min(7, Math.max(1.8, overflow / 24));
|
||||
|
||||
title.style.setProperty("--title-scroll-distance", `-${overflow}px`);
|
||||
title.style.setProperty("--title-scroll-duration", `${duration}s`);
|
||||
title.classList.add("is-overflowing");
|
||||
updateOverflowingStats(element);
|
||||
}
|
||||
|
||||
function clearItemHoverState(e: Event) {
|
||||
onHover?.(null);
|
||||
const element = e.currentTarget as HTMLElement;
|
||||
const stats = element.querySelectorAll<HTMLElement>(".stats-viewport");
|
||||
for (const stat of stats) {
|
||||
stat.classList.remove("is-overflowing");
|
||||
stat.style.removeProperty("--stats-scroll-distance");
|
||||
stat.style.removeProperty("--stats-scroll-duration");
|
||||
}
|
||||
|
||||
const title = element.querySelector<HTMLElement>(
|
||||
".anchor-title-viewport",
|
||||
);
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
title.classList.remove("is-overflowing");
|
||||
title.style.removeProperty("--title-scroll-distance");
|
||||
title.style.removeProperty("--title-scroll-duration");
|
||||
}
|
||||
|
||||
function handleItemMouseLeave(e: MouseEvent) {
|
||||
clearItemHoverState(e);
|
||||
}
|
||||
|
||||
function anchorIcon(index: number) {
|
||||
return valhallaAnchorDisplay(index, anchors.length).icon;
|
||||
}
|
||||
|
||||
interface SegmentMetrics {
|
||||
distance: number;
|
||||
elevationGain: number;
|
||||
elevationLoss: number;
|
||||
}
|
||||
|
||||
function snapshotMetrics(metrics: GpxMetricsComputation): SegmentMetrics {
|
||||
return {
|
||||
distance: metrics.totalDistance,
|
||||
elevationGain: metrics.totalElevationGainSmoothed,
|
||||
elevationLoss: metrics.totalElevationLossSmoothed,
|
||||
};
|
||||
}
|
||||
|
||||
function subtractMetrics(metrics: SegmentMetrics, previous: SegmentMetrics): SegmentMetrics {
|
||||
return {
|
||||
distance: metrics.distance - previous.distance,
|
||||
elevationGain: metrics.elevationGain - previous.elevationGain,
|
||||
elevationLoss: metrics.elevationLoss - previous.elevationLoss,
|
||||
};
|
||||
}
|
||||
|
||||
const routeMetrics = $derived.by(() => {
|
||||
const metrics = new GpxMetricsComputation(5, 5);
|
||||
const segmentMetrics: SegmentMetrics[] = [];
|
||||
const cumulativeMetrics: SegmentMetrics[] = [];
|
||||
let previous = snapshotMetrics(metrics);
|
||||
|
||||
for (const segment of segments) {
|
||||
const points = segment.trkpt ?? [];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
metrics.addAndFilter(points[i]);
|
||||
}
|
||||
|
||||
const cumulative = snapshotMetrics(metrics);
|
||||
cumulativeMetrics.push(cumulative);
|
||||
segmentMetrics.push(subtractMetrics(cumulative, previous));
|
||||
previous = cumulative;
|
||||
}
|
||||
|
||||
return {
|
||||
segmentMetrics,
|
||||
cumulativeMetrics,
|
||||
};
|
||||
});
|
||||
|
||||
const allSegmentMetrics = $derived(routeMetrics.segmentMetrics);
|
||||
const allCumulativeMetrics = $derived(routeMetrics.cumulativeMetrics);
|
||||
|
||||
function segmentMetrics(index: number) {
|
||||
if (index === 0) return null;
|
||||
return allSegmentMetrics[index - 1] ?? null;
|
||||
}
|
||||
|
||||
function cumulativeMetrics(index: number) {
|
||||
if (index === 0) return null;
|
||||
return allCumulativeMetrics[index - 1] ?? null;
|
||||
}
|
||||
|
||||
let listElement: HTMLOListElement;
|
||||
let hasVerticalOverflow = $state(false);
|
||||
let dragIndex = $state<number | null>(null);
|
||||
let insertBefore = $state<number | null>(null);
|
||||
let pointerId: number | null = null;
|
||||
|
||||
function updateListOverflow() {
|
||||
if (!listElement) {
|
||||
hasVerticalOverflow = false;
|
||||
return;
|
||||
}
|
||||
|
||||
hasVerticalOverflow = listElement.scrollHeight > listElement.clientHeight + 1;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
anchors.length;
|
||||
segments.length;
|
||||
void tick().then(updateListOverflow);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const element = listElement;
|
||||
if (!element || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateListOverflow);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
function isValidInsert(pos: number): boolean {
|
||||
return (
|
||||
dragIndex !== null &&
|
||||
insertBefore === pos &&
|
||||
pos !== dragIndex &&
|
||||
pos !== dragIndex + 1
|
||||
);
|
||||
}
|
||||
|
||||
function getInsertPosition(clientY: number): number {
|
||||
const items = Array.from(
|
||||
listElement.querySelectorAll<HTMLElement>("li[data-anchor-index]"),
|
||||
);
|
||||
|
||||
for (const item of items) {
|
||||
const itemIndex = Number(item.dataset.anchorIndex);
|
||||
const rect = item.getBoundingClientRect();
|
||||
if (clientY < rect.top + rect.height / 2) {
|
||||
return itemIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return anchors.length;
|
||||
}
|
||||
|
||||
function clearDragState() {
|
||||
dragIndex = null;
|
||||
insertBefore = null;
|
||||
pointerId = null;
|
||||
}
|
||||
|
||||
async function handleKeyDown(e: KeyboardEvent, index: number) {
|
||||
if (disabled) return;
|
||||
let toIndex: number | null = null;
|
||||
if (e.key === "ArrowUp" && index > 0) {
|
||||
e.preventDefault();
|
||||
toIndex = index - 1;
|
||||
} else if (e.key === "ArrowDown" && index < anchors.length - 1) {
|
||||
e.preventDefault();
|
||||
toIndex = index + 1;
|
||||
}
|
||||
if (toIndex === null) return;
|
||||
await onMove(index, toIndex);
|
||||
await tick();
|
||||
const handles = listElement.querySelectorAll<HTMLElement>("li[data-anchor-index] button.drag-handle");
|
||||
handles[toIndex]?.focus();
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent, index: number) {
|
||||
if (disabled || e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
dragIndex = index;
|
||||
insertBefore = index;
|
||||
pointerId = e.pointerId;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (pointerId !== e.pointerId || dragIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
insertBefore = getInsertPosition(e.clientY);
|
||||
}
|
||||
|
||||
function handlePointerUp(e: PointerEvent) {
|
||||
if (pointerId !== e.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (dragIndex !== null && insertBefore !== null) {
|
||||
const toIndex =
|
||||
dragIndex < insertBefore ? insertBefore - 1 : insertBefore;
|
||||
if (toIndex !== dragIndex) {
|
||||
onMove(dragIndex, toIndex);
|
||||
}
|
||||
}
|
||||
|
||||
clearDragState();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ol
|
||||
bind:this={listElement}
|
||||
class="anchor-list flex max-h-96 shrink-0 flex-col gap-2 overflow-y-auto py-2"
|
||||
class:has-scrollbar={hasVerticalOverflow}
|
||||
class:pr-3={hasVerticalOverflow}
|
||||
>
|
||||
{#each anchors as anchor, i (anchor.id)}
|
||||
{@const metrics = segmentMetrics(i)}
|
||||
{@const cumulative = cumulativeMetrics(i)}
|
||||
{@const showCumulative = i > 1 && cumulative != null}
|
||||
<li
|
||||
data-anchor-index={i}
|
||||
class="rounded-lg border border-input-border transition-colors hover:bg-secondary-hover"
|
||||
class:p-3={i !== 0}
|
||||
class:px-3={i === 0}
|
||||
class:pt-2={i === 0}
|
||||
class:pb-2={i === 0}
|
||||
class:has-cumulative={showCumulative}
|
||||
class:opacity-50={dragIndex === i}
|
||||
class:drop-above={isValidInsert(i)}
|
||||
class:drop-below={i === anchors.length - 1 && isValidInsert(anchors.length)}
|
||||
onmouseenter={(e) => handleItemMouseEnter(e, i)}
|
||||
onmouseleave={handleItemMouseLeave}
|
||||
onfocusin={(e) => {
|
||||
onHover?.(i);
|
||||
updateOverflowingStats(e.currentTarget as HTMLElement);
|
||||
}}
|
||||
onfocusout={clearItemHoverState}
|
||||
>
|
||||
<div class="grid grid-cols-[2rem_minmax(0,1fr)] gap-x-2 gap-y-1">
|
||||
<button
|
||||
class="drag-handle absolute inset-y-0 left-0 w-12 rounded-md p-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={$_("move-route-point")}
|
||||
aria-keyshortcuts="ArrowUp ArrowDown"
|
||||
onkeydown={(e) => handleKeyDown(e, i)}
|
||||
onpointerdown={(e) => handlePointerDown(e, i)}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
onpointercancel={clearDragState}
|
||||
onlostpointercapture={clearDragState}
|
||||
></button>
|
||||
|
||||
<span
|
||||
class="anchor-icon pointer-events-none relative col-start-1 row-start-1 flex h-8 w-8 -translate-x-0.5 items-center justify-center text-xl text-content"
|
||||
>
|
||||
<i class="fa {anchorIcon(i)}"></i>
|
||||
{#if i > 0 && i < anchors.length - 1}
|
||||
<span
|
||||
class="absolute -bottom-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-background px-1 text-[0.65rem] font-semibold leading-none text-gray-500"
|
||||
>
|
||||
{i}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<div class="anchor-title col-start-2 min-w-0">
|
||||
<div
|
||||
class="anchor-title-viewport"
|
||||
role="presentation"
|
||||
title={anchorTitle(anchor, i)}
|
||||
>
|
||||
<span class="anchor-title-text font-medium leading-5">
|
||||
{anchorTitle(anchor, i)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="truncate text-xs leading-4 text-gray-500">
|
||||
{anchorCoordinates(anchor)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if metrics}
|
||||
{#if showCumulative}
|
||||
<span
|
||||
class="cumulative-indicator pointer-events-none relative z-10 col-start-1 row-start-2 flex h-5 w-5 items-center justify-center justify-self-center rounded-full bg-gray-500 text-xs font-semibold leading-none text-background"
|
||||
title={$_("cumulative")}
|
||||
aria-label={$_("cumulative")}
|
||||
>
|
||||
Σ
|
||||
</span>
|
||||
{/if}
|
||||
<div class="col-start-2 row-start-2 min-w-0">
|
||||
<div
|
||||
class="segment-stats stats-viewport h-5 min-w-0 overflow-hidden text-sm leading-5 text-gray-500"
|
||||
>
|
||||
<span class="stats-content flex w-max items-center gap-x-3">
|
||||
<span class="flex shrink-0 items-center whitespace-nowrap">
|
||||
<i class="fa fa-left-right mr-1 w-4 text-center"></i>{formatDistance(metrics.distance, { compact: true })}
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center whitespace-nowrap">
|
||||
<i class="fa fa-arrow-trend-up mr-1 w-4 text-center"></i>{formatElevation(metrics.elevationGain)}
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center whitespace-nowrap">
|
||||
<i class="fa fa-arrow-trend-down mr-1 w-4 text-center"></i>{formatElevation(metrics.elevationLoss)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{#if showCumulative}
|
||||
<div
|
||||
class="cumulative-stats stats-viewport h-5 min-w-0 overflow-hidden text-sm leading-5 text-gray-500"
|
||||
>
|
||||
<span class="stats-content flex w-max items-center gap-x-3">
|
||||
<span class="flex shrink-0 items-center whitespace-nowrap">
|
||||
<i class="fa fa-left-right mr-1 w-4 text-center"></i>{formatDistance(cumulative.distance, { compact: true })}
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center whitespace-nowrap">
|
||||
<i class="fa fa-arrow-trend-up mr-1 w-4 text-center"></i>{formatElevation(cumulative.elevationGain)}
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center whitespace-nowrap">
|
||||
<i class="fa fa-arrow-trend-down mr-1 w-4 text-center"></i>{formatElevation(cumulative.elevationLoss)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="delete-button btn-icon text-xs text-gray-400 hover:text-red-500"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={$_("delete")}
|
||||
aria-label={$_("delete-route-point")}
|
||||
onclick={() => onDelete(i)}
|
||||
>
|
||||
<i class="fa fa-trash"></i>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<style>
|
||||
.anchor-list.has-scrollbar {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
}
|
||||
.delete-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
height: 1.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.anchor-title {
|
||||
--delete-button-space: 2rem;
|
||||
--title-end-space: var(--delete-button-space);
|
||||
}
|
||||
.anchor-title-viewport {
|
||||
width: calc(100% - var(--title-end-space));
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.anchor-title-text {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.drag-handle {
|
||||
background-image:
|
||||
radial-gradient(
|
||||
circle at 0.25rem 0.4375rem,
|
||||
rgba(var(--content), 0.5) 1.35px,
|
||||
transparent 1.65px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 0.75rem 0.125rem,
|
||||
rgba(var(--content), 0.38) 1.35px,
|
||||
transparent 1.65px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 1.25rem 0.4375rem,
|
||||
rgba(var(--content), 0.27) 1.35px,
|
||||
transparent 1.65px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 1.75rem 0.125rem,
|
||||
rgba(var(--content), 0.17) 1.35px,
|
||||
transparent 1.65px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 2.25rem 0.4375rem,
|
||||
rgba(var(--content), 0.1) 1.35px,
|
||||
transparent 1.65px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 2.75rem 0.125rem,
|
||||
rgba(var(--content), 0.05) 1.35px,
|
||||
transparent 1.65px
|
||||
);
|
||||
background-repeat: repeat-y;
|
||||
background-size: 3rem 0.75rem;
|
||||
cursor: grab;
|
||||
opacity: 0.82;
|
||||
touch-action: none;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.cumulative-indicator,
|
||||
.cumulative-stats {
|
||||
display: none;
|
||||
}
|
||||
li.drop-above::before,
|
||||
li.drop-below::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(var(--content));
|
||||
}
|
||||
li.drop-above::before {
|
||||
top: -6px;
|
||||
}
|
||||
li.drop-below::after {
|
||||
bottom: -6px;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.anchor-title {
|
||||
--title-end-space: 0rem;
|
||||
}
|
||||
|
||||
li:hover .anchor-title,
|
||||
li:focus-within .anchor-title {
|
||||
--title-end-space: var(--delete-button-space);
|
||||
}
|
||||
|
||||
:global(.anchor-title-viewport.is-overflowing) .anchor-title-text {
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
animation: anchor-title-marquee var(--title-scroll-duration, 3s)
|
||||
ease-in-out 0.15s infinite alternate;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
li:hover .delete-button,
|
||||
li:focus-within .delete-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
li:hover .drag-handle,
|
||||
li:focus-within .drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
li.has-cumulative:hover .segment-stats,
|
||||
li.has-cumulative:focus-within .segment-stats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
li.has-cumulative:hover .cumulative-indicator,
|
||||
li.has-cumulative:focus-within .cumulative-indicator,
|
||||
li.has-cumulative:hover .cumulative-stats,
|
||||
li.has-cumulative:focus-within .cumulative-stats {
|
||||
display: block;
|
||||
}
|
||||
|
||||
li.has-cumulative:hover .cumulative-indicator,
|
||||
li.has-cumulative:focus-within .cumulative-indicator {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
:global(.stats-viewport.is-overflowing) .stats-content {
|
||||
animation: anchor-stats-marquee var(--stats-scroll-duration, 2.5s)
|
||||
ease-in-out 0.15s infinite alternate;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes anchor-title-marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(var(--title-scroll-distance, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes anchor-stats-marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(var(--stats-scroll-distance, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(.anchor-title-viewport.is-overflowing) .anchor-title-text,
|
||||
:global(.stats-viewport.is-overflowing) .stats-content {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -289,6 +289,7 @@
|
||||
text: $_("delete"),
|
||||
value: "delete",
|
||||
icon: "trash",
|
||||
danger: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -413,6 +414,7 @@
|
||||
text: $_("delete"),
|
||||
value: "delete",
|
||||
icon: "trash",
|
||||
danger: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Tabs from "$lib/components/base/tabs.svelte";
|
||||
import TrailDropdown, { type MergeResult } from "$lib/components/trail/trail_dropdown.svelte";
|
||||
import { Comment } from "$lib/models/comment";
|
||||
import { Tag } from "$lib/models/tag";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
|
||||
import {
|
||||
@@ -57,7 +58,12 @@
|
||||
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
|
||||
import LikeButton from "./like_button.svelte";
|
||||
import Editor from "../base/editor.svelte";
|
||||
import { trails_update } from "$lib/stores/trail_store";
|
||||
import {
|
||||
trails_update,
|
||||
trails_update_metadata,
|
||||
} from "$lib/stores/trail_store";
|
||||
import Combobox, { type ComboboxItem } from "../base/combobox.svelte";
|
||||
import { tags_index } from "$lib/stores/tag_store";
|
||||
|
||||
interface Props {
|
||||
initTrail: Trail;
|
||||
@@ -87,15 +93,16 @@
|
||||
...($currentUser ? [$_("comment", { values: { n: 2 } })] : []),
|
||||
];
|
||||
|
||||
const trailIsShared =
|
||||
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0;
|
||||
const trailIsShared = $derived(
|
||||
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
let gallery: PhotoGallery;
|
||||
|
||||
let newComment: Comment = $state({
|
||||
text: "",
|
||||
author: "",
|
||||
trail: untrack(() => handle) + "/" + (trail.id ?? ""),
|
||||
trail: untrack(() => `${handle}/${trail.id ?? ""}`),
|
||||
});
|
||||
|
||||
let commentsLoading: boolean = $state(untrack(() => activeTab == 2));
|
||||
@@ -106,6 +113,26 @@
|
||||
let summitLogCreateLoading: boolean = $state(false);
|
||||
|
||||
let fullDescription: boolean = $state(false);
|
||||
let metadataSaving: boolean = $state(false);
|
||||
let editingName: boolean = $state(false);
|
||||
let editingDescription: boolean = $state(false);
|
||||
let editingTags: boolean = $state(false);
|
||||
let nameDraft: string = $state("");
|
||||
let descriptionDraft: string = $state("");
|
||||
let tagDraftItems: ComboboxItem[] = $state([]);
|
||||
let tagItems: ComboboxItem[] = $state([]);
|
||||
|
||||
const canEditTrail = $derived(
|
||||
Boolean(
|
||||
$currentUser &&
|
||||
(trail.author === $currentUser.actor ||
|
||||
trail.expand?.trail_share_via_trail?.some(
|
||||
(share) =>
|
||||
share.permission === "edit" &&
|
||||
share.actor === $currentUser.actor,
|
||||
)),
|
||||
),
|
||||
);
|
||||
|
||||
onMount(async () => {});
|
||||
|
||||
@@ -289,6 +316,133 @@
|
||||
const updatedTrail: Trail = { ...trail };
|
||||
await trails_update(trail, updatedTrail);
|
||||
}
|
||||
|
||||
function cloneTrail(value: Trail): Trail {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function mergeTrailUpdate(previousTrail: Trail, updatedTrail: Trail): Trail {
|
||||
return {
|
||||
...previousTrail,
|
||||
...updatedTrail,
|
||||
expand: {
|
||||
...previousTrail.expand,
|
||||
...updatedTrail.expand,
|
||||
author: previousTrail.expand?.author,
|
||||
trail_like_via_trail:
|
||||
previousTrail.expand?.trail_like_via_trail,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function saveTrailMetadata(update: (nextTrail: Trail) => void) {
|
||||
if (!canEditTrail || metadataSaving) {
|
||||
return false;
|
||||
}
|
||||
|
||||
metadataSaving = true;
|
||||
const oldTrail = cloneTrail(trail);
|
||||
const nextTrail = cloneTrail(trail);
|
||||
nextTrail.expand ??= {};
|
||||
nextTrail.tags = [...(trail.tags ?? [])];
|
||||
update(nextTrail);
|
||||
const tagsChanged =
|
||||
JSON.stringify(nextTrail.expand?.tags ?? []) !==
|
||||
JSON.stringify(oldTrail.expand?.tags ?? []);
|
||||
|
||||
try {
|
||||
const updatedTrail = await trails_update_metadata(oldTrail, {
|
||||
name:
|
||||
nextTrail.name !== oldTrail.name
|
||||
? nextTrail.name
|
||||
: undefined,
|
||||
description:
|
||||
nextTrail.description !== oldTrail.description
|
||||
? nextTrail.description
|
||||
: undefined,
|
||||
expand: tagsChanged ? { tags: nextTrail.expand?.tags } : undefined,
|
||||
});
|
||||
trail = mergeTrailUpdate(trail, updatedTrail);
|
||||
show_toast({
|
||||
icon: "check",
|
||||
type: "success",
|
||||
text: $_("trail-saved-successfully"),
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
show_toast({
|
||||
icon: "close",
|
||||
type: "error",
|
||||
text: $_("error-saving-trail"),
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
metadataSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startNameEdit() {
|
||||
nameDraft = trail.name;
|
||||
editingName = true;
|
||||
}
|
||||
|
||||
async function saveNameEdit() {
|
||||
const name = nameDraft.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const saved = await saveTrailMetadata((nextTrail) => {
|
||||
nextTrail.name = name;
|
||||
});
|
||||
editingName = !saved;
|
||||
}
|
||||
|
||||
function startDescriptionEdit() {
|
||||
descriptionDraft = trail.description ?? "";
|
||||
editingDescription = true;
|
||||
}
|
||||
|
||||
async function saveDescriptionEdit() {
|
||||
const saved = await saveTrailMetadata((nextTrail) => {
|
||||
nextTrail.description = descriptionDraft;
|
||||
});
|
||||
editingDescription = !saved;
|
||||
if (saved) {
|
||||
fullDescription = true;
|
||||
}
|
||||
}
|
||||
|
||||
function getTrailTagItems() {
|
||||
return (
|
||||
trail.expand?.tags?.map((tag) => ({
|
||||
text: tag.name,
|
||||
value: tag,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function startTagsEdit() {
|
||||
tagDraftItems = getTrailTagItems();
|
||||
editingTags = true;
|
||||
}
|
||||
|
||||
async function searchTags(q: string) {
|
||||
const result = await tags_index(q);
|
||||
tagItems = result.items.map((tag) => ({
|
||||
text: tag.name,
|
||||
value: tag,
|
||||
}));
|
||||
}
|
||||
|
||||
async function saveTagsEdit() {
|
||||
const saved = await saveTrailMetadata((nextTrail) => {
|
||||
nextTrail.expand!.tags = tagDraftItems.map((item) =>
|
||||
item.value ? item.value : new Tag(item.text),
|
||||
);
|
||||
});
|
||||
editingTags = !saved;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -348,12 +502,57 @@
|
||||
</section>
|
||||
<section class="border-b border-input-border p-8">
|
||||
<div class="flex justify-between items-center gap-x-4">
|
||||
{#if trail.expand?.tags && trail.expand.tags.length > 0}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if editingTags}
|
||||
<div class="flex-1">
|
||||
<Combobox
|
||||
bind:value={tagDraftItems}
|
||||
onupdate={searchTags}
|
||||
items={tagItems}
|
||||
placeholder={`${$_("tags")}...`}
|
||||
multiple
|
||||
chips
|
||||
></Combobox>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
type="button"
|
||||
disabled={metadataSaving}
|
||||
onclick={() => (editingTags = false)}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<Button
|
||||
primary
|
||||
type="button"
|
||||
loading={metadataSaving}
|
||||
onclick={saveTagsEdit}>{$_("save")}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else if trail.expand?.tags && trail.expand.tags.length > 0}
|
||||
<div class="group flex flex-wrap items-center gap-2">
|
||||
{#each trail.expand.tags as tag}
|
||||
<Chip text={tag.name} primary={false}></Chip>
|
||||
{/each}
|
||||
{#if canEditTrail}
|
||||
<button
|
||||
class="btn-icon tooltip opacity-0 hover:opacity-100 focus:opacity-100 group-hover:opacity-100 transition-opacity"
|
||||
type="button"
|
||||
aria-label={$_("tags")}
|
||||
data-title={$_("tags")}
|
||||
onclick={startTagsEdit}
|
||||
><i class="fa fa-pen text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if canEditTrail}
|
||||
<button
|
||||
class="btn-icon tooltip opacity-0 hover:opacity-100 focus:opacity-100 group-hover:opacity-100 transition-opacity"
|
||||
type="button"
|
||||
aria-label={$_("tags")}
|
||||
data-title={$_("tags")}
|
||||
onclick={startTagsEdit}
|
||||
><i class="fa fa-tags text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
{#if (trail.public || trailIsShared) && $currentUser}
|
||||
<div
|
||||
@@ -378,15 +577,63 @@
|
||||
</div>
|
||||
<div class="flex justify-between items-end w-full gap-y-4">
|
||||
<div class=" overflow-hidden">
|
||||
<h4
|
||||
title={trail.name}
|
||||
class="{mode == 'map'
|
||||
? 'text-4xl'
|
||||
: 'text-5xl'} font-bold line-clamp-3 mb-1 wrap-anywhere"
|
||||
style="line-height: 1.18"
|
||||
>
|
||||
{trail.name}
|
||||
</h4>
|
||||
{#if editingName}
|
||||
<div class="flex flex-col gap-3 mb-3">
|
||||
<input
|
||||
class="{mode == 'map'
|
||||
? 'text-4xl'
|
||||
: 'text-5xl'} font-bold bg-input-background border border-input-border rounded-md px-3 py-2 focus:outline-none focus:border-input-border-focus"
|
||||
bind:value={nameDraft}
|
||||
disabled={metadataSaving}
|
||||
aria-label={$_("name")}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
void saveNameEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
editingName = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
type="button"
|
||||
disabled={metadataSaving}
|
||||
onclick={() => (editingName = false)}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<Button
|
||||
primary
|
||||
type="button"
|
||||
loading={metadataSaving}
|
||||
disabled={!nameDraft.trim()}
|
||||
onclick={saveNameEdit}>{$_("save")}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="group flex items-end gap-2">
|
||||
<h4
|
||||
title={trail.name}
|
||||
class="{mode == 'map'
|
||||
? 'text-4xl'
|
||||
: 'text-5xl'} font-bold line-clamp-3 mb-1 wrap-anywhere"
|
||||
style="line-height: 1.18"
|
||||
>
|
||||
{trail.name}
|
||||
</h4>
|
||||
{#if canEditTrail}
|
||||
<button
|
||||
class="btn-icon tooltip shrink-0 mb-2 opacity-0 hover:opacity-100 focus:opacity-100 group-hover:opacity-100 transition-opacity"
|
||||
type="button"
|
||||
aria-label={$_("name")}
|
||||
data-title={$_("name")}
|
||||
onclick={startNameEdit}
|
||||
><i class="fa fa-pen text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if trail.date}
|
||||
<h5 class="text-sm text-gray-500">
|
||||
{new Date(trail.date).toLocaleDateString(
|
||||
@@ -514,10 +761,44 @@
|
||||
class:xl:grid-cols-[1fr_18rem]={mode == "overview"}
|
||||
>
|
||||
<div class="order-1 xl:-order-1">
|
||||
<h4 class="text-2xl font-semibold my-4">
|
||||
{$_("description")}
|
||||
</h4>
|
||||
{#if trail.description?.length}
|
||||
<div class="group flex items-center gap-2 my-4">
|
||||
<h4 class="text-2xl font-semibold">
|
||||
{$_("description")}
|
||||
</h4>
|
||||
{#if canEditTrail && !editingDescription}
|
||||
<button
|
||||
class="btn-icon tooltip opacity-0 hover:opacity-100 focus:opacity-100 group-hover:opacity-100 transition-opacity"
|
||||
type="button"
|
||||
aria-label={$_("description")}
|
||||
data-title={$_("description")}
|
||||
onclick={startDescriptionEdit}
|
||||
><i class="fa fa-pen text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if editingDescription}
|
||||
<div class="mb-6">
|
||||
<Editor
|
||||
extraClasses="min-h-24"
|
||||
bind:value={descriptionDraft}
|
||||
></Editor>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
type="button"
|
||||
disabled={metadataSaving}
|
||||
onclick={() => (editingDescription = false)}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<Button
|
||||
primary
|
||||
type="button"
|
||||
loading={metadataSaving}
|
||||
onclick={saveDescriptionEdit}>{$_("save")}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else if trail.description?.length}
|
||||
<article
|
||||
class="text-justify whitespace-pre-line text-sm prose dark:prose-invert"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user