diff --git a/website/src/lib/components/gpx-layer/GPXLayer.ts b/website/src/lib/components/gpx-layer/GPXLayer.ts
index 25781f5..e634e5f 100644
--- a/website/src/lib/components/gpx-layer/GPXLayer.ts
+++ b/website/src/lib/components/gpx-layer/GPXLayer.ts
@@ -6,7 +6,7 @@ import { currentPopupWaypoint, deleteWaypoint, waypointPopup } from "./WaypointP
import { addSelectItem, selectItem, selection } from "$lib/components/file-list/Selection";
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
import type { Waypoint } from "gpx";
-import { getElevation, resetCursor, setCursor, setGrabbingCursor, setPointerCursor } from "$lib/utils";
+import { getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
import { font } from "$lib/assets/layers";
import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
import { MapPin } from "lucide-static";
@@ -320,7 +320,7 @@ export class GPXLayer {
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`);
+ setScissorsCursor();
} else {
setPointerCursor();
}
diff --git a/website/src/lib/components/toolbar/ToolbarItemMenu.svelte b/website/src/lib/components/toolbar/ToolbarItemMenu.svelte
index 2598f53..312675c 100644
--- a/website/src/lib/components/toolbar/ToolbarItemMenu.svelte
+++ b/website/src/lib/components/toolbar/ToolbarItemMenu.svelte
@@ -4,7 +4,7 @@
import { flyAndScale } from '$lib/utils';
import * as Card from '$lib/components/ui/card';
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
- import Scissors from '$lib/components/toolbar/tools/Scissors.svelte';
+ import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
import Time from '$lib/components/toolbar/tools/Time.svelte';
import Merge from '$lib/components/toolbar/tools/Merge.svelte';
diff --git a/website/src/lib/components/toolbar/tools/Scissors.svelte b/website/src/lib/components/toolbar/tools/scissors/Scissors.svelte
similarity index 89%
rename from website/src/lib/components/toolbar/tools/Scissors.svelte
rename to website/src/lib/components/toolbar/tools/scissors/Scissors.svelte
index 4fa02e1..bf3ce80 100644
--- a/website/src/lib/components/toolbar/tools/Scissors.svelte
+++ b/website/src/lib/components/toolbar/tools/scissors/Scissors.svelte
@@ -15,12 +15,22 @@
import { Slider } from '$lib/components/ui/slider';
import * as Select from '$lib/components/ui/select';
import { Separator } from '$lib/components/ui/separator';
- import { gpxStatistics, slicedGPXStatistics, splitAs } from '$lib/stores';
+ import { gpxStatistics, map, slicedGPXStatistics, splitAs } from '$lib/stores';
import { get } from 'svelte/store';
import { _ } from 'svelte-i18n';
import { onDestroy, tick } from 'svelte';
import { Crop } from 'lucide-svelte';
import { dbUtils } from '$lib/db';
+ import { SplitControls } from './SplitControls';
+
+ let splitControls: SplitControls | undefined = undefined;
+
+ $: if ($map) {
+ if (splitControls) {
+ splitControls.destroy();
+ }
+ splitControls = new SplitControls($map);
+ }
$: validSelection =
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
@@ -86,6 +96,10 @@
onDestroy(() => {
$slicedGPXStatistics = undefined;
+ if (splitControls) {
+ splitControls.destroy();
+ splitControls = undefined;
+ }
});
diff --git a/website/src/lib/components/toolbar/tools/scissors/SplitControls.ts b/website/src/lib/components/toolbar/tools/scissors/SplitControls.ts
new file mode 100644
index 0000000..de1825d
--- /dev/null
+++ b/website/src/lib/components/toolbar/tools/scissors/SplitControls.ts
@@ -0,0 +1,165 @@
+import { TrackPoint, TrackSegment } from "gpx";
+import { get } from "svelte/store";
+import mapboxgl from "mapbox-gl";
+import { dbUtils, getFile } from "$lib/db";
+import { applyToOrderedSelectedItemsFromFile, selection } from "$lib/components/file-list/Selection";
+import { ListTrackSegmentItem } from "$lib/components/file-list/FileList";
+import { currentTool, gpxStatistics, Tool } from "$lib/stores";
+import { _ } from "svelte-i18n";
+import { Scissors } from "lucide-static";
+
+export class SplitControls {
+ active: boolean = false;
+ map: mapboxgl.Map;
+ controls: ControlWithMarker[] = [];
+ shownControls: ControlWithMarker[] = [];
+ unsubscribes: Function[] = [];
+
+ toggleControlsForZoomLevelAndBoundsBinded: () => void = this.toggleControlsForZoomLevelAndBounds.bind(this);
+
+ constructor(map: mapboxgl.Map) {
+ this.map = map;
+
+ this.unsubscribes.push(selection.subscribe(this.addIfNeeded.bind(this)));
+ this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
+ this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
+ }
+
+ addIfNeeded() {
+ let scissors = get(currentTool) === Tool.SCISSORS;
+ if (!scissors) {
+ if (this.active) {
+ this.remove();
+ }
+ return;
+ }
+
+ if (this.active) {
+ this.updateControls();
+ } else {
+ this.add();
+ }
+ }
+
+ add() {
+ this.active = true;
+
+ this.map.on('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
+ this.map.on('move', this.toggleControlsForZoomLevelAndBoundsBinded);
+ }
+
+ updateControls() { // Update the markers when the files change
+
+ let controlIndex = 0;
+
+ applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
+ let file = getFile(fileId);
+
+ if (file) {
+ file.forEachSegment((segment, trackIndex, segmentIndex) => {
+ if (get(selection).hasAnyParent(new ListTrackSegmentItem(fileId, trackIndex, segmentIndex))) {
+ for (let point of segment.trkpt.slice(1, -1)) { // Update the existing controls (could be improved by matching the existing controls with the new ones?)
+ if (point._data.anchor) {
+ if (controlIndex < this.controls.length) {
+ this.controls[controlIndex].point = point;
+ this.controls[controlIndex].segment = segment;
+ this.controls[controlIndex].trackIndex = trackIndex;
+ this.controls[controlIndex].segmentIndex = segmentIndex;
+ this.controls[controlIndex].marker.setLngLat(point.getCoordinates());
+ } else {
+ this.controls.push(this.createControl(point, segment, fileId, trackIndex, segmentIndex));
+ }
+ controlIndex++;
+ }
+ }
+ }
+ });
+
+ }
+ }, false);
+
+ while (controlIndex < this.controls.length) { // Remove the extra controls
+ this.controls.pop()?.marker.remove();
+ }
+
+ this.toggleControlsForZoomLevelAndBounds();
+ }
+
+ remove() {
+ this.active = false;
+
+ for (let control of this.controls) {
+ control.marker.remove();
+ }
+ this.map.off('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
+ this.map.off('move', this.toggleControlsForZoomLevelAndBoundsBinded);
+ }
+
+ toggleControlsForZoomLevelAndBounds() { // Show markers only if they are in the current zoom level and bounds
+ this.shownControls.splice(0, this.shownControls.length);
+
+ let southWest = this.map.unproject([0, this.map.getCanvas().height]);
+ let northEast = this.map.unproject([this.map.getCanvas().width, 0]);
+ let bounds = new mapboxgl.LngLatBounds(southWest, northEast);
+
+ let zoom = this.map.getZoom();
+ this.controls.forEach((control) => {
+ control.inZoom = control.point._data.zoom <= zoom;
+ if (control.inZoom && bounds.contains(control.marker.getLngLat())) {
+ control.marker.addTo(this.map);
+ this.shownControls.push(control);
+ } else {
+ control.marker.remove();
+ }
+ });
+ }
+
+ createControl(point: TrackPoint, segment: TrackSegment, fileId: string, trackIndex: number, segmentIndex: number): ControlWithMarker {
+ let element = document.createElement('div');
+ element.className = `h-6 w-6 p-0.5 rounded-full bg-white border-2 border-black cursor-pointer`;
+ element.innerHTML = Scissors.replace('width="24"', "").replace('height="24"', "");
+ console.log(element.innerHTML);
+
+ let marker = new mapboxgl.Marker({
+ draggable: true,
+ className: 'z-10',
+ element
+ }).setLngLat(point.getCoordinates());
+
+ let control = {
+ point,
+ segment,
+ fileId,
+ trackIndex,
+ segmentIndex,
+ marker,
+ inZoom: false
+ };
+
+ marker.getElement().addEventListener('click', (e) => {
+ e.stopPropagation();
+ console.log('click', fileId, trackIndex, segmentIndex, point.getCoordinates(), point._data.index);
+ dbUtils.split(fileId, trackIndex, segmentIndex, point.getCoordinates(), point._data.index);
+ });
+
+ return control;
+ }
+
+ destroy() {
+ this.remove();
+ this.unsubscribes.forEach((unsubscribe) => unsubscribe());
+ }
+}
+
+type Control = {
+ segment: TrackSegment;
+ fileId: string;
+ trackIndex: number;
+ segmentIndex: number;
+ point: TrackPoint;
+};
+
+type ControlWithMarker = Control & {
+ marker: mapboxgl.Marker;
+ inZoom: boolean;
+};
diff --git a/website/src/lib/db.ts b/website/src/lib/db.ts
index c3163c8..dd1837f 100644
--- a/website/src/lib/db.ts
+++ b/website/src/lib/db.ts
@@ -7,7 +7,7 @@ import { defaultBasemap, defaultBasemapTree, defaultOverlayTree, defaultOverlays
import { applyToOrderedItemsFromFile, 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';
+import { SplitType } from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import { getElevation } from '$lib/utils';
import { browser } from '$app/environment';
@@ -790,22 +790,26 @@ export const dbUtils = {
});
});
},
- split(fileId: string, trackIndex: number, segmentIndex: number, coordinates: Coordinates) {
+ split(fileId: string, trackIndex: number, segmentIndex: number, coordinates: Coordinates, trkptIndex?: number) {
let splitType = get(splitAs);
return applyGlobal((draft) => {
let file = getFile(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;
+ if (trkptIndex === undefined) {
+ // Find the point closest to split
+ let minDistance = Number.MAX_VALUE;
+ for (let i = 0; i < segment.trkpt.length; i++) {
+ let dist = distance(segment.trkpt[i].getCoordinates(), coordinates);
+ if (dist < minDistance) {
+ minDistance = dist;
+ minIndex = i;
+ }
}
+ } else {
+ minIndex = trkptIndex;
}
let absoluteIndex = minIndex;
diff --git a/website/src/lib/docs/en/toolbar/scissors.mdx b/website/src/lib/docs/en/toolbar/scissors.mdx
index a43a335..2b5905b 100644
--- a/website/src/lib/docs/en/toolbar/scissors.mdx
+++ b/website/src/lib/docs/en/toolbar/scissors.mdx
@@ -24,8 +24,9 @@ Validate the selection when you are satisfied with the result.
## Split
-To split the selected trace into two parts, hover over the trace on the map.
-Scissors will appear at the cursor position, indicating that you can split the trace at this point.
+To split the selected trace into two parts, click on one of the split markers displayed along the trace.
+To split at a specific point of your choice, hover over the trace on the map.
+Scissors will appear at the cursor position, showing that you can split the trace at that point.
You can choose to split the trace into two GPX files, or to keep the split parts in the same file as [tracks or segments](../gpx).
diff --git a/website/src/lib/stores.ts b/website/src/lib/stores.ts
index bb8f398..eb0f723 100644
--- a/website/src/lib/stores.ts
+++ b/website/src/lib/stores.ts
@@ -9,7 +9,7 @@ import { dbUtils, fileObservers, getFile, getStatistics, settings } from './db';
import { addSelectItem, applyToOrderedSelectedItemsFromFile, selectFile, selectItem, selection } from '$lib/components/file-list/Selection';
import { ListFileItem, ListItem, ListTrackItem, ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem } from '$lib/components/file-list/FileList';
import type { RoutingControls } from '$lib/components/toolbar/tools/routing/RoutingControls';
-import { SplitType } from '$lib/components/toolbar/tools/Scissors.svelte';
+import { SplitType } from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
const { fileOrder } = settings;
diff --git a/website/src/lib/utils.ts b/website/src/lib/utils.ts
index 348c6ab..1d789ef 100644
--- a/website/src/lib/utils.ts
+++ b/website/src/lib/utils.ts
@@ -8,7 +8,6 @@ import { base } from "$app/paths";
import { browser } from "$app/environment";
import { languages } from "$lib/languages";
import { locale } from "svelte-i18n";
-import type Coordinates from "gpx";
import type mapboxgl from "mapbox-gl";
export function cn(...inputs: ClassValue[]) {
@@ -102,6 +101,12 @@ export function setCrosshairCursor() {
setCursor('crosshair');
}
+export const scissorsCursor = `url('data:image/svg+xml,') 12 12, auto`;
+
+export function setScissorsCursor() {
+ setCursor(scissorsCursor);
+}
+
export function getURLForLanguage(lang: string | null | undefined, path?: string): string {
let newPath = path ?? (browser ? window.location.pathname.replace(base, '') : '');
let languageInPath = newPath.split('/')[1];
diff --git a/website/src/locales/en.json b/website/src/locales/en.json
index ee6e9a8..b7d5ceb 100644
--- a/website/src/locales/en.json
+++ b/website/src/locales/en.json
@@ -150,7 +150,7 @@
"crop": "Crop",
"split_as": "Split the trace into",
"help_invalid_selection": "Select a trace to crop or split.",
- "help": "Use the slider to crop the trace, or click on the map to split it at the selected point."
+ "help": "Use the slider to crop the trace, or split it by clicking on one of the split markers or on the trace itself."
},
"time": {
"tooltip": "Manage time data",