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}