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:
slothful-vassal
2026-06-06 11:56:12 +02:00
committed by GitHub
parent 14ebaa0d04
commit 44cd092065
32 changed files with 1792 additions and 192 deletions

View File

@@ -37,6 +37,9 @@ Click the **Draw a route** button to manually define a route on the map. While i
- Click on the map to place waypoints
- <span class="-tracking-[0.075em]">wanderer</span> will automatically route between points using the [Valhalla routing engine](https://github.com/valhalla/valhalla)
- You can drag points to reposition them
- The anchor list next to the map shows start, intermediate, and finish points with segment distance and elevation stats
- Hover an item in the anchor list to highlight its marker on the map
- Reorder intermediate anchors from the list to adjust the route sequence
- Use the top-left menu to change routing mode (e.g. walking, cycling)
- To remove a point, click on it and then click the red trash icon
@@ -105,4 +108,3 @@ To learn more about summit logs visit the [dedicated section](/use/summit-logs)
## Step 6: Save the trail
When you're done, click <button class="h-10 text-white rounded-lg px-4 py-2 mx-2 bg-primary font-semibold transition-all hover:bg-primary-hover focus:ring-4 ring-zinc-400 leading-none">Save Trail</button> to persist your trail to the database. This will also re-index it for search and display it in your trail list.

View File

@@ -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 })}
>

View File

@@ -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(
{

View File

@@ -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"

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

View File

@@ -289,6 +289,7 @@
text: $_("delete"),
value: "delete",
icon: "trash",
danger: true,
},
]
: []),
@@ -413,6 +414,7 @@
text: $_("delete"),
value: "delete",
icon: "trash",
danger: true,
},
]
: []),

View File

