scissor tool

This commit is contained in:
vcoppe
2024-06-10 20:03:57 +02:00
parent 287fd4c8ac
commit 14a81a530c
16 changed files with 428 additions and 89 deletions

View File

@@ -15,6 +15,7 @@ export abstract class GPXTreeElement<T extends GPXTreeElement<any>> {
abstract isLeaf(): boolean;
abstract get children(): ReadonlyArray<T>;
abstract getNumberOfTrackPoints(): number;
abstract getStartTimestamp(): Date;
abstract getEndTimestamp(): Date;
abstract getStatistics(): GPXStatistics;
@@ -34,6 +35,10 @@ abstract class GPXTreeNode<T extends GPXTreeElement<any>> 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<Track>{
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<TrackSegment> {
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;

View File

@@ -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",

View File

@@ -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",

View File

@@ -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()
};
}),

View File

@@ -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;
}
</script>
<Card.Root
@@ -26,25 +35,25 @@
<Tooltip>
<span slot="data" class="flex flex-row items-center">
<Ruler size="18" class="mr-1" />
<WithUnits value={$gpxStatistics.global.distance.total} type="distance" />
<WithUnits value={statistics.global.distance.total} type="distance" />
</span>
<span slot="tooltip">{$_('quantities.distance')}</span>
</Tooltip>
<Tooltip>
<span slot="data" class="flex flex-row items-center">
<MoveUpRight size="18" class="mr-1" />
<WithUnits value={$gpxStatistics.global.elevation.gain} type="elevation" />
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
<MoveDownRight size="18" class="mx-1" />
<WithUnits value={$gpxStatistics.global.elevation.loss} type="elevation" />
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
</span>
<span slot="tooltip">{$_('quantities.elevation')}</span>
</Tooltip>
<Tooltip>
<span slot="data" class="flex flex-row items-center">
<Zap size="18" class="mr-1" />
<WithUnits value={$gpxStatistics.global.speed.total} type="speed" showUnits={false} />
<WithUnits value={statistics.global.speed.total} type="speed" showUnits={false} />
<span class="mx-1">/</span>
<WithUnits value={$gpxStatistics.global.speed.moving} type="speed" />
<WithUnits value={statistics.global.speed.moving} type="speed" />
</span>
<span slot="tooltip"
>{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_(
@@ -55,9 +64,9 @@
<Tooltip>
<span slot="data" class="flex flex-row items-center">
<Timer size="18" class="mr-1" />
<WithUnits value={$gpxStatistics.global.time.total} type="time" />
<WithUnits value={statistics.global.time.total} type="time" />
<span class="mx-1">/</span>
<WithUnits value={$gpxStatistics.global.time.moving} type="time" />
<WithUnits value={statistics.global.time.moving} type="time" />
</span>
<span slot="tooltip"
>{$_('quantities.time')} ({$_('quantities.total')} / {$_('quantities.moving')})</span

View File

@@ -102,7 +102,7 @@
<Logo class="h-5 mt-0.5 mx-2" />
<Menubar.Root class="border-none h-fit p-0">
<Menubar.Menu>
<Menubar.Trigger>{$_('menu.file')}</Menubar.Trigger>
<Menubar.Trigger>{$_('gpx.file')}</Menubar.Trigger>
<Menubar.Content class="border-none">
<Menubar.Item on:click={createFile}>
<Plus size="16" class="mr-1" />

View File

@@ -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];
}
}
};

View File

