This commit is contained in:
vcoppe committed 2026-10-07 19:38:21 +02:00
1 parent e07ec97e12
commit 24b877c4ee
8 files changed
+532 -180

No files matched your search

@@ -1,21 +1,18 @@
<script lang="ts">
import { splitAs, SplitType } from '$lib/components/toolbar/tools/scissors/scissors';
import Help from '$lib/components/Help.svelte';
import { ListRootItem } from '$lib/components/file-list/file-list';
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 { map } from '$lib/components/map/map';
import { get } from 'svelte/store';
import { i18n } from '$lib/i18n.svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import { Crop } from '@lucide/svelte';
import { SplitControls } from './split-controls';
import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection';
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
import { slicedStatistics } from '$lib/logic/selection-statistics';
import { engine } from '$lib/engine';
let props: {
@@ -24,15 +21,35 @@
let splitControls: SplitControls | undefined = undefined;
const selection = engine.selection;
const statistics = engine.statistics;
// the tool works on files, tracks and segments, not on waypoints
let validSelection = $derived(
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
$gpxStatistics.global.length > 0
($selection.type === 'file' ||
$selection.type === 'track' ||
$selection.type === 'segment') &&
$statistics.length > 0
);
let maxSliderValue = $derived(
validSelection && $gpxStatistics.global.length > 0 ? $gpxStatistics.global.length - 1 : 1
);
let sliderValues = $derived([0, maxSliderValue]);
let canCrop = $derived(sliderValues[0] != 0 || sliderValues[1] != maxSliderValue);
// The slider does work for each of its steps every time it changes, which is too slow with
// the thousands of trackpoints of a long track: it has a limited number of steps, which are
// spread over the trackpoints.
const MAX_STEPS = 500;
let length = $derived($statistics.length);
let steps = $derived(validSelection ? Math.min(length - 1, MAX_STEPS) : 1);
let sliderValues = $state([0, 1]);
/** The index of the trackpoint of a step of the slider. */
function toIndex(step: number) {
return steps <= 0 || steps >= length - 1 ? step : Math.round((step * (length - 1)) / steps);
}
/** The step of the slider that is the closest to a trackpoint. */
function toStep(index: number) {
return steps <= 0 || steps >= length - 1
? index
: Math.round((index * steps) / (length - 1));
}
onMount(() => {
if ($map) {
@@ -40,42 +57,33 @@
}
});
function updateSlicedGPXStatistics() {
if (validSelection && canCrop) {
$slicedGPXStatistics = [
get(gpxStatistics).sliced(sliderValues[0], sliderValues[1]),
sliderValues[0],
sliderValues[1],
];
/** The user moved the slider: the part of the selection between its bounds is highlighted. */
function updateSlicedStatistics([startStep, endStep]: number[]) {
if (validSelection && (startStep !== 0 || endStep !== steps)) {
const [start, end] = [toIndex(startStep), toIndex(endStep)];
const global = $statistics.slice(start, end);
slicedStatistics.set(global && { global, start, end });
} else {
$slicedGPXStatistics = undefined;
}
}
function updateSliderValues() {
if ($slicedGPXStatistics !== undefined) {
sliderValues = [$slicedGPXStatistics[1], $slicedGPXStatistics[2]];
slicedStatistics.set(undefined);
}
}
// The slider follows the highlighted part, which can also be chosen on the elevation profile or
// reset (when the selection changes): it covers the whole selection when there is none.
// It only goes this way, the slider tells about its own changes (`onValueChange`).
$effect(() => {
if (sliderValues) {
untrack(() => updateSlicedGPXStatistics());
}
});
$effect(() => {
if (
$slicedGPXStatistics !== undefined &&
($slicedGPXStatistics[1] !== sliderValues[0] ||
$slicedGPXStatistics[2] !== sliderValues[1])
) {
untrack(() => updateSliderValues());
}
const sliced = $slicedStatistics;
const max = steps;
untrack(() => {
const bounds = sliced ? [toStep(sliced.start), toStep(sliced.end)] : [0, max];
if (sliderValues[0] !== bounds[0] || sliderValues[1] !== bounds[1]) {
sliderValues = bounds;
}
});
});
onDestroy(() => {
$slicedGPXStatistics = undefined;
slicedStatistics.set(undefined);
if (splitControls) {
splitControls.destroy();
}
@@ -86,7 +94,8 @@
<div class="p-2">
<Slider
bind:value={sliderValues}
max={maxSliderValue}
onValueChange={updateSlicedStatistics}
max={steps}
step={1}
type="multiple"
disabled={!validSelection}
@@ -94,8 +103,9 @@
</div>
<Button
variant="outline"
disabled={!validSelection || !canCrop}
onclick={() => engine.crop(sliderValues[0], sliderValues[1])}
disabled={!validSelection || !$slicedStatistics}
onclick={() =>
$slicedStatistics && engine.crop($slicedStatistics.start, $slicedStatistics.end)}
>
<Crop size="16" />{i18n._('toolbar.scissors.crop')}
</Button>
@@ -1,9 +1,32 @@
import { writable, type Writable } from 'svelte/store';
import { get, writable, type Writable } from 'svelte/store';
import { engine, type SplitType as EngineSplitType } from '$lib/engine';
import { closestPointIndexIn } from '$lib/closest-point';
export enum SplitType {
FILES = 'files',
TRACKS = 'tracks',
SEGMENTS = 'segments',
}
/** What a split cuts in two, the values being the ones of the engine. */
export const SplitType = {
FILES: 'files',
TRACKS: 'tracks',
SEGMENTS: 'segments',
} as const satisfies Record<string, EngineSplitType>;
export type SplitType = (typeof SplitType)[keyof typeof SplitType];
export let splitAs: Writable<SplitType> = writable(SplitType.FILES);
/**
* Splits at the trackpoint of a selected segment that is the closest to `point`, as `splitAs` says.
* The statistics of the engine need to have the anchors (the scissors tool asks for them).
*/
export function splitAtPoint(segmentId: string, point: { lng: number; lat: number }) {
const statistics = get(engine.statistics);
const anchors = statistics.anchors;
const segment = anchors?.segmentIds.indexOf(segmentId) ?? -1;
if (!anchors || segment < 0) {
return;
}
const starts = anchors.segmentStarts;
const end = segment + 1 < starts.length ? starts[segment + 1] : statistics.length;
const index = closestPointIndexIn(statistics.lng, statistics.lat, starts[segment], end, point);
if (index !== undefined) {
engine.split(anchors.revision, index, get(splitAs));
}
}
@@ -1,22 +1,32 @@
import { ListTrackSegmentItem } from '$lib/components/file-list/file-list';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
import { Scissors } from 'lucide-static';
import { selection } from '$lib/logic/selection';
import { gpxStatistics } from '$lib/logic/statistics';
import { get } from 'svelte/store';
import { fileStateCollection } from '$lib/logic/file-state';
import { fileActions } from '$lib/logic/file-actions';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
import {
engine,
type Selection,
type SelectionStatistics,
type StatisticsRequest,
} from '$lib/engine';
/**
* The anchors inside the segments of the selection, where a click splits the file, the track or
* the segment: a command of the engine, which finds them by the index of their trackpoint in the
* selection.
*/
export class SplitControls {
map: maplibregl.Map;
layerEventManager: MapLayerEventManager;
unsubscribes: Function[] = [];
/** What the tool needs from the engine, while it shows the anchors. */
request: StatisticsRequest = engine.requestStatistics();
selection: Selection = { type: 'empty' };
statistics: SelectionStatistics | undefined = undefined;
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
@@ -36,13 +46,29 @@ export class SplitControls {
</svg>`
);
this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
this.unsubscribes.push(
engine.statistics.subscribe((statistics) => {
this.statistics = statistics;
this.addIfNeeded();
})
);
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
this.unsubscribes.push(selection.subscribe(this.addIfNeeded.bind(this)));
this.unsubscribes.push(
engine.selection.subscribe((selection) => {
this.selection = selection;
this.addIfNeeded();
})
);
}
addIfNeeded() {
let scissors = get(currentTool) === Tool.SCISSORS;
// the tool works on files, tracks and segments, not on waypoints
const scissors =
get(currentTool) === Tool.SCISSORS &&
(this.selection.type === 'file' ||
this.selection.type === 'track' ||
this.selection.type === 'segment');
this.request.set(scissors ? ['anchors'] : []);
if (!scissors) {
this.remove();
return;
@@ -51,44 +77,42 @@ export class SplitControls {
this.updateControls();
}
updateControls() {
let data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
};
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = fileStateCollection.getFile(fileId);
if (file) {
file.forEachSegment((segment, trackIndex, segmentIndex) => {
if (
get(selection).hasAnyParent(
new ListTrackSegmentItem(fileId, trackIndex, segmentIndex)
)
) {
for (let i = 1; i < segment.trkpt.length - 1; i++) {
let point = segment.trkpt[i];
if (point._data.anchor) {
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [point.getLongitude(), point.getLatitude()],
},
properties: {
fileId: fileId,
trackIndex: trackIndex,
segmentIndex: segmentIndex,
pointIndex: i,
minZoom: point._data.zoom,
},
});
}
}
}
/** The anchors that are inside a segment, which is not where it starts or ends. */
inner(): GeoJSON.Feature[] {
const statistics = this.statistics;
const anchors = statistics?.anchors;
if (!statistics || !anchors) {
return [];
}
const starts = anchors.segmentStarts;
const features: GeoJSON.Feature[] = [];
let segment = 0;
for (let i = 0; i < anchors.indices.length; i++) {
const index = anchors.indices[i];
// the anchors are sorted: the segment can only be the same or a following one
while (segment + 1 < starts.length && starts[segment + 1] <= index) {
segment++;
}
const end = segment + 1 < starts.length ? starts[segment + 1] : statistics.length;
if (index > starts[segment] && index < end - 1) {
features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [statistics.lng[index], statistics.lat[index]],
},
properties: { index, minZoom: anchors.zooms[i] },
});
}
}, false);
}
return features;
}
updateControls() {
const data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: this.inner(),
};
try {
let source = this.map.getSource('split-controls') as GeoJSONSource | undefined;
@@ -161,19 +185,15 @@ export class SplitControls {
}
layerOnClick(e: maplibregl.MapLayerMouseEvent) {
let coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates;
fileActions.split(
get(splitAs),
e.features![0].properties!.fileId,
e.features![0].properties!.trackIndex,
e.features![0].properties!.segmentIndex,
{ lon: coordinates[0], lat: coordinates[1] },
e.features![0].properties!.pointIndex
);
const revision = this.statistics?.anchors?.revision;
if (revision !== undefined) {
engine.split(revision, e.features![0].properties!.index, get(splitAs));
}
}
destroy() {
this.remove();
this.request.release();
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
}
}