adds undo/redo to route edit

This commit is contained in:
Christian Beutel
2025-08-02 17:00:09 +02:00
parent 353e52b0f6
commit 46d3c45b58
5 changed files with 203 additions and 101 deletions

17
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "wanderer", "name": "wanderer",
"version": "0.17.1", "version": "0.17.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wanderer", "name": "wanderer",
"version": "0.17.1", "version": "0.17.2",
"dependencies": { "dependencies": {
"@felte/validator-zod": "^1.0.18", "@felte/validator-zod": "^1.0.18",
"@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-free": "^6.5.1",
@@ -38,6 +38,7 @@
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"instead": "^1.0.3", "instead": "^1.0.3",
"isomorphic-xml2js": "^0.1.3", "isomorphic-xml2js": "^0.1.3",
"json-diff-ts": "^4.8.1",
"jspdf": "^2.5.1", "jspdf": "^2.5.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"maplibre-gl": "^4.7.1", "maplibre-gl": "^4.7.1",
@@ -2665,9 +2666,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001695", "version": "1.0.30001727",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001695.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
"integrity": "sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==", "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4303,6 +4304,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/json-stringify-pretty-compact": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",

View File

@@ -63,6 +63,7 @@
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"instead": "^1.0.3", "instead": "^1.0.3",
"isomorphic-xml2js": "^0.1.3", "isomorphic-xml2js": "^0.1.3",
"json-diff-ts": "^4.8.1",
"jspdf": "^2.5.1", "jspdf": "^2.5.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"maplibre-gl": "^4.7.1", "maplibre-gl": "^4.7.1",

View File

@@ -3,14 +3,15 @@
RoutingOptions, RoutingOptions,
ValhallaBicycleCostingOptions, ValhallaBicycleCostingOptions,
} from "$lib/models/valhalla"; } from "$lib/models/valhalla";
import { valhallaStore } from "$lib/stores/valhalla_store.svelte";
import { formatSpeed } from "$lib/util/format_util"; import { formatSpeed } from "$lib/util/format_util";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { slide } from "svelte/transition"; 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 Select, { type SelectItem } from "../base/select.svelte";
import Slider from "../base/slider.svelte"; import Slider from "../base/slider.svelte";
import Toggle from "../base/toggle.svelte"; import Toggle from "../base/toggle.svelte";
import Button from "../base/button.svelte";
import DoubleSlider from "../base/double_slider.svelte";
interface Props { interface Props {
options: RoutingOptions; options: RoutingOptions;
onReverse: () => void; onReverse: () => void;
@@ -19,6 +20,8 @@
onUpdateCropRange: (data: [number, number]) => void; onUpdateCropRange: (data: [number, number]) => void;
onCrop: () => void; onCrop: () => void;
onRecalculateElevationData: () => void; onRecalculateElevationData: () => void;
onUndo: () => void;
onRedo: () => void;
} }
let { let {
@@ -29,6 +32,8 @@
onUpdateCropRange, onUpdateCropRange,
onCrop, onCrop,
onRecalculateElevationData, onRecalculateElevationData,
onUndo,
onRedo,
}: Props = $props(); }: Props = $props();
const modesOfTransport: SelectItem[] = [ const modesOfTransport: SelectItem[] = [
@@ -121,6 +126,7 @@
recalculateElevationData = false; recalculateElevationData = false;
crop = false; crop = false;
editRoute = !editRoute; editRoute = !editRoute;
onCropToggle(false);
}}><i class="fa fa-route text-sm"></i></button }}><i class="fa fa-route text-sm"></i></button
> >
<button <button
@@ -142,8 +148,23 @@
recalculateElevationData = !recalculateElevationData; recalculateElevationData = !recalculateElevationData;
crop = false; crop = false;
editRoute = false; editRoute = false;
onCropToggle(false);
}}><i class="fa fa-mountain text-sm"></i></button }}><i class="fa fa-mountain text-sm"></i></button
> >
<button
class="btn-icon"
class:text-gray-500={valhallaStore.undoStack.length == 0}
disabled={valhallaStore.undoStack.length == 0}
aria-label="undo route action"
onclick={onUndo}><i class="fa fa-undo text-sm"></i></button
>
<button
class="btn-icon"
class:text-gray-500={valhallaStore.redoStack.length == 0}
disabled={valhallaStore.redoStack.length == 0}
aria-label="redo route action"
onclick={onRedo}><i class="fa fa-redo text-sm"></i></button
>
</div> </div>
{#if editRoute} {#if editRoute}

View File

@@ -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 { type RoutingOptions, type ValhallaAnchor, type ValhallaHeightResponse, type ValhallaRouteResponse } from "$lib/models/valhalla";
import { APIError } from "$lib/util/api_util"; import { APIError } from "$lib/util/api_util";
import { decodePolyline, encodePolyline } from "$lib/util/polyline_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 type { LngLat } from "maplibre-gl";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { get } from "svelte/store"; import { get } from "svelte/store";
const emtpyTrack: Track = { trkseg: [] } const emtpyTrack: Track = { trkseg: [] }
class ValhallaStore { class ValhallaStore {
route: GPX = $state(new GPX({ trk: [emtpyTrack] })); route: GPX = $state(new GPX({ trk: [emtpyTrack] }));
anchors: ValhallaAnchor[] = $state([]); anchors: ValhallaAnchor[] = $state([]);
undoStack: { delta: Changeset, reverseDelta: Changeset }[] = $state([]);
redoStack: { delta: Changeset, reverseDelta: Changeset }[] = $state([]);
} }
export const valhallaStore = new ValhallaStore(); export const valhallaStore = new ValhallaStore();
export function clearRoute() { export function clearRoute() {
valhallaStore.route = new GPX({ trk: [emtpyTrack] }); valhallaStore.route = new GPX({ trk: [emtpyTrack] });
} }
@@ -32,8 +33,25 @@ export function clearAnchors() {
valhallaStore.anchors = []; valhallaStore.anchors = [];
} }
export function setRoute(newRoute: GPX) { export function clearUndoRedoStack() {
valhallaStore.route = newRoute 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) { 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) { export async function insertIntoRoute(waypoints: Waypoint[], index?: number) {
const snapshot = new GPX({ ...valhallaStore.route })
const segment = new TrackSegment({ trkpt: waypoints }) const segment = new TrackSegment({ trkpt: waypoints })
if (index) { if (index) {
valhallaStore.route.trk?.at(0)?.trkseg?.splice(index, 0, segment); snapshot.trk?.at(0)?.trkseg?.splice(index, 0, segment);
} else { } 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(); valhallaStore.route.features = valhallaStore.route.getTotals();
} }
export async function editRoute(index: number, waypoints: Waypoint[]) { 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) { if (segment) {
segment.trkpt = waypoints 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(); valhallaStore.route.features = valhallaStore.route.getTotals();
} }
export function deleteFromRoute(index: number) { export function deleteFromRoute(index: number) {
valhallaStore.route.trk?.at(0)?.trkseg?.splice(index, 1); const snapshot = new GPX({ ...valhallaStore.route })
valhallaStore.route.features = valhallaStore.route.getTotals();
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() { 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 ?? []) { for (const seg of trk.trkseg ?? []) {
seg.trkpt?.reverse() seg.trkpt?.reverse()
} }
trk.trkseg?.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(); valhallaStore.route.features = valhallaStore.route.getTotals();
@@ -146,7 +193,10 @@ export function reverseRoute() {
} }
export function resetRoute() { 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) => { valhallaStore.anchors.forEach((a) => {
if (!a.marker) { 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 firstSegmentPoints = [...points.slice(0, bestSplitIndex), intersectionPoint];
const secondSegmentPoints = [intersectionPoint, ...points.slice(bestSplitIndex)]; const secondSegmentPoints = [intersectionPoint, ...points.slice(bestSplitIndex)];
@@ -209,3 +259,25 @@ export function normalizeRouteTime() {
currentTime = new Date(seg.trkpt[seg.trkpt.length - 1].time!.getTime()); currentTime = new Date(seg.trkpt[seg.trkpt.length - 1].time!.getTime());
} }
} }
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();
}

View File

@@ -48,6 +48,9 @@
reverseRoute, reverseRoute,
setRoute, setRoute,
splitSegment, splitSegment,
undo,
redo,
clearUndoRedoStack,
} from "$lib/stores/valhalla_store.svelte.js"; } from "$lib/stores/valhalla_store.svelte.js";
import { waypoint } from "$lib/stores/waypoint_store"; import { waypoint } from "$lib/stores/waypoint_store";
import { getFileURL } from "$lib/util/file_util"; import { getFileURL } from "$lib/util/file_util";
@@ -125,8 +128,6 @@
let cropStartMarker: FontawesomeMarker; let cropStartMarker: FontawesomeMarker;
let cropEndMarker: FontawesomeMarker; let cropEndMarker: FontawesomeMarker;
let flatRoute: GPXWaypoint[] = $derived(valhallaStore.route.flatten());
let croppedGPX: GPX | null = null; let croppedGPX: GPX | null = null;
const ClientTrailCreateSchema = TrailCreateSchema.extend({ const ClientTrailCreateSchema = TrailCreateSchema.extend({
@@ -261,8 +262,9 @@
}); });
onMount(async () => { onMount(async () => {
clearAnchorMarker(); clearAnchors();
clearRoute(); clearRoute();
clearUndoRedoStack();
if ($formData.expand!.gpx_data) { if ($formData.expand!.gpx_data) {
const gpx = await GPX.parse($formData.expand!.gpx_data); const gpx = await GPX.parse($formData.expand!.gpx_data);
@@ -300,7 +302,8 @@
} }
clearWaypoints(); clearWaypoints();
clearAnchorMarker(); clearAnchors();
clearUndoRedoStack();
clearRoute(); clearRoute();
drawingActive = false; drawingActive = false;
overwriteGPX = false; overwriteGPX = false;
@@ -354,7 +357,6 @@
} }
setRoute(parseResult.gpx); setRoute(parseResult.gpx);
initRouteAnchors(parseResult.gpx); initRouteAnchors(parseResult.gpx);
initCropMarkers();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -380,10 +382,6 @@
$formData.waypoints = []; $formData.waypoints = [];
} }
function clearAnchorMarker() {
clearAnchors();
}
function initRouteAnchors(gpx: GPX, addToMap: boolean = false) { function initRouteAnchors(gpx: GPX, addToMap: boolean = false) {
const segments = gpx.trk?.at(0)?.trkseg ?? []; 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) { function openMarkerPopup(waypoint: Waypoint) {
waypoint.marker?.togglePopup(); waypoint.marker?.togglePopup();
} }
@@ -600,6 +548,7 @@
} }
function startDrawing() { function startDrawing() {
console.log("starting drawing...");
if (!map) { if (!map) {
return; return;
} }
@@ -612,28 +561,32 @@
} }
async function stopDrawing() { async function stopDrawing() {
console.log("stopping drawing...");
drawingActive = false; drawingActive = false;
for (const anchor of valhallaStore.anchors) { // for (const anchor of valhallaStore.anchors) {
anchor.marker?.remove(); // anchor.marker?.remove();
} // }
toggleCropMarkers(false); // toggleCropMarkers(false);
// clearUndoRedoStack();
if (valhallaStore.route.trk?.at(0)?.trkseg?.at(0)?.trkpt?.at(0)) { // if (valhallaStore.route.trk?.at(0)?.trkseg?.at(0)?.trkpt?.at(0)) {
$formData.lat = valhallaStore.route.trk // $formData.lat = valhallaStore.route.trk
?.at(0) // ?.at(0)
?.trkseg?.at(0) // ?.trkseg?.at(0)
?.trkpt?.at(0)?.$.lat; // ?.trkpt?.at(0)?.$.lat;
$formData.lon = valhallaStore.route.trk // $formData.lon = valhallaStore.route.trk
?.at(0) // ?.at(0)
?.trkseg?.at(0) // ?.trkseg?.at(0)
?.trkpt?.at(0)?.$.lon; // ?.trkpt?.at(0)?.$.lon;
} // }
const r = await searchLocationReverse($formData.lat!, $formData.lon!); // if ($formData.lat && $formData.lon) {
// const r = await searchLocationReverse($formData.lat, $formData.lon);
if (r) { // if (r) {
setFields("location", r); // setFields("location", r);
} // }
// }
} }
async function handleMapClick(e: M.MapMouseEvent) { async function handleMapClick(e: M.MapMouseEvent) {
@@ -984,8 +937,40 @@
} }
function updateCropMarkers(range: [start: number, end: number]) { 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 [start, end] = range;
const flatRoute = valhallaStore.route.flatten();
const targetStartDistance = const targetStartDistance =
valhallaStore.route.features.distance * (start / 100); valhallaStore.route.features.distance * (start / 100);
const [startLon, startLat, startIndex] = getCoordinateAtDistance( const [startLon, startLat, startIndex] = getCoordinateAtDistance(
@@ -1021,9 +1006,9 @@
if (!croppedGPX) { if (!croppedGPX) {
return; return;
} }
setRoute(croppedGPX); setRoute(croppedGPX, true);
updateTrailWithRouteData(); updateTrailWithRouteData();
clearAnchorMarker(); clearAnchors();
initRouteAnchors(croppedGPX, true); 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);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -1488,6 +1487,8 @@
onCrop={confirmCrop} onCrop={confirmCrop}
onUpdateCropRange={updateCropMarkers} onUpdateCropRange={updateCropMarkers}
onRecalculateElevationData={recalculateElevationData} onRecalculateElevationData={recalculateElevationData}
onUndo={undoRouteEdit}
onRedo={redoRouteEdit}
></RouteEditor> ></RouteEditor>
</div> </div>
{/if} {/if}