From 46d3c45b583fe9d7faf5e11d6b9665578c828c07 Mon Sep 17 00:00:00 2001
From: Christian Beutel <>
Date: Sat, 2 Aug 2025 17:00:09 +0200
Subject: [PATCH] adds undo/redo to route edit
---
web/package-lock.json | 17 +-
web/package.json | 1 +
.../lib/components/trail/route_editor.svelte | 25 ++-
web/src/lib/stores/valhalla_store.svelte.ts | 100 +++++++++--
web/src/routes/trail/edit/[id]/+page.svelte | 161 +++++++++---------
5 files changed, 203 insertions(+), 101 deletions(-)
diff --git a/web/package-lock.json b/web/package-lock.json
index 73155c4f..91f9dd2b 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "wanderer",
- "version": "0.17.1",
+ "version": "0.17.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wanderer",
- "version": "0.17.1",
+ "version": "0.17.2",
"dependencies": {
"@felte/validator-zod": "^1.0.18",
"@fortawesome/fontawesome-free": "^6.5.1",
@@ -38,6 +38,7 @@
"heic2any": "^0.0.4",
"instead": "^1.0.3",
"isomorphic-xml2js": "^0.1.3",
+ "json-diff-ts": "^4.8.1",
"jspdf": "^2.5.1",
"jszip": "^3.10.1",
"maplibre-gl": "^4.7.1",
@@ -2665,9 +2666,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001695",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001695.tgz",
- "integrity": "sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==",
+ "version": "1.0.30001727",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
+ "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
"dev": true,
"funding": [
{
@@ -4303,6 +4304,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-diff-ts": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/json-diff-ts/-/json-diff-ts-4.8.1.tgz",
+ "integrity": "sha512-Bjs+7bgxkolosAL1n/29XJXCXByAVMdhARkRk32HpJ2IuCvZ/KpqT79ljlYZZRlcN6JE0ygNxRPR+mEMPQBshw==",
+ "license": "MIT"
+ },
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
diff --git a/web/package.json b/web/package.json
index a03a1939..28d4c946 100644
--- a/web/package.json
+++ b/web/package.json
@@ -63,6 +63,7 @@
"heic2any": "^0.0.4",
"instead": "^1.0.3",
"isomorphic-xml2js": "^0.1.3",
+ "json-diff-ts": "^4.8.1",
"jspdf": "^2.5.1",
"jszip": "^3.10.1",
"maplibre-gl": "^4.7.1",
diff --git a/web/src/lib/components/trail/route_editor.svelte b/web/src/lib/components/trail/route_editor.svelte
index cf2598da..7605442e 100644
--- a/web/src/lib/components/trail/route_editor.svelte
+++ b/web/src/lib/components/trail/route_editor.svelte
@@ -3,14 +3,15 @@
RoutingOptions,
ValhallaBicycleCostingOptions,
} from "$lib/models/valhalla";
+ import { valhallaStore } from "$lib/stores/valhalla_store.svelte";
import { formatSpeed } from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import { slide } from "svelte/transition";
+ import Button from "../base/button.svelte";
+ import DoubleSlider from "../base/double_slider.svelte";
import Select, { type SelectItem } from "../base/select.svelte";
import Slider from "../base/slider.svelte";
import Toggle from "../base/toggle.svelte";
- import Button from "../base/button.svelte";
- import DoubleSlider from "../base/double_slider.svelte";
interface Props {
options: RoutingOptions;
onReverse: () => void;
@@ -19,6 +20,8 @@
onUpdateCropRange: (data: [number, number]) => void;
onCrop: () => void;
onRecalculateElevationData: () => void;
+ onUndo: () => void;
+ onRedo: () => void;
}
let {
@@ -29,6 +32,8 @@
onUpdateCropRange,
onCrop,
onRecalculateElevationData,
+ onUndo,
+ onRedo,
}: Props = $props();
const modesOfTransport: SelectItem[] = [
@@ -121,6 +126,7 @@
recalculateElevationData = false;
crop = false;
editRoute = !editRoute;
+ onCropToggle(false);
}}>
+
+
{#if editRoute}
diff --git a/web/src/lib/stores/valhalla_store.svelte.ts b/web/src/lib/stores/valhalla_store.svelte.ts
index 82e43045..f300c7fe 100644
--- a/web/src/lib/stores/valhalla_store.svelte.ts
+++ b/web/src/lib/stores/valhalla_store.svelte.ts
@@ -6,21 +6,22 @@ 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 { applyChangeset, diff, revertChangeset, type Changeset } from 'json-diff-ts';
import type { LngLat } from "maplibre-gl";
import { _ } from "svelte-i18n";
import { get } from "svelte/store";
-
const emtpyTrack: 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([]);
}
export const valhallaStore = new ValhallaStore();
-
export function clearRoute() {
valhallaStore.route = new GPX({ trk: [emtpyTrack] });
}
@@ -32,8 +33,25 @@ export function clearAnchors() {
valhallaStore.anchors = [];
}
-export function setRoute(newRoute: GPX) {
- valhallaStore.route = newRoute
+export function clearUndoRedoStack() {
+ valhallaStore.undoStack = []
+ valhallaStore.redoStack = []
+}
+
+function pushToUndoStack(delta: Changeset, reverseDelta: Changeset) {
+ valhallaStore.undoStack.push({ delta, reverseDelta })
+ valhallaStore.redoStack = []
+}
+
+
+export function setRoute(newRoute: GPX, undoable: boolean = false) {
+ const delta = diff(valhallaStore.route, newRoute);
+ const reverseDelta = diff(newRoute, valhallaStore.route);
+ valhallaStore.route = applyChangeset(valhallaStore.route, delta);
+ if (undoable) {
+ pushToUndoStack(delta, reverseDelta)
+ }
+
}
export async function calculateRouteBetween(startLat: number, startLon: number, endLat: number, endLon: number, options: RoutingOptions) {
@@ -92,38 +110,67 @@ export async function calculateRouteBetween(startLat: number, startLon: number,
}
export async function insertIntoRoute(waypoints: Waypoint[], index?: number) {
+ const snapshot = new GPX({ ...valhallaStore.route })
const segment = new TrackSegment({ trkpt: waypoints })
if (index) {
- valhallaStore.route.trk?.at(0)?.trkseg?.splice(index, 0, segment);
+ snapshot.trk?.at(0)?.trkseg?.splice(index, 0, segment);
} else {
- valhallaStore.route.trk?.at(0)?.trkseg?.push(segment);
+ snapshot.trk?.at(0)?.trkseg?.push(segment);
}
+ const delta = diff(valhallaStore.route, snapshot);
+ const reverseDelta = diff(snapshot, valhallaStore.route);
+ valhallaStore.route = applyChangeset(valhallaStore.route, delta);
+ pushToUndoStack(delta, reverseDelta)
+
valhallaStore.route.features = valhallaStore.route.getTotals();
}
export async function editRoute(index: number, waypoints: Waypoint[]) {
- const segment = valhallaStore.route.trk?.at(0)?.trkseg?.at(index)
+ const snapshot = new GPX({ ...valhallaStore.route })
+
+ const segment = snapshot.trk?.at(0)?.trkseg?.at(index)
if (segment) {
segment.trkpt = waypoints
}
+
+ const delta = diff(valhallaStore.route, snapshot);
+ const reverseDelta = diff(snapshot, valhallaStore.route)
+ valhallaStore.route = applyChangeset(valhallaStore.route, delta);
+ pushToUndoStack(delta, reverseDelta)
+
+
+
valhallaStore.route.features = valhallaStore.route.getTotals();
}
export function deleteFromRoute(index: number) {
- valhallaStore.route.trk?.at(0)?.trkseg?.splice(index, 1);
- valhallaStore.route.features = valhallaStore.route.getTotals();
+ const snapshot = new GPX({ ...valhallaStore.route })
+
+ snapshot.trk?.at(0)?.trkseg?.splice(index, 1);
+ snapshot.features = valhallaStore.route.getTotals();
+
+ const delta = diff(valhallaStore.route, snapshot);
+ const reverseDelta = diff(snapshot, valhallaStore.route)
+ valhallaStore.route = applyChangeset(valhallaStore.route, delta);
+ pushToUndoStack(delta, reverseDelta)
}
export function reverseRoute() {
- for (const trk of valhallaStore.route.trk ?? []) {
+ const snapshot = new GPX({ ...valhallaStore.route })
+ for (const trk of snapshot.trk ?? []) {
for (const seg of trk.trkseg ?? []) {
seg.trkpt?.reverse()
}
trk.trkseg?.reverse()
}
- valhallaStore.route.trk?.reverse()
+ snapshot.trk?.reverse()
+
+ const delta = diff(valhallaStore.route, snapshot);
+ const reverseDelta = diff(snapshot, valhallaStore.route);
+ valhallaStore.route = applyChangeset(valhallaStore.route, delta);
+ pushToUndoStack(delta, reverseDelta)
valhallaStore.route.features = valhallaStore.route.getTotals();
@@ -146,7 +193,10 @@ export function reverseRoute() {
}
export function resetRoute() {
- valhallaStore.route = new GPX({ trk: [{ ...emtpyTrack }] });
+ const delta = diff(valhallaStore.route, new GPX({ trk: [{ ...emtpyTrack }] }));
+ const reverseDelta = diff(new GPX({ trk: [{ ...emtpyTrack }] }), valhallaStore.route);
+ valhallaStore.route = applyChangeset(valhallaStore.route, delta);
+ pushToUndoStack(delta, reverseDelta)
valhallaStore.anchors.forEach((a) => {
if (!a.marker) {
@@ -182,7 +232,7 @@ export async function splitSegment(index: number, pos: LngLat) {
}
}
- const intersectionPoint = new Waypoint({ $: { lat: pos.lat, lon: pos.lng}, ele: points[bestSplitIndex].ele });
+ const intersectionPoint = new Waypoint({ $: { lat: pos.lat, lon: pos.lng }, ele: points[bestSplitIndex].ele });
const firstSegmentPoints = [...points.slice(0, bestSplitIndex), intersectionPoint];
const secondSegmentPoints = [intersectionPoint, ...points.slice(bestSplitIndex)];
@@ -208,4 +258,26 @@ export function normalizeRouteTime() {
}
currentTime = new Date(seg.trkpt[seg.trkpt.length - 1].time!.getTime());
}
-}
\ No newline at end of file
+}
+
+export function undo() {
+ const historyItem = valhallaStore.undoStack.pop()
+ if (!historyItem) {
+ return
+ }
+ valhallaStore.redoStack.push(historyItem)
+
+ valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.reverseDelta);
+ valhallaStore.route.features = valhallaStore.route.getTotals();
+}
+
+export function redo() {
+ const historyItem = valhallaStore.redoStack.pop()
+ if (!historyItem) {
+ return
+ }
+ valhallaStore.undoStack.push(historyItem)
+
+ valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.delta);
+ valhallaStore.route.features = valhallaStore.route.getTotals();
+}
diff --git a/web/src/routes/trail/edit/[id]/+page.svelte b/web/src/routes/trail/edit/[id]/+page.svelte
index 304eaf5e..b6ec772c 100644
--- a/web/src/routes/trail/edit/[id]/+page.svelte
+++ b/web/src/routes/trail/edit/[id]/+page.svelte
@@ -48,6 +48,9 @@
reverseRoute,
setRoute,
splitSegment,
+ undo,
+ redo,
+ clearUndoRedoStack,
} from "$lib/stores/valhalla_store.svelte.js";
import { waypoint } from "$lib/stores/waypoint_store";
import { getFileURL } from "$lib/util/file_util";
@@ -125,8 +128,6 @@
let cropStartMarker: FontawesomeMarker;
let cropEndMarker: FontawesomeMarker;
- let flatRoute: GPXWaypoint[] = $derived(valhallaStore.route.flatten());
-
let croppedGPX: GPX | null = null;
const ClientTrailCreateSchema = TrailCreateSchema.extend({
@@ -261,8 +262,9 @@
});
onMount(async () => {
- clearAnchorMarker();
+ clearAnchors();
clearRoute();
+ clearUndoRedoStack();
if ($formData.expand!.gpx_data) {
const gpx = await GPX.parse($formData.expand!.gpx_data);
@@ -300,7 +302,8 @@
}
clearWaypoints();
- clearAnchorMarker();
+ clearAnchors();
+ clearUndoRedoStack();
clearRoute();
drawingActive = false;
overwriteGPX = false;
@@ -354,7 +357,6 @@
}
setRoute(parseResult.gpx);
initRouteAnchors(parseResult.gpx);
- initCropMarkers();
} catch (e) {
console.error(e);
@@ -380,10 +382,6 @@
$formData.waypoints = [];
}
- function clearAnchorMarker() {
- clearAnchors();
- }
-
function initRouteAnchors(gpx: GPX, addToMap: boolean = false) {
const segments = gpx.trk?.at(0)?.trkseg ?? [];
@@ -410,56 +408,6 @@
}
}
- function initCropMarkers() {
- const routeStartPoint: M.LngLatLike = [
- valhallaStore.route.trk?.at(0)?.trkseg?.at(0)?.trkpt?.at(0)?.$
- .lon ?? 0,
- valhallaStore.route.trk?.at(0)?.trkseg?.at(0)?.trkpt?.at(0)?.$
- .lat ?? 0,
- ];
- const routeEndPoint: M.LngLatLike = [
- valhallaStore.route.trk?.at(-1)?.trkseg?.at(-1)?.trkpt?.at(-1)?.$
- .lon ?? 0,
- valhallaStore.route.trk?.at(-1)?.trkseg?.at(-1)?.trkpt?.at(-1)?.$
- .lat ?? 0,
- ];
- if (!cropStartMarker || !cropEndMarker) {
- cropStartMarker = new FontawesomeMarker(
- {
- id: "crop-start-marker",
- icon: "fa-regular fa-circle",
- fontSize: "xs",
- style: "w-6",
- width: 4,
- backgroundColor: "bg-primary",
- fontColor: "white",
- },
- {},
- );
- cropEndMarker = new FontawesomeMarker(
- {
- id: "crop-end-marker",
- icon: "fa fa-flag-checkered",
- fontSize: "xs",
- style: "w-6",
- width: 4,
- backgroundColor: "bg-primary",
- fontColor: "white",
- },
- {},
- );
-
- cropStartMarker
- .setOpacity("0")
- .setLngLat(routeStartPoint)
- .addTo(map!);
- cropEndMarker.setOpacity("0").setLngLat(routeEndPoint).addTo(map!);
- } else {
- cropStartMarker.setLngLat(routeStartPoint);
- cropEndMarker.setLngLat(routeEndPoint);
- }
- }
-
function openMarkerPopup(waypoint: Waypoint) {
waypoint.marker?.togglePopup();
}
@@ -600,6 +548,7 @@
}
function startDrawing() {
+ console.log("starting drawing...");
if (!map) {
return;
}
@@ -612,28 +561,32 @@
}
async function stopDrawing() {
+ console.log("stopping drawing...");
+
drawingActive = false;
- for (const anchor of valhallaStore.anchors) {
- anchor.marker?.remove();
- }
- toggleCropMarkers(false);
+ // for (const anchor of valhallaStore.anchors) {
+ // anchor.marker?.remove();
+ // }
+ // toggleCropMarkers(false);
+ // clearUndoRedoStack();
- if (valhallaStore.route.trk?.at(0)?.trkseg?.at(0)?.trkpt?.at(0)) {
- $formData.lat = valhallaStore.route.trk
- ?.at(0)
- ?.trkseg?.at(0)
- ?.trkpt?.at(0)?.$.lat;
- $formData.lon = valhallaStore.route.trk
- ?.at(0)
- ?.trkseg?.at(0)
- ?.trkpt?.at(0)?.$.lon;
- }
+ // if (valhallaStore.route.trk?.at(0)?.trkseg?.at(0)?.trkpt?.at(0)) {
+ // $formData.lat = valhallaStore.route.trk
+ // ?.at(0)
+ // ?.trkseg?.at(0)
+ // ?.trkpt?.at(0)?.$.lat;
+ // $formData.lon = valhallaStore.route.trk
+ // ?.at(0)
+ // ?.trkseg?.at(0)
+ // ?.trkpt?.at(0)?.$.lon;
+ // }
- const r = await searchLocationReverse($formData.lat!, $formData.lon!);
-
- if (r) {
- setFields("location", r);
- }
+ // if ($formData.lat && $formData.lon) {
+ // const r = await searchLocationReverse($formData.lat, $formData.lon);
+ // if (r) {
+ // setFields("location", r);
+ // }
+ // }
}
async function handleMapClick(e: M.MapMouseEvent) {
@@ -984,8 +937,40 @@
}
function updateCropMarkers(range: [start: number, end: number]) {
+ if (!cropStartMarker || !cropEndMarker) {
+ cropStartMarker = new FontawesomeMarker(
+ {
+ id: "crop-start-marker",
+ icon: "fa-regular fa-circle",
+ fontSize: "xs",
+ style: "w-6",
+ width: 4,
+ backgroundColor: "bg-primary",
+ fontColor: "white",
+ },
+ {},
+ );
+ cropEndMarker = new FontawesomeMarker(
+ {
+ id: "crop-end-marker",
+ icon: "fa fa-flag-checkered",
+ fontSize: "xs",
+ style: "w-6",
+ width: 4,
+ backgroundColor: "bg-primary",
+ fontColor: "white",
+ },
+ {},
+ );
+
+ cropStartMarker.setLngLat([0, 0]).addTo(map!);
+ cropEndMarker.setLngLat([0, 0]).addTo(map!);
+ }
+
const [start, end] = range;
+ const flatRoute = valhallaStore.route.flatten();
+
const targetStartDistance =
valhallaStore.route.features.distance * (start / 100);
const [startLon, startLat, startIndex] = getCoordinateAtDistance(
@@ -1021,9 +1006,9 @@
if (!croppedGPX) {
return;
}
- setRoute(croppedGPX);
+ setRoute(croppedGPX, true);
updateTrailWithRouteData();
- clearAnchorMarker();
+ clearAnchors();
initRouteAnchors(croppedGPX, true);
}
@@ -1167,6 +1152,20 @@
}
}
}
+
+ function undoRouteEdit() {
+ undo();
+ updateTrailWithRouteData();
+ clearAnchors();
+ initRouteAnchors(valhallaStore.route, true);
+ }
+
+ function redoRouteEdit() {
+ redo();
+ updateTrailWithRouteData();
+ clearAnchors();
+ initRouteAnchors(valhallaStore.route, true);
+ }
@@ -1488,6 +1487,8 @@
onCrop={confirmCrop}
onUpdateCropRange={updateCropMarkers}
onRecalculateElevationData={recalculateElevationData}
+ onUndo={undoRouteEdit}
+ onRedo={redoRouteEdit}
>
{/if}