diff --git a/gpx/src/gpx.ts b/gpx/src/gpx.ts index bc083ee5..39662e5c 100644 --- a/gpx/src/gpx.ts +++ b/gpx/src/gpx.ts @@ -15,6 +15,7 @@ export abstract class GPXTreeElement> { abstract isLeaf(): boolean; abstract get children(): ReadonlyArray; + abstract getNumberOfTrackPoints(): number; abstract getStartTimestamp(): Date; abstract getEndTimestamp(): Date; abstract getStatistics(): GPXStatistics; @@ -34,6 +35,10 @@ abstract class GPXTreeNode> extends GPXTreeElement return false; } + getNumberOfTrackPoints(): number { + return this.children.reduce((acc, child) => acc + child.getNumberOfTrackPoints(), 0); + } + getStartTimestamp(): Date { return this.children[0].getStartTimestamp(); } @@ -241,6 +246,34 @@ export class GPXFile extends GPXTreeNode{ draft.trk = freeze(trk); // Pre-freeze the array, faster as well }); } + + crop(start: number, end: number, trackIndices?: number[], segmentIndices?: number[]) { + return produce(this, (draft) => { + let og = getOriginal(draft); // Read as much as possible from the original object because it is faster + let trk = og.trk.slice(); + let i = 0; + let trackIndex = 0; + while (i < trk.length) { + let length = trk[i].getNumberOfTrackPoints(); + if (trackIndices === undefined || trackIndices.includes(trackIndex)) { + if (start >= length || end < 0) { + trk.splice(i, 1); + } else { + if (start > 0 || end < length - 1) { + trk[i] = trk[i].crop(Math.max(0, start), Math.min(length - 1, end), segmentIndices); + } + i++; + } + start -= length; + end -= length; + } else { + i++; + } + trackIndex++; + } + draft.trk = freeze(trk); // Pre-freeze the array, faster as well + }); + } }; // A class that represents a Track in a GPX file @@ -353,6 +386,34 @@ export class Track extends GPXTreeNode { draft.trkseg = freeze(trkseg); // Pre-freeze the array, faster as well }); } + + crop(start: number, end: number, segmentIndices?: number[]) { + return produce(this, (draft) => { + let og = getOriginal(draft); // Read as much as possible from the original object because it is faster + let trkseg = og.trkseg.slice(); + let i = 0; + let segmentIndex = 0; + while (i < trkseg.length) { + let length = trkseg[i].getNumberOfTrackPoints(); + if (segmentIndices === undefined || segmentIndices.includes(segmentIndex)) { + if (start >= length || end < 0) { + trkseg.splice(i, 1); + } else { + if (start > 0 || end < length - 1) { + trkseg[i] = trkseg[i].crop(Math.max(0, start), Math.min(length - 1, end)); + } + i++; + } + start -= length; + end -= length; + } else { + i++; + } + segmentIndex++; + } + draft.trkseg = freeze(trkseg); // Pre-freeze the array, faster as well + }); + } }; // A class that represents a TrackSegment in a GPX file @@ -393,14 +454,14 @@ export class TrackSegment extends GPXTreeLeaf { statistics.global.distance.total += dist; } - statistics.local.distance.push(statistics.global.distance.total); + statistics.local.distance.total.push(statistics.global.distance.total); // elevation if (i > 0) { const ele = statistics.local.elevation.smoothed[i] - statistics.local.elevation.smoothed[i - 1]; if (ele > 0) { statistics.global.elevation.gain += ele; - } else { + } else if (ele < 0) { statistics.global.elevation.loss -= ele; } } @@ -410,9 +471,9 @@ export class TrackSegment extends GPXTreeLeaf { // time if (points[0].time !== undefined && points[i].time !== undefined) { - const time = (points[i].time.getTime() - points[0].time.getTime()) / 1000; - - statistics.local.time.push(time); + statistics.local.time.total.push((points[i].time.getTime() - points[0].time.getTime()) / 1000); + } else { + statistics.local.time.total.push(undefined); } // speed @@ -427,6 +488,9 @@ export class TrackSegment extends GPXTreeLeaf { } } + statistics.local.distance.moving.push(statistics.global.distance.moving); + statistics.local.time.moving.push(statistics.global.time.moving); + // bounds statistics.global.bounds.southWest.lat = Math.min(statistics.global.bounds.southWest.lat, points[i].attributes.lat); statistics.global.bounds.southWest.lon = Math.min(statistics.global.bounds.southWest.lon, points[i].attributes.lon); @@ -434,9 +498,9 @@ export class TrackSegment extends GPXTreeLeaf { statistics.global.bounds.northEast.lon = Math.max(statistics.global.bounds.northEast.lon, points[i].attributes.lon); } - statistics.global.time.total = statistics.local.time[statistics.local.time.length - 1]; - statistics.global.speed.total = statistics.global.distance.total / (statistics.global.time.total / 3600); - statistics.global.speed.moving = statistics.global.distance.moving / (statistics.global.time.moving / 3600); + statistics.global.time.total = statistics.local.time.total[statistics.local.time.total.length - 1] ?? 0; + statistics.global.speed.total = statistics.global.time.total > 0 ? statistics.global.distance.total / (statistics.global.time.total / 3600) : 0; + statistics.global.speed.moving = statistics.global.time.moving > 0 ? statistics.global.distance.moving / (statistics.global.time.moving / 3600) : 0; statistics.local.speed = distanceWindowSmoothingWithDistanceAccumulator(points, 200, (accumulated, start, end) => (points[start].time && points[end].time) ? 3600 * accumulated / (points[end].time.getTime() - points[start].time.getTime()) : undefined); @@ -462,6 +526,10 @@ export class TrackSegment extends GPXTreeLeaf { return distanceWindowSmoothingWithDistanceAccumulator(points, 50, (accumulated, start, end) => 100 * (points[end].ele - points[start].ele) / accumulated); } + getNumberOfTrackPoints(): number { + return this.trkpt.length; + } + getStartTimestamp(): Date { return this.trkpt[0].time; } @@ -540,6 +608,14 @@ export class TrackSegment extends GPXTreeLeaf { } }); } + + crop(start: number, end: number) { + return produce(this, (draft) => { + let og = getOriginal(draft); // Read as much as possible from the original object because it is faster + let trkpt = og.trkpt.slice(start, end + 1); + draft.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well + }); + } }; export class TrackPoint { @@ -718,8 +794,14 @@ export class GPXStatistics { }; local: { points: TrackPoint[], - distance: number[], - time: number[], + distance: { + moving: number[], + total: number[], + }, + time: { + moving: number[], + total: number[], + }, speed: number[], elevation: { smoothed: number[], @@ -760,8 +842,14 @@ export class GPXStatistics { }; this.local = { points: [], - distance: [], - time: [], + distance: { + moving: [], + total: [], + }, + time: { + moving: [], + total: [], + }, speed: [], elevation: { smoothed: [], @@ -773,11 +861,12 @@ export class GPXStatistics { } mergeWith(other: GPXStatistics): void { - this.local.points = this.local.points.concat(other.local.points); - this.local.distance = this.local.distance.concat(other.local.distance.map((distance) => distance + this.global.distance.total)); - this.local.time = this.local.time.concat(other.local.time.map((time) => time + this.global.time.total)); + this.local.distance.total = this.local.distance.total.concat(other.local.distance.total.map((distance) => distance + this.global.distance.total)); + this.local.distance.moving = this.local.distance.moving.concat(other.local.distance.moving.map((distance) => distance + this.global.distance.moving)); + this.local.time.total = this.local.time.total.concat(other.local.time.total.map((time) => time + this.global.time.total)); + this.local.time.moving = this.local.time.moving.concat(other.local.time.moving.map((time) => time + this.global.time.moving)); this.local.elevation.gain = this.local.elevation.gain.concat(other.local.elevation.gain.map((gain) => gain + this.global.elevation.gain)); this.local.elevation.loss = this.local.elevation.loss.concat(other.local.elevation.loss.map((loss) => loss + this.global.elevation.loss)); @@ -791,8 +880,8 @@ export class GPXStatistics { this.global.time.total += other.global.time.total; this.global.time.moving += other.global.time.moving; - this.global.speed.moving = this.global.distance.moving / (this.global.time.moving / 3600); - this.global.speed.total = this.global.distance.total / (this.global.time.total / 3600); + this.global.speed.moving = this.global.time.moving > 0 ? this.global.distance.moving / (this.global.time.moving / 3600) : 0; + this.global.speed.total = this.global.time.total > 0 ? this.global.distance.total / (this.global.time.total / 3600) : 0; this.global.elevation.gain += other.global.elevation.gain; this.global.elevation.loss += other.global.elevation.loss; @@ -802,6 +891,31 @@ export class GPXStatistics { this.global.bounds.northEast.lat = Math.max(this.global.bounds.northEast.lat, other.global.bounds.northEast.lat); this.global.bounds.northEast.lon = Math.max(this.global.bounds.northEast.lon, other.global.bounds.northEast.lon); } + + slice(start: number, end: number): GPXStatistics { + let statistics = new GPXStatistics(); + + statistics.local.points = this.local.points.slice(start, end); + + statistics.global.distance.total = this.local.distance.total[end - 1] - this.local.distance.total[start]; + statistics.global.distance.moving = this.local.distance.moving[end - 1] - this.local.distance.moving[start]; + + statistics.global.time.total = this.local.time.total[end - 1] - this.local.time.total[start]; + statistics.global.time.moving = this.local.time.moving[end - 1] - this.local.time.moving[start]; + + statistics.global.speed.moving = statistics.global.time.moving > 0 ? statistics.global.distance.moving / (statistics.global.time.moving / 3600) : 0; + statistics.global.speed.total = statistics.global.time.total > 0 ? statistics.global.distance.total / (statistics.global.time.total / 3600) : 0; + + statistics.global.elevation.gain = this.local.elevation.gain[end - 1] - this.local.elevation.gain[start]; + statistics.global.elevation.loss = this.local.elevation.loss[end - 1] - this.local.elevation.loss[start]; + + statistics.global.bounds.southWest.lat = this.global.bounds.southWest.lat; + statistics.global.bounds.southWest.lon = this.global.bounds.southWest.lon; + statistics.global.bounds.northEast.lat = this.global.bounds.northEast.lat; + statistics.global.bounds.northEast.lon = this.global.bounds.northEast.lon; + + return statistics; + } } const earthRadius = 6371008.8; diff --git a/website/package-lock.json b/website/package-lock.json index 76ad738b..a51b4f68 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "dependencies": { "@mapbox/mapbox-gl-geocoder": "^5.0.2", - "bits-ui": "^0.21.5", + "bits-ui": "^0.21.10", "chart.js": "^4.4.2", "clsx": "^2.1.0", "dexie": "^4.0.4", @@ -1882,9 +1882,9 @@ } }, "node_modules/bits-ui": { - "version": "0.21.7", - "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.21.7.tgz", - "integrity": "sha512-1PKp90ly1R6jexIiAUj1Dk4u2pln7ok+L8Vc0rHMY7pi7YZvadFNZvkp1G5BtmL8qh2xsn4MVNgKjPAQMCxW0A==", + "version": "0.21.10", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.21.10.tgz", + "integrity": "sha512-KuweEOKO0Rr8XX87dQh46G9mG0bZSmTqNxj5qBazz4OTQC+oPKui04/wP/ISsCOSGFomaRydTULqh4p+nsyc2g==", "dependencies": { "@internationalized/date": "^3.5.1", "@melt-ui/svelte": "0.76.2", diff --git a/website/package.json b/website/package.json index 8af4f5c9..0cb101eb 100644 --- a/website/package.json +++ b/website/package.json @@ -42,7 +42,7 @@ "type": "module", "dependencies": { "@mapbox/mapbox-gl-geocoder": "^5.0.2", - "bits-ui": "^0.21.5", + "bits-ui": "^0.21.10", "chart.js": "^4.4.2", "clsx": "^2.1.0", "dexie": "^4.0.4", diff --git a/website/src/lib/components/ElevationProfile.svelte b/website/src/lib/components/ElevationProfile.svelte index 8e050a70..74c9b1b5 100644 --- a/website/src/lib/components/ElevationProfile.svelte +++ b/website/src/lib/components/ElevationProfile.svelte @@ -242,7 +242,7 @@ label: $_('quantities.elevation'), data: data.local.points.map((point, index) => { return { - x: getConvertedDistance(data.local.distance[index]), + x: getConvertedDistance(data.local.distance.total[index]), y: point.ele ? getConvertedElevation(point.ele) : 0, slope: data.local.slope[index], surface: point.getSurface(), @@ -257,7 +257,7 @@ label: datasets.speed.getLabel(), data: data.local.points.map((point, index) => { return { - x: getConvertedDistance(data.local.distance[index]), + x: getConvertedDistance(data.local.distance.total[index]), y: getConvertedVelocity(data.local.speed[index]) }; }), @@ -269,7 +269,7 @@ label: datasets.hr.getLabel(), data: data.local.points.map((point, index) => { return { - x: getConvertedDistance(data.local.distance[index]), + x: getConvertedDistance(data.local.distance.total[index]), y: point.getHeartRate() }; }), @@ -281,7 +281,7 @@ label: datasets.cad.getLabel(), data: data.local.points.map((point, index) => { return { - x: getConvertedDistance(data.local.distance[index]), + x: getConvertedDistance(data.local.distance.total[index]), y: point.getCadence() }; }), @@ -293,7 +293,7 @@ label: datasets.atemp.getLabel(), data: data.local.points.map((point, index) => { return { - x: getConvertedDistance(data.local.distance[index]), + x: getConvertedDistance(data.local.distance.total[index]), y: getConvertedTemperature(point.getTemperature()) }; }), @@ -305,7 +305,7 @@ label: datasets.power.getLabel(), data: data.local.points.map((point, index) => { return { - x: getConvertedDistance(data.local.distance[index]), + x: getConvertedDistance(data.local.distance.total[index]), y: point.getPower() }; }), diff --git a/website/src/lib/components/GPXStatistics.svelte b/website/src/lib/components/GPXStatistics.svelte index bf6a3f21..432966b4 100644 --- a/website/src/lib/components/GPXStatistics.svelte +++ b/website/src/lib/components/GPXStatistics.svelte @@ -3,14 +3,23 @@ import Tooltip from '$lib/components/Tooltip.svelte'; import WithUnits from '$lib/components/WithUnits.svelte'; - import { gpxStatistics } from '$lib/stores'; + import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from '$lib/stores'; import { settings } from '$lib/db'; import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from 'lucide-svelte'; import { _ } from 'svelte-i18n'; + import type { GPXStatistics } from 'gpx'; const { velocityUnits, elevationProfile } = settings; + + let statistics: GPXStatistics; + + $: if ($currentTool === Tool.SCISSORS) { + statistics = $slicedGPXStatistics; + } else { + statistics = $gpxStatistics; + } - + {$_('quantities.distance')} - + - + {$_('quantities.elevation')} - + / - + {$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_( @@ -55,9 +64,9 @@ - + / - + {$_('quantities.time')} ({$_('quantities.total')} / {$_('quantities.moving')}) - {$_('menu.file')} + {$_('gpx.file')} diff --git a/website/src/lib/components/file-list/Selection.ts b/website/src/lib/components/file-list/Selection.ts index 0df7c316..52279432 100644 --- a/website/src/lib/components/file-list/Selection.ts +++ b/website/src/lib/components/file-list/Selection.ts @@ -129,8 +129,10 @@ export class SelectionTreeType { } deleteChild(id: string | number) { - this.size -= this.children[id].size; - delete this.children[id]; + if (this.children.hasOwnProperty(id)) { + this.size -= this.children[id].size; + delete this.children[id]; + } } }; diff --git a/website/src/lib/components/gpx-layer/GPXLayer.ts b/website/src/lib/components/gpx-layer/GPXLayer.ts index 13006782..d2cdece3 100644 --- a/website/src/lib/components/gpx-layer/GPXLayer.ts +++ b/website/src/lib/components/gpx-layer/GPXLayer.ts @@ -7,7 +7,7 @@ import { addSelectItem, selectItem, selection } from "$lib/components/file-list/ import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList"; import type { Waypoint } from "gpx"; import { produce } from "immer"; -import { resetCursor, setGrabbingCursor, setPointerCursor } from "$lib/utils"; +import { resetCursor, setCursor, setGrabbingCursor, setPointerCursor } from "$lib/utils"; import { font } from "$lib/assets/layers"; let defaultWeight = 5; @@ -56,7 +56,9 @@ export class GPXLayer { unsubscribe: Function[] = []; updateBinded: () => void = this.update.bind(this); - selectOnClickBinded: (e: any) => void = this.selectOnClick.bind(this); + layerOnMouseEnterBinded: () => void = this.layerOnMouseEnter.bind(this); + layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this); + layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this); constructor(map: mapboxgl.Map, fileId: string, file: Readable) { this.map = map; @@ -122,9 +124,9 @@ export class GPXLayer { } }); - this.map.on('click', this.fileId, this.selectOnClickBinded); - this.map.on('mouseenter', this.fileId, setPointerCursor); - this.map.on('mouseleave', this.fileId, resetCursor); + this.map.on('click', this.fileId, this.layerOnClickBinded); + this.map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded); + this.map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded); } if (get(directionMarkers)) { @@ -232,9 +234,9 @@ export class GPXLayer { } remove() { - this.map.off('click', this.fileId, this.selectOnClickBinded); - this.map.off('mouseenter', this.fileId, setPointerCursor); - this.map.off('mouseleave', this.fileId, resetCursor); + this.map.off('click', this.fileId, this.layerOnClickBinded); + this.map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded); + this.map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded); this.map.off('style.load', this.updateBinded); if (this.map.getLayer(this.fileId + '-direction')) { @@ -265,11 +267,34 @@ export class GPXLayer { } } - selectOnClick(e: any) { + layerOnMouseEnter(e: any) { + let trackIndex = e.features[0].properties.trackIndex; + let segmentIndex = e.features[0].properties.segmentIndex; + + if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) { + setCursor(`url('data:image/svg+xml,') 12 12, auto`); + } else { + setPointerCursor(); + } + } + + layerOnMouseLeave() { + resetCursor(); + } + + layerOnClick(e: any) { if (get(currentTool) === Tool.ROUTING && get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) { return; } + let trackIndex = e.features[0].properties.trackIndex; + let segmentIndex = e.features[0].properties.segmentIndex; + + if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) { + dbUtils.split(this.fileId, trackIndex, segmentIndex, { lat: e.lngLat.lat, lon: e.lngLat.lng }); + return; + } + let file = get(this.file)?.file; if (!file) { return; @@ -277,8 +302,6 @@ export class GPXLayer { let item = undefined; if (get(verticalFileView) && file.getSegments().length > 1) { // Select inner item - let trackIndex = e.features[0].properties.trackIndex; - let segmentIndex = e.features[0].properties.segmentIndex; item = file.children[trackIndex].children.length > 1 ? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) : new ListTrackItem(this.fileId, trackIndex); } else { item = new ListFileItem(this.fileId); diff --git a/website/src/lib/components/gpx-layer/StartEndMarkers.ts b/website/src/lib/components/gpx-layer/StartEndMarkers.ts index 8dc3c961..a1338f99 100644 --- a/website/src/lib/components/gpx-layer/StartEndMarkers.ts +++ b/website/src/lib/components/gpx-layer/StartEndMarkers.ts @@ -1,4 +1,4 @@ -import { gpxStatistics, currentTool, Tool } from "$lib/stores"; +import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from "$lib/stores"; import mapboxgl from "mapbox-gl"; import { get } from "svelte/store"; @@ -21,12 +21,14 @@ export class StartEndMarkers { this.end = new mapboxgl.Marker({ element: endElement }); gpxStatistics.subscribe(this.updateBinded); + slicedGPXStatistics.subscribe(this.updateBinded); currentTool.subscribe(this.updateBinded); } update() { - let statistics = get(gpxStatistics); - if (statistics.local.points.length > 0 && get(currentTool) !== Tool.ROUTING) { + let tool = get(currentTool); + let statistics = tool === Tool.SCISSORS ? get(slicedGPXStatistics) : get(gpxStatistics); + if (statistics.local.points.length > 0 && tool !== Tool.ROUTING) { this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(this.map); this.end.setLngLat(statistics.local.points[statistics.local.points.length - 1].getCoordinates()).addTo(this.map); } else { diff --git a/website/src/lib/components/toolbar/tools/Scissors.svelte b/website/src/lib/components/toolbar/tools/Scissors.svelte index a466fe77..e23e7427 100644 --- a/website/src/lib/components/toolbar/tools/Scissors.svelte +++ b/website/src/lib/components/toolbar/tools/Scissors.svelte @@ -1 +1,100 @@ -
- Start/end sliders - Cut by clicking on a route
+ + + + +
+
+ +
+ + + + + {#if validSelection} + {$_('toolbar.scissors.help')} + {:else} + {$_('toolbar.scissors.help_invalid_selection')} + {/if} + +
diff --git a/website/src/lib/components/toolbar/tools/routing/RoutingControls.ts b/website/src/lib/components/toolbar/tools/routing/RoutingControls.ts index b3c87ed2..ab1bbdc0 100644 --- a/website/src/lib/components/toolbar/tools/routing/RoutingControls.ts +++ b/website/src/lib/components/toolbar/tools/routing/RoutingControls.ts @@ -291,7 +291,7 @@ export class RoutingControls { getPermanentAnchor(): Anchor { let file = get(this.file)?.file; - // Find the closest point closest to the temporary anchor + // Find the point closest to the temporary anchor let minDistance = Number.MAX_VALUE; let minAnchor = this.temporaryAnchor as Anchor; file?.forEachSegment((segment, trackIndex, segmentIndex) => { diff --git a/website/src/lib/components/ui/slider/index.ts b/website/src/lib/components/ui/slider/index.ts new file mode 100644 index 00000000..820f209c --- /dev/null +++ b/website/src/lib/components/ui/slider/index.ts @@ -0,0 +1,7 @@ +import Root from "./slider.svelte"; + +export { + Root, + // + Root as Slider, +}; diff --git a/website/src/lib/components/ui/slider/slider.svelte b/website/src/lib/components/ui/slider/slider.svelte new file mode 100644 index 00000000..b6503cb0 --- /dev/null +++ b/website/src/lib/components/ui/slider/slider.svelte @@ -0,0 +1,27 @@ + + + + + + + {#each thumbs as thumb} + + {/each} + diff --git a/website/src/lib/db.ts b/website/src/lib/db.ts index f9ad6fea..cbc6db33 100644 --- a/website/src/lib/db.ts +++ b/website/src/lib/db.ts @@ -1,13 +1,14 @@ import Dexie, { liveQuery } from 'dexie'; -import { GPXFile, GPXStatistics, Track, type AnyGPXTreeElement, TrackSegment, Waypoint, TrackPoint } from 'gpx'; +import { GPXFile, GPXStatistics, Track, type AnyGPXTreeElement, TrackSegment, Waypoint, TrackPoint, type Coordinates, distance } from 'gpx'; import { enableMapSet, enablePatches, applyPatches, type Patch, type WritableDraft, castDraft, freeze, produceWithPatches, original, produce } from 'immer'; import { writable, get, derived, type Readable, type Writable } from 'svelte/store'; -import { initTargetMapBounds, updateTargetMapBounds } from './stores'; +import { initTargetMapBounds, splitAs, updateTargetMapBounds } from './stores'; import { mode } from 'mode-watcher'; import { defaultBasemap, defaultBasemapTree, defaultOverlayTree, defaultOverlays } from './assets/layers'; import { applyToOrderedSelectedItemsFromFile, selection } from '$lib/components/file-list/Selection'; import { ListFileItem, ListItem, ListTrackItem, ListLevel, ListTrackSegmentItem, ListWaypointItem, ListRootItem } from '$lib/components/file-list/FileList'; import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simplify'; +import { SplitType } from '$lib/components/toolbar/tools/Scissors.svelte'; enableMapSet(); enablePatches(); @@ -393,35 +394,6 @@ export const dbUtils = { applyEachToFilesAndGlobal: (ids: string[], callbacks: ((file: WritableDraft, context?: any) => GPXFile)[], globalCallback: (files: Map, context?: any) => void, context?: any) => { applyEachToFilesAndGlobal(ids, callbacks, globalCallback, context); }, - applyToSelection: (callback: (file: WritableDraft) => AnyGPXTreeElement) => { - if (get(selection).size === 0) { - return; - } - applyGlobal((draft) => { - applyToOrderedSelectedItemsFromFile((fileId, level, items) => { - let file = draft.get(fileId); - if (file) { - for (let item of items) { - if (item instanceof ListFileItem) { - callback(castDraft(file)); - } else if (item instanceof ListTrackItem) { - let trackIndex = item.getTrackIndex(); - file = produce(file, (fileDraft) => { - callback(fileDraft.trk[trackIndex]); - }); - } else if (item instanceof ListTrackSegmentItem) { - let trackIndex = item.getTrackIndex(); - let segmentIndex = item.getSegmentIndex(); - file = produce(file, (fileDraft) => { - callback(fileDraft.trk[trackIndex].trkseg[segmentIndex]); - }); - } - } - draft.set(fileId, freeze(file)); - } - }); - }); - }, duplicateSelection: () => { if (get(selection).size === 0) { return; @@ -601,6 +573,80 @@ export const dbUtils = { } }); }, + cropSelection: (start: number, end: number) => { + if (get(selection).size === 0) { + return; + } + applyGlobal((draft) => { + applyToOrderedSelectedItemsFromFile((fileId, level, items) => { + let file = original(draft)?.get(fileId); + if (file) { + if (level === ListLevel.FILE) { + let length = file.getNumberOfTrackPoints(); + if (start >= length || end < 0) { + draft.delete(fileId); + } else if (start > 0 || end < length - 1) { + let newFile = file.crop(Math.max(0, start), Math.min(length - 1, end)); + draft.set(newFile._data.id, freeze(newFile)); + } + start -= length; + end -= length; + } else if (level === ListLevel.TRACK) { + let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex()); + let newFile = file.crop(start, end, trackIndices); + draft.set(newFile._data.id, freeze(newFile)); + } else if (level === ListLevel.SEGMENT) { + let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()]; + let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex()); + let newFile = file.crop(start, end, trackIndices, segmentIndices); + draft.set(newFile._data.id, freeze(newFile)); + } + } + }, false); + }); + }, + split(fileId: string, trackIndex: number, segmentIndex: number, coordinates: Coordinates) { + let splitType = get(splitAs); + return applyGlobal((draft) => { + let file = original(draft)?.get(fileId); + if (file) { + let segment = file.trk[trackIndex].trkseg[segmentIndex]; + + // Find the point closest to split + let minDistance = Number.MAX_VALUE; + let minIndex = 0; + for (let i = 0; i < segment.trkpt.length; i++) { + let dist = distance(segment.trkpt[i].getCoordinates(), coordinates); + if (dist < minDistance) { + minDistance = dist; + minIndex = i; + } + } + + let absoluteIndex = minIndex; + file.forEachSegment((seg, trkIndex, segIndex) => { + if ((trkIndex < trackIndex && splitType === SplitType.FILES) || (trkIndex === trackIndex && segIndex < segmentIndex)) { + absoluteIndex += seg.trkpt.length; + } + }); + + if (splitType === SplitType.FILES) { + let newFile = file.crop(0, absoluteIndex); + draft.set(newFile._data.id, freeze(newFile)); + let newFile2 = file.clone(); + newFile2._data.id = getFileIds(1)[0]; + newFile2 = newFile2.crop(absoluteIndex, file.getNumberOfTrackPoints() - 1); + draft.set(newFile2._data.id, freeze(newFile2)); + } else if (splitType === SplitType.TRACKS) { + let newFile = file.replaceTracks(trackIndex, trackIndex, [file.trk[trackIndex].crop(0, absoluteIndex), file.trk[trackIndex].crop(absoluteIndex, file.trk[trackIndex].getNumberOfTrackPoints() - 1)])[0]; + draft.set(newFile._data.id, freeze(newFile)); + } else if (splitType === SplitType.SEGMENTS) { + let newFile = file.replaceTrackSegments(trackIndex, segmentIndex, segmentIndex, [segment.crop(0, minIndex), segment.crop(minIndex, segment.trkpt.length - 1)])[0]; + draft.set(newFile._data.id, freeze(newFile)); + } + } + }); + }, deleteSelection: () => { if (get(selection).size === 0) { return; diff --git a/website/src/lib/stores.ts b/website/src/lib/stores.ts index e9bcdc83..03a76719 100644 --- a/website/src/lib/stores.ts +++ b/website/src/lib/stores.ts @@ -10,11 +10,13 @@ import { applyToOrderedSelectedItemsFromFile, selectFile, selection } from '$lib import { ListFileItem, ListWaypointItem, ListWaypointsItem } from '$lib/components/file-list/FileList'; import type { RoutingControls } from '$lib/components/toolbar/tools/routing/RoutingControls'; import { overlayTree, overlays, stravaHeatmapActivityIds, stravaHeatmapServers } from '$lib/assets/layers'; +import { SplitType } from '$lib/components/toolbar/tools/Scissors.svelte'; export const map = writable(null); export const selectFiles = writable<{ [key: string]: (fileId?: string) => void }>({}); export const gpxStatistics: Writable = writable(new GPXStatistics()); +export const slicedGPXStatistics: Writable = writable(new GPXStatistics()); function updateGPXData() { let statistics = new GPXStatistics(); @@ -128,6 +130,7 @@ export enum Tool { STYLE } export const currentTool = writable(null); +export const splitAs = writable(SplitType.FILES); export function newGPXFile() { let file = new GPXFile(); diff --git a/website/src/locales/en.json b/website/src/locales/en.json index 0c12f4e1..5e07aaeb 100644 --- a/website/src/locales/en.json +++ b/website/src/locales/en.json @@ -1,6 +1,5 @@ { "menu": { - "file": "File", "create": "Create", "new_filename": "new", "load_desktop": "Load...", @@ -112,7 +111,11 @@ } }, "scissors": { - "tooltip": "Trim or split routes" + "tooltip": "Crop or split traces", + "crop": "Crop", + "split_as": "Split the trace into", + "help_invalid_selection": "Select a file element to crop or split", + "help": "Use the slider to crop the trace, or click on the map to split it at the selected point" }, "time_tooltip": "Manage time and speed data", "merge": { @@ -129,7 +132,7 @@ "waypoint_tooltip": "Create and edit points of interest", "reduce_tooltip": "Reduce the number of GPS points", "clean_tooltip": "Clean GPS points and points of interest with a rectangle selection", - "style_tooltip": "Change the style of the route" + "style_tooltip": "Change the style of the trace" }, "layers": { "settings": "Layer settings", @@ -250,8 +253,12 @@ "power": "W" }, "gpx": { + "file": "File", + "files": "Files", "track": "Track", + "tracks": "Tracks", "segment": "Segment", + "segments": "Segments", "waypoint": "Point of interest", "waypoints": "Points of interest" }