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

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

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);
lat: String(lat),
lon: String(lon),
});
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`;
}
@@ -148,4 +158,4 @@ export function formatHTMLAsText(html?: string) {
// Trim the result
return text.trim();
}
}

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;
}
@@ -336,4 +379,4 @@ export function calculateScaleFactor(map: M.Map) {
const scaleFactor = realWorldMetersPer100Pixels / screenMetersPer100Pixels
return scaleFactor
}
}

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