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