@@ -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,6 +577,42 @@
</div>
<div class="flex justify-between items-end w-full gap-y-4">
<div class=" overflow-hidden">
{#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'
@@ -387,6 +622,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">
<div class="group flex items-center gap-2 my-4">
<h4 class="text-2xl font-semibold">
{$_("description")}
</h4>
{#if trail.description?.length}
{#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"
>

View File

@@ -373,6 +373,7 @@
"road": "Silnice",
"route": "{n, plural, =1 {Trasa} few {Trasy} other {Tras}}",
"route-point": "Bod trasy",
"add-as-endpoint": "Add as endpoint",
"russian": "Ruština",
"save": "Uložit",
"save-list": "Uložit seznam",

View File

@@ -102,6 +102,7 @@
"creation-date": "Erstellungsdatum",
"crop": "Zuschneiden",
"cross": "Querfeldein",
"cumulative": "Kumulativ",
"current-password": "Aktuelles Passwort",
"cycling": "Radfahren",
"cycling-speed": "Radfahrgeschwindigkeit",
@@ -116,6 +117,7 @@
"delete-linked-trails": "Verknüpfte Routen löschen",
"delete-list-confirm": "Möchtest Du diese Liste wirklich löschen? Die Routen in der Liste sind danach weiterhin verfügbar.",
"delete-summit-log-confirm": "Möchtest du diesen Gipfelbuch-Eintrag wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"delete-route-point": "Routenpunkt löschen",
"delete-trail-confirm": "Möchtest Du diese Route wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"describe-your-trail": "Beschreibe deine Route",
"description": "Beschreibung",
@@ -292,6 +294,7 @@
"moderate": "Mittel",
"more": "weitere",
"more-route-settings": "Weitere Routen-Einstellungen",
"move-route-point": "Routenpunkt verschieben",
"mountain": "Berg",
"mountain-pass": "Bergpass",
"must-be-at-least-n-characters-long": "Muss mindestens {n} Zeichen lang sein",
@@ -391,10 +394,13 @@
"required": "Pflichtfeld",
"reset": "Zurücksetzen",
"reset-password": "Passwort zurücksetzen",
"reset-route": "Route zurücksetzen",
"reverse-direction": "Richtung umkehren",
"road": "Straße",
"route": "{n, plural, =1 {Route} other {Routen}}",
"route-point": "Punkt auf Route",
"reset-route-confirm": "Die aktuelle Route wird entfernt. Trail-Details, Fotos, Listen und andere Metadaten bleiben erhalten.",
"add-as-endpoint": "Als Endpunkt hinzufügen",
"russian": "Russisch",
"save": "Speichern",
"save-list": "Liste speichern",

View File

@@ -102,6 +102,7 @@
"creation-date": "Creation date",
"crop": "Crop",
"cross": "Cross",
"cumulative": "Cumulative",
"current-password": "Current password",
"cycling": "Cycling",
"cycling-speed": "Cycling Speed",
@@ -116,6 +117,7 @@
"delete-linked-trails": "Delete linked trails",
"delete-list-confirm": "Do you really want to delete this list? The trails in the list will still be available.",
"delete-summit-log-confirm": "Do you really want to delete this summit log? This action cannot be undone.",
"delete-route-point": "Delete route point",
"delete-trail-confirm": "Do you really want to delete this trail? This action cannot be undone.",
"describe-your-trail": "Describe your trail",
"description": "Description",
@@ -292,6 +294,7 @@
"moderate": "Moderate",
"more": "More",
"more-route-settings": "More route settings",
"move-route-point": "Move route point",
"mountain": "Mountain",
"mountain-pass": "Mountain pass",
"must-be-at-least-n-characters-long": "Must be at least {n} characters long",
@@ -391,10 +394,13 @@
"required": "Required",
"reset": "Reset",
"reset-password": "Reset Password",
"reset-route": "Reset route",
"reverse-direction": "Reverse direction",
"road": "Road",
"route": "{n, plural, =1 {Route} other {Routes}}",
"route-point": "Route Point",
"reset-route-confirm": "The current route will be removed. Trail details, photos, lists and other metadata will be kept.",
"add-as-endpoint": "Add as endpoint",
"russian": "Russian",
"save": "Save",
"save-list": "Save List",

View File

@@ -373,6 +373,7 @@
"road": "Carretera",
"route": "{n, plural, one {}=1 {Ruta} other {Rutas}}",
"route-point": "Punto de ruta",
"add-as-endpoint": "Add as endpoint",
"russian": "Ruso",
"save": "Guardar",
"save-list": "Guardar Lista",

View File

@@ -373,6 +373,7 @@
"road": "Errepidea",
"route": "{n, plural, one {}=1 {ibilbide} other {ibilbide}}",
"route-point": "Ibilbideko puntua",
"add-as-endpoint": "Add as endpoint",
"russian": "Errusiera",
"save": "Gorde",
"save-list": "Gorde zerrenda",

View File

@@ -373,6 +373,7 @@
"road": "Route",
"route": "{n, plural, =1 {Itinéraire} other {Itinéraires}}",
"route-point": "Étape",
"add-as-endpoint": "Add as endpoint",
"russian": "Russe",
"save": "Sauvegarder",
"save-list": "Sauvegarder la liste",

View File

@@ -373,6 +373,7 @@
"road": "Road",
"route": "{n, plural, =1 {Route} other {Routes}}",
"route-point": "Route Point",
"add-as-endpoint": "Add as endpoint",
"russian": "Russian",
"save": "Mentés",
"save-list": "Save List",

View File

@@ -373,6 +373,7 @@
"road": "Road",
"route": "{n, plural, =1 {Route} other {Routes}}",
"route-point": "Route Point",
"add-as-endpoint": "Add as endpoint",
"russian": "Russian",
"save": "Salva",
"save-list": "Salta Lista",

View File

@@ -373,6 +373,7 @@
"road": "Weg",
"route": "{n, plural,=1 {Tocht} other {Tochten}}",
"route-point": "Routepunt",
"add-as-endpoint": "Add as endpoint",
"russian": "Russisch",
"save": "Bewaren",
"save-list": "Bewaar lijst",

View File

@@ -373,6 +373,7 @@
"road": "Vei",
"route": "{n, plural, =1 {Rute} other {Ruter}}",
"route-point": "Rutepunkt",
"add-as-endpoint": "Add as endpoint",
"russian": "Russisk",
"save": "Lagre",
"save-list": "Lagre liste",

View File

@@ -373,6 +373,7 @@
"road": "Droga",
"route": "{n, plural,=1 {Trasa} other {Trasy}}",
"route-point": "Punkt trasy",
"add-as-endpoint": "Add as endpoint",
"russian": "Russian",
"save": "Zapisz",
"save-list": "Zapisz listę",

View File

@@ -373,6 +373,7 @@
"road": "Road",
"route": "{n, plural, =1 {Route} other {Routes}}",
"route-point": "Route Point",
"add-as-endpoint": "Add as endpoint",
"russian": "Russian",
"save": "Guardar",
"save-list": "Gravar lista",

View File

@@ -373,6 +373,7 @@
"road": "Шоссе",
"route": "{n, plural, =1 {Маршрут} other {Маршрутов}}",
"route-point": "Точка маршрута",
"add-as-endpoint": "Add as endpoint",
"russian": "Русский",
"save": "Сохранить",
"save-list": "Сохранить список",

View File

@@ -373,6 +373,7 @@
"road": "道路",
"route": "{n, plural, =1 {Route} other {Routes}}",
"route-point": "路线点",
"add-as-endpoint": "Add as endpoint",
"russian": "Russian",
"save": "保存",
"save-list": "保存列表",

View File

@@ -45,7 +45,7 @@ const TrailUpdateSchema = z.object({
"photos-": z.string().optional(),
"photos+": z.string().optional(),
thumbnail: z.number().int().nonnegative().optional(),
like_count: z.number().int().min(0).optional().default(0),
like_count: z.number().int().min(0).optional(),
category: z.string().optional(),
tags: z.array(z.string()).optional(),
gpx: z.string().optional(),

View File

@@ -128,20 +128,46 @@ export async function searchLocations(q: string, limit?: number, f: (url: Reques
}))
}
async function fetchGeocoding(path: string, params: URLSearchParams, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<Response> {
async function fetchGeocoding(path: string, params: URLSearchParams, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch, signal?: AbortSignal): Promise<Response> {
const query = params.toString();
const url = query.length ? `/api/v1/geocoding/${path}?${query}` : `/api/v1/geocoding/${path}`;
return await f(url);
return await f(url, signal ? { signal } : undefined);
}
export async function searchLocationReverse(lat: number, lon: number, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
type ReverseGeocodingOptions = {
includeRoad?: boolean;
signal?: AbortSignal;
}
export type ReverseLocationResult = {
label: string;
fullLabel: string;
country: string;
}
export type FetchFunction = (url: RequestInfo | URL, config?: RequestInit) => Promise<Response>;
export async function searchLocationReverse(
lat: number,
lon: number,
options: ReverseGeocodingOptions = {},
f: FetchFunction = fetch,
) {
const location = await searchLocationReverseStructured(lat, lon, options, f);
return location?.fullLabel ?? "";
}
export async function searchLocationReverseStructured(
lat: number,
lon: number,
options: ReverseGeocodingOptions = {},
f: FetchFunction = fetch,
): Promise<ReverseLocationResult | null> {
const params = new URLSearchParams({
lat: String(lat),
lon: String(lon),
format: "geojson",
addressdetails: "1",
});
const r = await fetchGeocoding("reverse", params, f);
const r = await fetchGeocoding("reverse", params, f, options.signal);
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
@@ -149,30 +175,52 @@ export async function searchLocationReverse(lat: number, lon: number, f: (url: R
const response: NominatimResponse = await r.json();
if (response.features?.at(0)?.properties.address) {
return getLocationDescription(response.features[0].properties.address)
return getReverseLocationResult(response.features[0].properties.address, options);
}
return ""
return null
}
function getLocationDescription(address: Address) {
let description = ""
function getReverseLocationResult(
address: Address,
options: ReverseGeocodingOptions = {},
): ReverseLocationResult {
const country = address.country ?? "";
const label = getLocationDescription(address, { ...options, includeCountry: false });
const fullLabel = getLocationDescription(address, options);
if (address.country) {
description += address.country;
}
if (address.state) {
description = `${address.state}, ` + description
return {
label: label || fullLabel,
fullLabel,
country,
};
}
function getLocationDescription(
address: Address,
options: ReverseGeocodingOptions & { includeCountry?: boolean } = {},
) {
const parts = [];
if (options.includeRoad && address.road) {
parts.push(address.road);
}
if (address.city) {
description = `${address.city}, ` + description
parts.push(address.city);
} else if (address.town) {
description = `${address.town}, ` + description
parts.push(address.town);
} else if (address.hamlet) {
description = `${address.hamlet}, ` + description
parts.push(address.hamlet);
} else if (address.village) {
description = `${address.village}, ` + description
parts.push(address.village);
}
return description;
if (address.state) {
parts.push(address.state);
}
if (options.includeCountry !== false && address.country) {
parts.push(address.country);
}
return parts.join(", ");
}
export async function searchMulti(options: MultiSearchParams): Promise<MultiSearchResult<any>[]> {

View File

@@ -334,9 +334,11 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
}
let r = await fetch(`/api/v1/trail/form/${newTrail.id}?` + new URLSearchParams({
const updateUrl = `/api/v1/trail/form/${newTrail.id}?` + new URLSearchParams({
expand: "category,waypoints_via_trail,summit_logs_via_trail,trail_share_via_trail,tags",
}), {
});
let r = await fetch(updateUrl, {
method: 'POST',
body: formData,
})
@@ -360,6 +362,54 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
return model;
}
export async function trails_update_metadata(
currentTrail: Trail,
patch: Pick<Partial<Trail>, "name" | "description" | "tags"> & {
expand?: Pick<NonNullable<Trail["expand"]>, "tags">;
},
) {
const tagIds: string[] | undefined = patch.expand?.tags
? []
: patch.tags;
for (const tag of patch.expand?.tags ?? []) {
if (!tag.id) {
const model = await tags_create(tag);
tagIds!.push(model.id!);
} else {
tagIds!.push(tag.id);
}
}
const searchParams = new URLSearchParams(
tagIds !== undefined ? { expand: "tags" } : {},
);
const query = searchParams.toString();
const url = `/api/v1/trail/${currentTrail.id}${query ? `?${query}` : ""}`;
const payload = {
name: patch.name ?? currentTrail.name,
...(patch.description !== undefined
? { description: patch.description }
: {}),
...(tagIds !== undefined ? { tags: tagIds } : {}),
};
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
const model: Trail = await r.json();
trail.set(model);
return model;
}
export async function trails_delete(trail: Trail) {
const r = await fetch('/api/v1/trail/' + trail.id, {

View File

@@ -6,6 +6,7 @@ import Waypoint from "$lib/models/gpx/waypoint";
import { type RoutingOptions, type ValhallaAnchor, type ValhallaHeightResponse, type ValhallaRouteResponse } from "$lib/models/valhalla";
import { APIError } from "$lib/util/api_util";
import { decodePolyline, encodePolyline } from "$lib/util/polyline_util";
import { renderValhallaAnchorMarker, valhallaAnchorTitle } from "$lib/util/valhalla_anchor_util";
import { applyChangeset, diff, revertChangeset, type Changeset } from 'json-diff-ts';
import type { LngLat } from "maplibre-gl";
import { _ } from "svelte-i18n";
@@ -16,8 +17,8 @@ const emtpyTrack = new Track({ trkseg: [] })
class ValhallaStore {
route: GPX = $state(new GPX({ trk: [emtpyTrack] }));
anchors: ValhallaAnchor[] = $state([]);
undoStack: { delta: Changeset, reverseDelta: Changeset }[] = $state([]);
redoStack: { delta: Changeset, reverseDelta: Changeset }[] = $state([]);
undoStack: { delta: Changeset, reverseDelta: Changeset, anchorsBefore?: ValhallaAnchor[], anchorsAfter?: ValhallaAnchor[] }[] = $state([]);
redoStack: { delta: Changeset, reverseDelta: Changeset, anchorsBefore?: ValhallaAnchor[], anchorsAfter?: ValhallaAnchor[] }[] = $state([]);
}
export const valhallaStore = new ValhallaStore();
@@ -179,14 +180,21 @@ export function reverseRoute() {
if (!a.marker) {
return;
}
a.marker.getElement().textContent = "" + (i + 1);
renderValhallaAnchorMarker(
a.marker.getElement(),
i,
valhallaStore.anchors.length,
);
const anchorPopupHeading = a.marker
.getPopup()
._content.getElementsByTagName("h5")[0];
if (anchorPopupHeading) {
anchorPopupHeading.textContent =
get(_)("route-point") + " #" + (i + 1);
anchorPopupHeading.textContent = valhallaAnchorTitle(
i,
valhallaStore.anchors.length,
get(_),
);
}
});
}
@@ -235,8 +243,8 @@ export async function splitSegment(index: number, pos: LngLat) {
const firstSegmentPoints = [...points.slice(0, bestSplitIndex), intersectionPoint];
const secondSegmentPoints = [intersectionPoint, ...points.slice(bestSplitIndex)];
editRoute(index, firstSegmentPoints)
insertIntoRoute(secondSegmentPoints, index + 1)
await editRoute(index, firstSegmentPoints)
await insertIntoRoute(secondSegmentPoints, index + 1)
}
@@ -263,21 +271,30 @@ export function normalizeRouteTime() {
export function undo() {
const historyItem = valhallaStore.undoStack.pop()
if (!historyItem) {
return
return undefined
}
valhallaStore.redoStack.push(historyItem)
valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.reverseDelta);
valhallaStore.route.features = valhallaStore.route.getTotals();
return historyItem;
}
export function revertRouteChange() {
const historyItem = valhallaStore.undoStack.pop();
if (!historyItem) return;
valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.reverseDelta);
valhallaStore.route.features = valhallaStore.route.getTotals();
}
export function redo() {
const historyItem = valhallaStore.redoStack.pop()
if (!historyItem) {
return
return undefined
}
valhallaStore.undoStack.push(historyItem)
valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.delta);
valhallaStore.route.features = valhallaStore.route.getTotals();
return historyItem;
}

View File

@@ -13,7 +13,10 @@ export function formatTimeHHMM(seconds?: number) {
return (h < 10 ? "0" : "") + h.toString() + "h " + (m < 10 ? "0" : "") + m.toString() + "m";
}
export function formatDistance(meters?: number) {
export function formatDistance(
meters?: number,
options: { compact?: boolean } = {},
) {
if (meters === undefined) {
return "-";
}
@@ -22,7 +25,14 @@ export function formatDistance(meters?: number) {
if (unit == "metric") {
if (meters >= 1000) {
return `${(meters / 1000).toFixed(2)} km`
const kilometers = meters / 1000;
if (options.compact && kilometers >= 100) {
return `${kilometers.toFixed(0)} km`;
}
if (options.compact && kilometers >= 10) {
return `${kilometers.toFixed(1)} km`;
}
return `${kilometers.toFixed(2)} km`
} else {
return meters % 1 == 0 ? `${meters} m` : `${Math.round(meters)} m`;
}

View File

@@ -76,13 +76,12 @@ export function createMarkerFromWaypoint(waypoint: Waypoint, onDragEnd?: (marker
return marker;
}
export function createAnchorMarker(lat: number, lon: number, index: number,
export function createAnchorMarker(lat: number, lon: number,
onDeleteClick: () => void, onLoopClick: () => void,
onDragStart: (event: Event) => void, onDragEnd: (event: Event) => void): FontawesomeMarker {
const anchorElement = document.createElement("span")
anchorElement.className = "route-anchor cursor-pointer rounded-full w-6 h-6 border border-black text-center bg-primary text-white"
anchorElement.textContent = "" + index
anchorElement.className = "route-anchor cursor-pointer flex items-center justify-center rounded-full w-6 h-6 border border-black bg-primary text-white"
const marker = new M.Marker(
{
draggable: true,
@@ -96,7 +95,7 @@ export function createAnchorMarker(lat: number, lon: number, index: number,
popupContent.className = "py-3 pl-3"
const anchorH = document.createElement("h5")
anchorH.classList.add("text-base", "font-medium");
anchorH.textContent = get(_)("route-point") + " #" + index;
anchorH.textContent = get(_)("route-point");
const deleteButton = document.createElement("button");
deleteButton.className = "btn-secondary w-full mt-2 text-sm";
@@ -260,19 +259,33 @@ export function createPopupFromTrail(trail: Trail) {
return popup;
}
export function createOverpassPopup(feature: GeoJSON.Feature, coordinates: GeoJSON.Position) {
export type OverpassPopupAction = {
label: string;
onClick: () => void;
disabled?: boolean;
helperText?: string;
icon?: string;
};
export function createOverpassPopup(
feature: GeoJSON.Feature,
coordinates: GeoJSON.Position,
action?: OverpassPopupAction,
) {
const tags: Record<string, string> = JSON.parse(feature.properties?.tags);
const name = tags.name ?? get(_)(feature.properties?.query) ?? "?"
const popupContainer = document.createElement("div");
popupContainer.className = "p-4"
popupContainer.className = "p-4 relative"
const indent = action ? "pl-12 " : "";
const popupHeading = document.createElement("h1");
popupHeading.className = "font-medium text-lg"
popupHeading.className = indent + "font-medium text-lg"
popupHeading.textContent = name;
const coordinateSubtitle = document.createElement("p")
coordinateSubtitle.className = "text-gray-500"
coordinateSubtitle.className = indent + "text-gray-500"
coordinateSubtitle.textContent = `${coordinates[0].toFixed(6)}, ${coordinates[1].toFixed(6)}`
popupContainer.appendChild(popupHeading)
@@ -295,6 +308,36 @@ export function createOverpassPopup(feature: GeoJSON.Feature, coordinates: GeoJS
popupContainer.appendChild(tagsGrid)
if (action) {
const actionButton = document.createElement("button");
actionButton.type = "button";
actionButton.className =
"flex h-9 w-9 items-center justify-center absolute top-4 left-4 rounded-full p-0 text-xl text-content hover:bg-secondary-hover disabled:opacity-40 disabled:cursor-not-allowed";
actionButton.disabled = action.disabled ?? false;
actionButton.setAttribute("aria-label", action.label);
actionButton.setAttribute("title", action.label);
const iconElement = document.createElement("i");
iconElement.className = (action.icon ?? "fa fa-flag-checkered");
iconElement.setAttribute("aria-hidden", "true");
actionButton.appendChild(iconElement);
actionButton.addEventListener("click", () => {
if (!actionButton.disabled) {
action.onClick();
}
});
popupContainer.appendChild(actionButton);
if (action.helperText) {
const helper = document.createElement("p");
helper.className = "text-xs text-gray-500 mt-2";
helper.textContent = action.helperText;
popupContainer.appendChild(helper);
}
}
return popupContainer;
}

View File

@@ -0,0 +1,60 @@
interface ValhallaAnchorDisplay {
icon: string;
number: number | null;
titleKey: "start" | "finish" | "route-point";
}
export function valhallaAnchorDisplay(index: number, total: number): ValhallaAnchorDisplay {
if (index === 0) {
return {
icon: "fa-bullseye",
number: null,
titleKey: "start",
};
}
if (index === total - 1) {
return {
icon: "fa-flag-checkered",
number: null,
titleKey: "finish",
};
}
return {
icon: "fa-location-dot",
number: index,
titleKey: "route-point",
};
}
export function valhallaAnchorTitle(
index: number,
total: number,
translate: (key: string) => string,
) {
const display = valhallaAnchorDisplay(index, total);
if (display.number === null) {
return translate(display.titleKey);
}
return `${translate(display.titleKey)} #${display.number}`;
}
export function renderValhallaAnchorMarker(
element: HTMLElement,
index: number,
total: number,
) {
const display = valhallaAnchorDisplay(index, total);
element.replaceChildren();
if (display.number !== null) {
element.textContent = `${display.number}`;
return;
}
const icon = document.createElement("i");
icon.classList.add("fa", display.icon);
element.appendChild(icon);
}

View File

@@ -2,7 +2,7 @@ import * as M from "maplibre-gl";
import { DebugLayer } from "./debug-layer";
import { baseMapStyles, defaultMapState, type BaseLayer, type MapState } from "./layers";
import { OverlayLayer } from "./overlay-layer";
import { OverpassLayer } from "./overpass-layer";
import { OverpassLayer, type OverpassPopupActionFactory } from "./overpass-layer";
@@ -11,9 +11,11 @@ export class LayerManager {
state!: MapState;
layers: Record<string, BaseLayer> = {};
private addedListeners: Set<string> = new Set();
private overpassActionFactory?: OverpassPopupActionFactory;
constructor(map: M.Map) {
constructor(map: M.Map, options?: { overpassActionFactory?: OverpassPopupActionFactory }) {
this.map = map;
this.overpassActionFactory = options?.overpassActionFactory;
const storedMapState = localStorage.getItem("map-state")
if (storedMapState) {
@@ -40,7 +42,7 @@ export class LayerManager {
try {
this.update(this.state, true);
const overpassLayer = new OverpassLayer(this.map)
const overpassLayer = new OverpassLayer(this.map, this.overpassActionFactory)
const debugLayer = new DebugLayer()
this.addLayer("overpass", overpassLayer)

View File

@@ -4,13 +4,18 @@
* License: MIT
*/
import { createOverpassPopup } from "$lib/util/maplibre_util";
import { createOverpassPopup, type OverpassPopupAction } from "$lib/util/maplibre_util";
import * as M from "maplibre-gl";
import { type LngLatBounds, type MapMouseEvent, type StyleSpecification } from "maplibre-gl";
import { pois, type BaseLayer, type MapState } from "./layers";
import type { OverpassResponse } from "./types";
import { env } from '$env/dynamic/public'
export type OverpassPopupActionFactory = (
feature: GeoJSON.Feature,
coordinates: GeoJSON.Position,
) => OverpassPopupAction | null | undefined;
export class OverpassLayer implements BaseLayer {
private overpassApiURL: string = "/api/v1/overpass/interpreter";
@@ -61,9 +66,11 @@ export class OverpassLayer implements BaseLayer {
private popup: M.Popup;
private map: M.Map;
private currentPopupCoordinates: GeoJSON.Position | null = null
private popupActionFactory?: OverpassPopupActionFactory;
constructor(map: M.Map) {
constructor(map: M.Map, popupActionFactory?: OverpassPopupActionFactory) {
this.map = map;
this.popupActionFactory = popupActionFactory;
this.popup = new M.Popup()
.setMaxWidth("420px")
}
@@ -71,7 +78,8 @@ export class OverpassLayer implements BaseLayer {
private openPopup(e: MapMouseEvent) {
const features = (e as any).features as GeoJSON.Feature[];
const point = features[0].geometry as GeoJSON.Point;
const content = createOverpassPopup(features[0], point.coordinates);
const action = this.popupActionFactory?.(features[0], point.coordinates);
const content = createOverpassPopup(features[0], point.coordinates, action ?? undefined);
this.currentPopupCoordinates = point.coordinates;
this.popup

View File

@@ -76,7 +76,12 @@ export async function PUT(event: RequestEvent) {
if (trail.lat && trail.lon) {
try {
const location = await searchLocationReverse(trail.lat, trail.lon, event.fetch)
const location = await searchLocationReverse(
trail.lat,
trail.lon,
{},
event.fetch,
)
trail.location ??= location;
} catch (e: any) {
console.warn("Reverse geocoding failed during upload", e);

View File

@@ -9,6 +9,7 @@
import SummitLogModal from "$lib/components/summit_log/summit_log_modal.svelte";
import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
import PhotoPicker from "$lib/components/trail/photo_picker.svelte";
import TrailAnchorList from "$lib/components/trail/trail_anchor_list.svelte";
import WaypointCard from "$lib/components/waypoint/waypoint_card.svelte";
import WaypointMergeModal, {
type WaypointMergeOptions,
@@ -23,6 +24,8 @@
import { SummitLog } from "$lib/models/summit_log";
import { Trail } from "$lib/models/trail";
import type { RoutingOptions, ValhallaAnchor } from "$lib/models/valhalla";
import type { OverpassPopupActionFactory } from "$lib/vendor/maplibre-layer-manager/overpass-layer";
import { type OverpassPopupAction } from "$lib/util/maplibre_util";
import { Waypoint } from "$lib/models/waypoint";
import { categories } from "$lib/stores/category_store";
import {
@@ -52,6 +55,7 @@
splitSegment,
undo,
redo,
revertRouteChange,
clearUndoRedoStack,
} from "$lib/stores/valhalla_store.svelte.js";
import { waypoint } from "$lib/stores/waypoint_store";
@@ -91,6 +95,10 @@
createEditTrailMapPopup,
FontawesomeMarker,
} from "$lib/util/maplibre_util";
import {
renderValhallaAnchorMarker,
valhallaAnchorTitle,
} from "$lib/util/valhalla_anchor_util";
import EXIF from "$lib/vendor/exif-js/exif.js";
import { validator } from "@felte/validator-zod";
import cryptoRandomString from "crypto-random-string";
@@ -117,6 +125,7 @@
let summitLogModal: SummitLogModal;
let listSelectModal: ListSearchModal;
let markTrailAsCompletedModal: ConfirmModal;
let replaceRouteModal: ConfirmModal;
let loading = $state(false);
@@ -127,6 +136,8 @@
let gpxFile: File | Blob | null = null;
let drawingActive = $state(false);
let replacingRoute = $state(false);
let isNewTrail = $derived(page.params.id === "new");
function routeCalculationErrorText(error: unknown) {
if (error instanceof Error && error.message) {
@@ -142,6 +153,7 @@
| undefined = $state();
let searchDropdownItems: SearchItem[] = $state([]);
let selectedSearchLocation: SearchItem | null = $state(null);
let cropStartMarker: FontawesomeMarker;
let cropEndMarker: FontawesomeMarker;
@@ -171,6 +183,8 @@
autoRouting: true,
modeOfTransport: "pedestrian",
});
let routeAnchorListUpdating = $state(false);
let routeSegments = $state<TrackSegment[]>([]);
let savedAtLeastOnce = $state(false);
@@ -308,10 +322,42 @@
initRouteAnchors(gpx);
updateTrailOnMap();
if (!isNewTrail) {
startDrawing();
}
}
}
});
function fitCurrentRoute(initializedMap: M.Map) {
const bounds = valhallaStore.route.toGeoJSON().bbox;
if (!bounds) {
return;
}
initializedMap.fitBounds(bounds as M.LngLatBoundsLike, {
animate: false,
padding: {
top: 16,
left: 16,
right: 16,
bottom: 16,
},
});
}
function handleMapInit(initializedMap: M.Map) {
if (drawingActive) {
for (const anchor of valhallaStore.anchors) {
anchor.marker?.addTo(initializedMap);
}
}
if (!isNewTrail) {
fitCurrentRoute(initializedMap);
}
}
function openFileBrowser() {
document.getElementById("fileInput")!.click();
}
@@ -325,7 +371,10 @@
return;
}
const replaceExistingRoute = replacingRoute && !isNewTrail;
if (!replaceExistingRoute) {
clearWaypoints();
}
clearAnchors();
clearUndoRedoStack();
clearRoute();
@@ -339,10 +388,20 @@
try {
const prevId = $formData.id;
const parseResult = await gpx2trail(gpxData, selectedFile.name);
if (replaceExistingRoute) {
setFields("lat", parseResult.trail.lat);
setFields("lon", parseResult.trail.lon);
setFields("distance", parseResult.trail.distance);
setFields("duration", parseResult.trail.duration);
setFields("elevation_gain", parseResult.trail.elevation_gain);
setFields("elevation_loss", parseResult.trail.elevation_loss);
} else {
setFields(parseResult.trail);
}
$formData.id = prevId ?? cryptoRandomString({ length: 15 });
$formData.expand!.gpx_data = gpxData;
if (!replaceExistingRoute) {
setFields(
"category",
page.data.settings.category || $categories[0].id,
@@ -351,6 +410,7 @@
"public",
page.data.settings?.privacy?.trails === "public",
);
}
// const log = new SummitLog(parseResult.trail.date as string, {
// distance: $formData.distance,
@@ -383,6 +443,13 @@
}
setRoute(parseResult.gpx);
initRouteAnchors(parseResult.gpx);
replacingRoute = false;
if (!isNewTrail) {
startDrawing();
if (map) {
fitCurrentRoute(map);
}
}
updateTrailOnMap();
} catch (e) {
@@ -745,17 +812,23 @@
}
function startDrawing() {
drawingActive = true;
routeSegments = [...(valhallaStore.route.trk?.at(0)?.trkseg ?? [])];
if (!map) {
return;
}
drawingActive = true;
if (!valhallaStore.route.trk?.at(0)?.trkseg?.at(0)?.trkpt?.length) {
}
for (const anchor of valhallaStore.anchors) {
anchor.marker?.addTo(map);
}
}
function startReplacementDrawing() {
replacingRoute = false;
startDrawing();
}
async function stopDrawing() {
drawingActive = false;
for (const anchor of valhallaStore.anchors) {
@@ -816,8 +889,13 @@
async function addAnchorAndRecalculate(lat: number, lon: number) {
const previousAnchor =
valhallaStore.anchors[valhallaStore.anchors.length - 1];
if (!previousAnchor) {
addAnchor(lat, lon, 0);
return;
}
const anchor = addAnchor(lat, lon, valhallaStore.anchors.length);
const markerText = startAnchorLoading(anchor);
startAnchorLoading(anchor);
try {
const routeWaypoints = await calculateRouteBetween(
previousAnchor.lat,
@@ -826,9 +904,9 @@
lon,
routingOptions,
);
insertIntoRoute(routeWaypoints);
updateTrailWithRouteData();
await insertIntoRoute(routeWaypoints);
normalizeRouteTime();
updateTrailWithRouteData();
} catch (e) {
console.error(e);
show_toast({
@@ -837,7 +915,7 @@
type: "error",
});
} finally {
stopAnchorLoading(anchor, markerText);
stopAnchorLoading(anchor);
}
}
@@ -855,7 +933,6 @@
const marker = createAnchorMarker(
lat,
lon,
index + 1,
() => {
removeAnchor(
valhallaStore.anchors.findIndex((a) => a.id == anchor.id),
@@ -896,6 +973,7 @@
}
anchor.marker = marker;
valhallaStore.anchors.splice(index, 0, anchor);
refreshAnchorLabels(Math.max(0, index - 1));
return anchor;
}
@@ -903,18 +981,15 @@
function startAnchorLoading(anchor: ValhallaAnchor) {
const markerIcon = anchor.marker?.getElement();
if (!markerIcon) {
return null;
return;
}
markerIcon.classList.add("spinner", "spinner-light", "spinner-small");
const savedMarkerNumber = markerIcon.textContent;
markerIcon.textContent = "";
return savedMarkerNumber;
markerIcon.replaceChildren();
}
function stopAnchorLoading(anchor: ValhallaAnchor, index: string | null) {
function stopAnchorLoading(anchor: ValhallaAnchor) {
const markerIcon = anchor.marker?.getElement();
if (!markerIcon || !index) {
if (!markerIcon) {
return;
}
markerIcon.classList.remove(
@@ -922,7 +997,47 @@
"spinner-light",
"spinner-small",
);
markerIcon.textContent = index;
refreshAnchorLabel(valhallaStore.anchors.findIndex((a) => a.id === anchor.id));
}
function refreshAnchorLabel(index: number) {
if (index < 0) {
return;
}
const anchor = valhallaStore.anchors[index];
const markerIcon = anchor.marker?.getElement();
if (markerIcon) {
renderValhallaAnchorMarker(
markerIcon,
index,
valhallaStore.anchors.length,
);
anchor
.marker!.getPopup()
._content.getElementsByTagName("h5")[0].textContent =
valhallaAnchorTitle(index, valhallaStore.anchors.length, $_);
}
}
function refreshAnchorLabels(startIndex: number = 0) {
for (let i = startIndex; i < valhallaStore.anchors.length; i++) {
refreshAnchorLabel(i);
}
}
function highlightAnchorMarker(index: number | null) {
for (const anchor of valhallaStore.anchors) {
anchor.marker?.getElement().classList.remove("anchor-list-highlight");
}
if (index === null) {
return;
}
valhallaStore.anchors[index]?.marker
?.getElement()
.classList.add("anchor-list-highlight");
}
async function removeAnchor(anchorIndex: number) {
@@ -931,20 +1046,7 @@
}
valhallaStore.anchors[anchorIndex]?.marker?.remove();
valhallaStore.anchors.splice(anchorIndex, 1);
for (let i = anchorIndex; i < valhallaStore.anchors.length; i++) {
const anchor = valhallaStore.anchors[i];
const markerIcon = anchor.marker?.getElement();
if (markerIcon) {
const markerText = markerIcon.textContent ?? "0";
const markerIndex = parseInt(markerText);
const newIndex = markerIndex - 1;
markerIcon.textContent = newIndex + "";
anchor
.marker!.getPopup()
._content.getElementsByTagName("h5")[0].textContent =
$_("route-point") + " #" + newIndex;
}
}
refreshAnchorLabels(anchorIndex);
if (anchorIndex == 0) {
deleteFromRoute(anchorIndex);
if ($formData.expand?.gpx_data) {
@@ -955,24 +1057,141 @@
updateTrailWithRouteData();
} else {
deleteFromRoute(anchorIndex - 1);
await recalculateRoute(anchorIndex);
await recalculateRoute(anchorIndex, [anchorIndex - 1, anchorIndex]);
}
}
async function recalculateRoute(anchorIndex: number) {
const markerText = startAnchorLoading(
valhallaStore.anchors[anchorIndex],
async function recalculateRouteFromAnchors(fromIndex: number, toIndex: number) {
const anchors = valhallaStore.anchors;
const N = anchors.length;
if (N < 2) {
setRoute(new GPX({ trk: [new Track({ trkseg: [] })] }), true);
updateTrailWithRouteData();
return;
}
// Segments not touching the moved anchor are reused (shifted by ±1); only the 23 boundary segments are recalculated.
const oldSegments = valhallaStore.route.trk?.at(0)?.trkseg ?? [];
const newSegments: (TrackSegment | null)[] = new Array(N - 1).fill(null);
const toRecalc: number[] = [];
if (fromIndex < toIndex) {
for (let i = 0; i < fromIndex - 1; i++) newSegments[i] = oldSegments[i] ?? null;
for (let i = fromIndex; i <= toIndex - 2; i++) newSegments[i] = oldSegments[i + 1] ?? null;
for (let i = toIndex + 1; i < N - 1; i++) newSegments[i] = oldSegments[i] ?? null;
if (fromIndex > 0) toRecalc.push(fromIndex - 1);
toRecalc.push(toIndex - 1);
if (toIndex < N - 1) toRecalc.push(toIndex);
} else {
for (let i = 0; i < toIndex - 1; i++) newSegments[i] = oldSegments[i] ?? null;
for (let i = toIndex + 1; i <= fromIndex - 1; i++) newSegments[i] = oldSegments[i - 1] ?? null;
for (let i = fromIndex + 1; i < N - 1; i++) newSegments[i] = oldSegments[i] ?? null;
if (toIndex > 0) toRecalc.push(toIndex - 1);
toRecalc.push(toIndex);
if (fromIndex < N - 1) toRecalc.push(fromIndex);
}
const loadingAnchorIndexes = [...new Set(toRecalc.flatMap((i) => [i, i + 1]))];
for (const index of loadingAnchorIndexes) {
startAnchorLoading(anchors[index]);
}
try {
const recalcResults = await Promise.all(
toRecalc.map((i) =>
calculateRouteBetween(
anchors[i].lat,
anchors[i].lon,
anchors[i + 1].lat,
anchors[i + 1].lon,
routingOptions,
).then((pts) => ({ i, segment: new TrackSegment({ trkpt: pts }) })),
),
);
for (const { i, segment } of recalcResults) {
newSegments[i] = segment;
}
setRoute(
new GPX({ trk: [new Track({ trkseg: newSegments.filter((s): s is TrackSegment => s !== null) })] }),
true,
);
normalizeRouteTime();
updateTrailWithRouteData();
} finally {
for (const index of loadingAnchorIndexes) {
stopAnchorLoading(anchors[index]);
}
}
}
async function moveAnchor(fromIndex: number, toIndex: number) {
if (
routeAnchorListUpdating ||
!drawingActive ||
fromIndex === toIndex ||
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= valhallaStore.anchors.length ||
toIndex >= valhallaStore.anchors.length
) {
return;
}
const previousAnchors = [...valhallaStore.anchors];
const previousUndoStackLength = valhallaStore.undoStack.length;
const [anchor] = valhallaStore.anchors.splice(fromIndex, 1);
valhallaStore.anchors.splice(toIndex, 0, anchor);
refreshAnchorLabels(Math.min(fromIndex, toIndex));
routeAnchorListUpdating = true;
try {
await recalculateRouteFromAnchors(fromIndex, toIndex);
const lastEntry = valhallaStore.undoStack.at(-1);
if (lastEntry && valhallaStore.undoStack.length > previousUndoStackLength) {
lastEntry.anchorsBefore = previousAnchors;
lastEntry.anchorsAfter = [...valhallaStore.anchors];
}
} catch (e) {
while (valhallaStore.undoStack.length > previousUndoStackLength) {
revertRouteChange();
}
routeSegments = [...(valhallaStore.route.trk?.at(0)?.trkseg ?? [])];
valhallaStore.anchors = previousAnchors;
refreshAnchorLabels(Math.min(fromIndex, toIndex));
console.error(e);
show_toast({
text: routeCalculationErrorText(e),
icon: "close",
type: "error",
});
} finally {
routeAnchorListUpdating = false;
}
}
async function recalculateRoute(anchorIndex: number, loadingAnchorIndexes = [anchorIndex]) {
const anchor = valhallaStore.anchors[anchorIndex];
if (!anchor) {
return;
}
const anchors = valhallaStore.anchors;
const loadingAnchors = [
...new Set(
loadingAnchorIndexes
.map((index) => anchors[index])
.filter((anchor): anchor is ValhallaAnchor => Boolean(anchor)),
),
];
for (const loadingAnchor of loadingAnchors) {
startAnchorLoading(loadingAnchor);
}
let nextRouteSegment;
let previousRouteSegment;
try {
if (anchorIndex < valhallaStore.anchors.length - 1) {
const nextAnchor = valhallaStore.anchors[anchorIndex + 1];
if (anchorIndex < anchors.length - 1) {
const nextAnchor = anchors[anchorIndex + 1];
nextRouteSegment = await calculateRouteBetween(
anchor.lat,
@@ -983,7 +1202,7 @@
);
}
if (anchorIndex > 0) {
const previousAnchor = valhallaStore.anchors[anchorIndex - 1];
const previousAnchor = anchors[anchorIndex - 1];
previousRouteSegment = await calculateRouteBetween(
previousAnchor.lat,
previousAnchor.lon,
@@ -994,13 +1213,13 @@
}
if (nextRouteSegment) {
editRoute(anchorIndex, nextRouteSegment);
await editRoute(anchorIndex, nextRouteSegment);
}
if (previousRouteSegment) {
editRoute(anchorIndex - 1, previousRouteSegment);
await editRoute(anchorIndex - 1, previousRouteSegment);
}
updateTrailWithRouteData();
normalizeRouteTime();
updateTrailWithRouteData();
} catch (e) {
console.error(e);
show_toast({
@@ -1009,7 +1228,9 @@
type: "error",
});
} finally {
stopAnchorLoading(valhallaStore.anchors[anchorIndex], markerText);
for (const loadingAnchor of loadingAnchors) {
stopAnchorLoading(loadingAnchor);
}
}
}
@@ -1025,8 +1246,7 @@
data.event.lngLat.lng,
data.segment + 1,
);
const markerText = startAnchorLoading(anchor);
updateFollowingAnchors(data.segment);
startAnchorLoading(anchor);
const previousAnchor = valhallaStore.anchors[data.segment];
const nextAnchor = valhallaStore.anchors[data.segment + 2];
@@ -1047,8 +1267,8 @@
routingOptions,
);
editRoute(data.segment, previousRouteSegment);
insertIntoRoute(nextRouteSegment, data.segment + 1);
await editRoute(data.segment, previousRouteSegment);
await insertIntoRoute(nextRouteSegment, data.segment + 1);
normalizeRouteTime();
updateTrailWithRouteData();
} catch (e) {
@@ -1059,24 +1279,7 @@
type: "error",
});
} finally {
stopAnchorLoading(anchor, markerText);
}
}
function updateFollowingAnchors(segment: number) {
for (let i = segment + 2; i < valhallaStore.anchors.length; i++) {
const anchor = valhallaStore.anchors[i];
const markerIcon = anchor.marker?.getElement();
if (markerIcon) {
const markerText = markerIcon.textContent ?? "0";
const markerIndex = parseInt(markerText);
const newIndex = markerIndex + 1;
markerIcon.textContent = newIndex + "";
anchor
.marker!.getPopup()
._content.getElementsByTagName("h5")[0].textContent =
$_("route-point") + " #" + newIndex;
}
stopAnchorLoading(anchor);
}
}
@@ -1090,8 +1293,7 @@
data.segment + 1,
);
splitSegment(data.segment, data.event.lngLat);
updateFollowingAnchors(data.segment);
await splitSegment(data.segment, data.event.lngLat);
updateTrailWithRouteData();
}
@@ -1107,6 +1309,22 @@
updateTrailWithRouteData();
}
function requestReplaceRoute() {
replaceRouteModal.openModal();
}
function replaceRoute() {
resetRoute();
clearUndoRedoStack();
gpxFile = null;
overwriteGPX = true;
replacingRoute = true;
drawingActive = false;
routeSegments = [];
$formData.expand!.gpx_data = undefined;
updateTrailWithRouteData();
}
async function recalculateElevationData() {
await recalculateHeight();
@@ -1228,6 +1446,7 @@
function updateTrailWithRouteData() {
overwriteGPX = true;
routeSegments = [...(valhallaStore.route.trk?.at(0)?.trkseg ?? [])];
updateTotals(valhallaStore.route);
if (!$formData.id) {
@@ -1259,6 +1478,42 @@
zoom: 13,
animate: false,
});
selectedSearchLocation = item;
}
function clearSelectedSearchLocation() {
selectedSearchLocation = null;
}
const buildPoiAnchorAction: OverpassPopupActionFactory = (
_feature,
coordinates,
) => {
const [lon, lat] = coordinates;
if (typeof lat !== "number" || typeof lon !== "number") {
return null;
}
if (!drawingActive) {
return null;
}
return {
label: $_("add-as-endpoint"),
icon: "fa fa-flag-checkered",
onClick: () => addAnchorAndRecalculate(lat, lon),
} satisfies OverpassPopupAction;
};
async function addSelectedLocationAsEndpoint() {
if (!selectedSearchLocation) {
return;
}
const { lat, lon } = selectedSearchLocation.value;
if (valhallaStore.anchors.length === 0) {
addAnchor(lat, lon, 0);
} else {
await addAnchorAndRecalculate(lat, lon);
}
selectedSearchLocation = null;
}
async function searchCities(q: string) {
@@ -1460,16 +1715,26 @@
}
function undoRouteEdit() {
undo();
const entry = undo();
if (entry?.anchorsBefore) {
valhallaStore.anchors = entry.anchorsBefore;
refreshAnchorLabels();
} else {
clearAnchors();
initRouteAnchors(valhallaStore.route, true);
}
updateTrailWithRouteData();
}
function redoRouteEdit() {
redo();
const entry = redo();
if (entry?.anchorsAfter) {
valhallaStore.anchors = entry.anchorsAfter;
refreshAnchorLabels();
} else {
clearAnchors();
initRouteAnchors(valhallaStore.route, true);
}
updateTrailWithRouteData();
}
@@ -1498,28 +1763,52 @@
placeholder="{$_('search-places')}..."
items={searchDropdownItems}
></Search>
<hr class="border-input-border" />
<h3 class="text-xl font-semibold">{$_("pick-a-trail")}</h3>
<Button
primary={true}
type="button"
disabled={drawingActive}
onclick={openFileBrowser}
>{$formData.expand?.gpx_data
? $_("upload-new-file")
: $_("upload-file")}</Button
{#if selectedSearchLocation && drawingActive}
<div
class="rounded-xl border border-input-border bg-menu-item-background px-4 py-3 flex flex-col gap-3"
>
<div class="flex gap-4 items-center w-full">
<hr class="basis-full border-input-border" />
<span class="text-gray-500 uppercase">{$_("or")}</span>
<hr class="basis-full border-input-border" />
<div class="flex items-start gap-3">
<button
type="button"
class="flex h-9 w-9 shrink-0 items-center justify-center self-start rounded-full p-0 text-xl text-content hover:bg-secondary-hover"
aria-label={$_("add-as-endpoint")}
title={$_("add-as-endpoint")}
onclick={addSelectedLocationAsEndpoint}
>
<i class="fa fa-flag-checkered"></i>
</button>
<div class="flex-1">
<p class="font-semibold">
{selectedSearchLocation.text}
</p>
{#if selectedSearchLocation.description}
<p class="text-sm text-gray-500">
{selectedSearchLocation.description}
</p>
{/if}
</div>
<button
type="button"
class="btn-icon"
aria-label={$_("clear-all")}
onclick={clearSelectedSearchLocation}
>
<i class="fa fa-close text-sm"></i>
</button>
</div>
</div>
{/if}
<hr class="border-input-border" />
{#if isNewTrail || replacingRoute}
<h3 class="text-xl font-semibold">{$_("pick-a-trail")}</h3>
<button
class="btn-primary"
type="button"
onclick={async () => {
if (drawingActive) {
await stopDrawing();
} else if (replacingRoute) {
startReplacementDrawing();
} else {
startDrawing();
}
@@ -1533,6 +1822,32 @@
? $_("stop-drawing")
: $_("draw-a-route")}</button
>
{/if}
{#if drawingActive && valhallaStore.anchors.length}
<TrailAnchorList
anchors={valhallaStore.anchors}
segments={routeSegments}
disabled={routeAnchorListUpdating}
onMove={moveAnchor}
onDelete={removeAnchor}
onHover={highlightAnchorMarker}
></TrailAnchorList>
{/if}
{#if !drawingActive && (isNewTrail || replacingRoute)}
<div class="flex gap-4 items-center w-full">
<hr class="basis-full border-input-border" />
<span class="text-gray-500 uppercase">{$_("or")}</span>
<hr class="basis-full border-input-border" />
</div>
<Button
primary={true}
type="button"
onclick={openFileBrowser}
>{$formData.expand?.gpx_data
? $_("upload-new-file")
: $_("upload-file")}</Button
>
{/if}
<input
type="file"
name="gpx"
@@ -1801,7 +2116,9 @@
<RouteEditor
bind:options={routingOptions}
onReverse={reverseTrail}
onReset={resetTrail}
onReset={isNewTrail ? resetTrail : requestReplaceRoute}
resetLabel="reset-route"
resetAriaLabel="reset-route"
onCropToggle={toggleCropMarkers}
onCrop={confirmCrop}
onUpdateCropRange={updateCropMarkers}
@@ -1821,10 +2138,12 @@
onmarkerdragend={moveMarker}
activeTrail={0}
bind:map
oninit={handleMapInit}
onclick={(target) => handleMapClick(target)}
onsegmentclick={(data) => handleSegmentClick(data)}
onsegmentdragend={(data) => handleSegmentDragEnd(data)}
mapOptions={{ preserveDrawingBuffer: true }}
{buildPoiAnchorAction}
></MapWithElevationMaplibre>
</div>
</div>
@@ -1853,6 +2172,15 @@
bind:this={markTrailAsCompletedModal}
onconfirm={markTrailAsCompleted}
></ConfirmModal>
<ConfirmModal
id="replace-route-modal"
title={$_("reset-route")}
text={$_("reset-route-confirm")}
action="reset-route"
deny="cancel"
bind:this={replaceRouteModal}
onconfirm={replaceRoute}
></ConfirmModal>
<style>
#trail-map {
@@ -1864,4 +2192,12 @@
height: calc(100vh - 124px);
}
}
:global(.route-anchor.anchor-list-highlight) {
border-color: rgb(255 255 255);
box-shadow:
0 0 0 4px rgba(var(--primary), 0.35),
0 0 0 8px rgba(var(--primary), 0.16);
z-index: 1;
}
</style>