@@ -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<GPXFileWithStatistics | undefined>) {
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,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1"><path d="M 3.200 3.200 C 0.441 5.959, 2.384 9.516, 7 10.154 C 10.466 10.634, 10.187 13.359, 6.607 13.990 C 2.934 14.637, 1.078 17.314, 2.612 19.750 C 4.899 23.380, 10 21.935, 10 17.657 C 10 16.445, 12.405 13.128, 15.693 9.805 C 18.824 6.641, 21.066 3.732, 20.674 3.341 C 20.283 2.950, 18.212 4.340, 16.072 6.430 C 12.019 10.388, 10 10.458, 10 6.641 C 10 2.602, 5.882 0.518, 3.200 3.200 M 4.446 5.087 C 3.416 6.755, 5.733 8.667, 7.113 7.287 C 8.267 6.133, 7.545 4, 6 4 C 5.515 4, 4.816 4.489, 4.446 5.087 M 14 14.813 C 14 16.187, 19.935 21.398, 20.667 20.667 C 21.045 20.289, 20.065 18.634, 18.490 16.990 C 15.661 14.036, 14 13.231, 14 14.813 M 4.446 17.087 C 3.416 18.755, 5.733 20.667, 7.113 19.287 C 8.267 18.133, 7.545 16, 6 16 C 5.515 16, 4.816 16.489, 4.446 17.087" stroke="black" stroke-width="1.2" fill="white" fill-rule="evenodd"/></svg>') 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);

View File

@@ -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 {

View File

@@ -1 +1,100 @@
<div>- Start/end sliders - Cut by clicking on a route</div>
<script lang="ts" context="module">
export enum SplitType {
FILES = 'files',
TRACKS = 'tracks',
SEGMENTS = 'segments'
}
</script>
<script lang="ts">
import Help from '$lib/components/Help.svelte';
import { ListRootItem } from '$lib/components/file-list/FileList';
import { selection } from '$lib/components/file-list/Selection';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
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 { get } from 'svelte/store';
import { _ } from 'svelte-i18n';
import { tick } from 'svelte';
import { Crop } from 'lucide-svelte';
import { dbUtils } from '$lib/db';
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
let maxSliderValue = 100;
let sliderValues = [0, 100];
function updateSlicedGPXStatistics() {
if (validSelection) {
slicedGPXStatistics.set(get(gpxStatistics).slice(sliderValues[0], sliderValues[1]));
} else {
slicedGPXStatistics.set(get(gpxStatistics));
}
}
async function updateSliderLimits() {
if (validSelection) {
maxSliderValue = $gpxStatistics.local.points.length;
} else {
maxSliderValue = 100;
}
await tick();
sliderValues = [0, maxSliderValue];
}
$: if ($gpxStatistics.local.points.length != maxSliderValue) {
updateSliderLimits();
}
$: if (sliderValues) {
updateSlicedGPXStatistics();
}
const splitTypes = [
{ value: SplitType.FILES, label: $_('gpx.files') },
{ value: SplitType.TRACKS, label: $_('gpx.tracks') },
{ value: SplitType.SEGMENTS, label: $_('gpx.segments') }
];
let splitType = splitTypes[0];
$: splitAs.set(splitType.value);
</script>
<div class="flex flex-col gap-3 max-w-80">
<div class="p-2">
<Slider bind:value={sliderValues} max={maxSliderValue} step={1} disabled={!validSelection} />
</div>
<Button
variant="outline"
disabled={!validSelection}
on:click={() => dbUtils.cropSelection(sliderValues[0], sliderValues[1])}
><Crop size="16" class="mr-1" />{$_('toolbar.scissors.crop')}</Button
>
<Separator />
<Label class="flex flex-row gap-3 items-center">
<span class="shrink-0">
{$_('toolbar.scissors.split_as')}
</span>
<Select.Root bind:selected={splitType}>
<Select.Trigger class="h-8">
<Select.Value />
</Select.Trigger>
<Select.Content>
{#each splitTypes as { value, label }}
<Select.Item {value}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Label>
<Help>
{#if validSelection}
{$_('toolbar.scissors.help')}
{:else}
{$_('toolbar.scissors.help_invalid_selection')}
{/if}
</Help>
</div>

View File

@@ -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) => {

View File

@@ -0,0 +1,7 @@
import Root from "./slider.svelte";
export {
Root,
//
Root as Slider,
};

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { Slider as SliderPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
type $$Props = SliderPrimitive.Props;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"] = [0];
export { className as class };
</script>
<SliderPrimitive.Root
bind:value
class={cn("relative flex w-full touch-none select-none items-center", className)}
{...$$restProps}
let:thumbs
>
<span class="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range class="absolute h-full bg-primary" />
</span>
{#each thumbs as thumb}
<SliderPrimitive.Thumb
{thumb}
class="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
</SliderPrimitive.Root>

View File

@@ -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<GPXFile>, context?: any) => GPXFile)[], globalCallback: (files: Map<string, GPXFile>, context?: any) => void, context?: any) => {
applyEachToFilesAndGlobal(ids, callbacks, globalCallback, context);
},
applyToSelection: (callback: (file: WritableDraft<AnyGPXTreeElement>) => 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;

View File

@@ -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<mapboxgl.Map | null>(null);
export const selectFiles = writable<{ [key: string]: (fileId?: string) => void }>({});
export const gpxStatistics: Writable<GPXStatistics> = writable(new GPXStatistics());
export const slicedGPXStatistics: Writable<GPXStatistics> = writable(new GPXStatistics());
function updateGPXData() {
let statistics = new GPXStatistics();
@@ -128,6 +130,7 @@ export enum Tool {
STYLE
}
export const currentTool = writable<Tool | null>(null);
export const splitAs = writable(SplitType.FILES);
export function newGPXFile() {
let file = new GPXFile();

View File

@@ -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"
}