mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-09-01 08:12:32 +00:00
scissor tool
This commit is contained in:
148
gpx/src/gpx.ts
148
gpx/src/gpx.ts
@@ -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;
|
||||
|
8
website/package-lock.json
generated
8
website/package-lock.json
generated
@@ -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",
|
||||
|
@@ -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",
|
||||
|
@@ -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()
|
||||
};
|
||||
}),
|
||||
|
@@ -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
|
||||
|
@@ -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" />
|
||||
|
@@ -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];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -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);
|
||||
|
@@ -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 {
|
||||
|
@@ -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>
|
||||
|
@@ -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) => {
|
||||
|
7
website/src/lib/components/ui/slider/index.ts
Normal file
7
website/src/lib/components/ui/slider/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./slider.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Slider,
|
||||
};
|
27
website/src/lib/components/ui/slider/slider.svelte
Normal file
27
website/src/lib/components/ui/slider/slider.svelte
Normal 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>
|
@@ -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;
|
||||
|
@@ -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();
|
||||
|
@@ -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"
|
||||
}
|
||||
|
Reference in New Issue
Block a user