This commit is contained in:
vcoppe committed 2026-10-08 08:36:29 +02:00
1 parent b29c4cdb08
commit bec1ce4c9b
13 files changed
+372 -215

No files matched your search

@@ -19,7 +19,6 @@ import { isCovered, hasSelectionWithin, type FileTreeNode } from '$lib/selection
import { isSegmentHidden, isWaypointHidden } from '$lib/file-visibility';
import { settings } from '$lib/logic/settings';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { fileActions } from '$lib/logic/file-actions';
import { splitAtPoint } from '$lib/components/toolbar/tools/scissors/scissors';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
@@ -2,26 +2,39 @@
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import { Slider } from '$lib/components/ui/slider';
import { ListRootItem } from '$lib/components/file-list/file-list';
import Help from '$lib/components/Help.svelte';
import { Funnel } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import WithUnits from '$lib/components/WithUnits.svelte';
import { onDestroy } from 'svelte';
import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection';
import { minTolerance, ReducedGPXLayerCollection, tolerance } from './utils.svelte';
import { engine } from '$lib/engine';
import { minTolerance, ReducedLayer, tolerance } from './utils.svelte';
let props: { class?: string } = $props();
let sliderValue = $state(50);
const maxTolerance = 10000;
const selection = engine.selection;
const statistics = engine.statistics;
// the preview needs the start of each segment
const request = engine.requestStatistics();
request.set(['anchors']);
let validSelection = $derived(
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints'])
$selection.type === 'file' || $selection.type === 'track' || $selection.type === 'segment'
);
let reducedLayers = new ReducedGPXLayerCollection();
let reducedLayer = new ReducedLayer();
$effect(() => {
// recomputed when the selection or the files change
const stats = $statistics;
const distances = engine.reductionDistances();
reducedLayer.update(stats, distances, $tolerance);
});
$effect(() => {
tolerance.set(
@@ -30,7 +43,8 @@
});
onDestroy(() => {
reducedLayers.destroy();
request.release();
reducedLayer.destroy();
});
</script>
@@ -44,9 +58,9 @@
</Label>
<Label class="flex flex-row justify-between">
<span>{i18n._('toolbar.reduce.number_of_points')}</span>
<span class="font-normal">{reducedLayers.currentPoints}/{reducedLayers.maxPoints}</span>
<span class="font-normal">{reducedLayer.currentPoints}/{reducedLayer.maxPoints}</span>
</Label>
<Button variant="outline" disabled={!validSelection} onclick={() => reducedLayers.reduce()}>
<Button variant="outline" disabled={!validSelection} onclick={() => engine.reduce($tolerance)}>
<Funnel size="16" />
{i18n._('toolbar.reduce.button')}
</Button>
@@ -1,205 +1,85 @@
import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list';
import { map } from '$lib/components/map/map';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { fileActions } from '$lib/logic/file-actions';
import { GPXFileStateCollectionObserver, type GPXFileState } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection';
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
import type { SelectionStatistics } from '$lib/engine';
import type { GeoJSONSource } from 'maplibre-gl';
import { get, writable } from 'svelte/store';
/** Lowest tolerance (m) of the reduction, the one of the engine. */
export const minTolerance = 0.1;
export class ReducedGPXLayer {
private _fileState: GPXFileState;
private _updateSimplified: (
itemId: string,
data: [ListItem, number, SimplifiedTrackPoint[]]
) => void;
private _unsubscribes: (() => void)[] = [];
constructor(
fileState: GPXFileState,
updateSimplified: (itemId: string, data: [ListItem, number, SimplifiedTrackPoint[]]) => void
) {
this._fileState = fileState;
this._updateSimplified = updateSimplified;
this._unsubscribes.push(this._fileState.subscribe(() => this.update()));
}
update() {
const file = this._fileState.file;
if (!file) {
return;
}
file.forEachSegment((segment, trackIndex, segmentIndex) => {
let segmentItem = new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex);
this._updateSimplified(segmentItem.getFullId(), [
segmentItem,
segment.trkpt.length,
ramerDouglasPeucker(segment.trkpt, minTolerance),
]);
});
}
destroy() {
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
}
}
export const tolerance = writable<number>(0);
export class ReducedGPXLayerCollection {
private _layers: Map<string, ReducedGPXLayer> = new Map();
private _simplified: Map<string, [ListItem, number, SimplifiedTrackPoint[]]>;
private _currentPoints = $state(0);
private _maxPoints = $state(0);
private _fileStateCollectionObserver: GPXFileStateCollectionObserver;
private _updateSimplified = this.updateSimplified.bind(this);
private _unsubscribes: (() => void)[] = [];
const SOURCE = 'simplified';
constructor() {
this._layers = new Map();
this._simplified = new Map();
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
(newFiles) => {
newFiles.forEach((fileState, fileId) => {
this._layers.set(
fileId,
new ReducedGPXLayer(fileState, this._updateSimplified)
);
});
},
(fileId) => {
this._layers.get(fileId)?.destroy();
this._layers.delete(fileId);
},
() => {
this._layers.forEach((layer) => layer.destroy());
this._layers.clear();
/**
* Draws on the map what the selection would be reduced to with a tolerance, from the distances of
* the engine (see `Engine.reductionDistances`). It is the same as the result of `Engine.reduce`.
*/
export class ReducedLayer {
/** The number of trackpoints kept by the tolerance. */
currentPoints = $state(0);
/** The number of trackpoints of the selection. */
maxPoints = $state(0);
update(statistics: SelectionStatistics, distances: Float64Array, tolerance: number) {
const { lng, lat, length } = statistics;
const starts = statistics.anchors?.segmentStarts ?? new Uint32Array();
const data: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] };
let current = 0;
// distances are for the same selection as the statistics, unless they are about to change
const count = Math.min(length, distances.length);
for (let i = 0; i < starts.length; i++) {
const end = Math.min(i + 1 < starts.length ? starts[i + 1] : length, count);
const coordinates: [number, number][] = [];
for (let j = starts[i]; j < end; j++) {
if (distances[j] > tolerance) {
coordinates.push([lng[j], lat[j]]);
}
}
);
this._unsubscribes.push(selection.subscribe(() => this.update()));
this._unsubscribes.push(tolerance.subscribe(() => this.update()));
}
updateSimplified(itemId: string, data: [ListItem, number, SimplifiedTrackPoint[]]) {
this._simplified.set(itemId, data);
if (get(selection).hasAnyParent(data[0])) {
this.update();
}
}
removeSimplified(itemId: string) {
if (this._simplified.delete(itemId)) {
this.update();
}
}
update() {
this._currentPoints = 0;
this._maxPoints = 0;
let data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
};
this._simplified.forEach(([item, maxPts, points], itemFullId) => {
if (!get(selection).hasAnyParent(item)) {
return;
}
this._maxPoints += maxPts;
let current = points.filter(
(point) => point.distance === undefined || point.distance >= get(tolerance)
);
this._currentPoints += current.length;
current += coordinates.length;
data.features.push({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: current.map((point) => [
point.point.getLongitude(),
point.point.getLatitude(),
]),
},
geometry: { type: 'LineString', coordinates },
properties: {},
});
});
}
this.currentPoints = current;
this.maxPoints = length;
const map_ = get(map);
if (!map_) {
return;
}
let source: GeoJSONSource | undefined = map_.getSource('simplified');
const source = map_.getSource<GeoJSONSource>(SOURCE);
if (source) {
source.setData(data);
} else {
map_.addSource('simplified', {
type: 'geojson',
data: data,
});
map_.addSource(SOURCE, { type: 'geojson', data });
}
if (!map_.getLayer('simplified')) {
if (!map_.getLayer(SOURCE)) {
map_.addLayer(
{
id: 'simplified',
id: SOURCE,
type: 'line',
source: 'simplified',
paint: {
'line-color': 'white',
'line-width': 3,
},
source: SOURCE,
paint: { 'line-color': 'white', 'line-width': 3 },
},
ANCHOR_LAYER_KEY.interactions
);
}
}
reduce() {
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
this._simplified.forEach(([item, maxPts, points], itemFullId) => {
if (!get(selection).hasAnyParent(item)) {
return;
}
itemsAndPoints.set(
item,
points
.filter(
(point) => point.distance === undefined || point.distance >= get(tolerance)
)
.map((point) => point.point)
);
});
fileActions.reduce(itemsAndPoints);
}
get currentPoints() {
return this._currentPoints;
}
get maxPoints() {
return this._maxPoints;
}
destroy() {
this._fileStateCollectionObserver.destroy();
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
const map_ = get(map);
if (!map_) {
return;
}
if (map_.getLayer('simplified')) {
map_.removeLayer('simplified');
if (map_.getLayer(SOURCE)) {
map_.removeLayer(SOURCE);
}
if (map_.getSource('simplified')) {
map_.removeSource('simplified');
if (map_.getSource(SOURCE)) {
map_.removeSource(SOURCE);
}
}
}
@@ -30,7 +30,7 @@
import { settings } from '$lib/logic/settings';
import { map } from '$lib/components/map/map';
import { engine } from '$lib/engine';
import { fileActions, newFileName } from '$lib/logic/file-actions';
import { newFileName } from '$lib/logic/file-actions';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { RoutingControls } from './routing-controls';
+18
View File
@@ -701,6 +701,24 @@ class Engine {
);
}
/**
* Removes the trackpoints of the selection that are less than `tolerance` meters away from the
* line of their neighbours, except the ends of the segments. Resolves to `false` when there
* is nothing to remove.
*/
reduce(tolerance: number) {
return this.run((w) => w.reduce(tolerance));
}
/**
* For each trackpoint of the selection, in the order of the statistics, the highest tolerance
* (m) of `reduce` that keeps it: infinite for the ends of the segments. Not requested with
* the statistics: ask when they changed.
*/
reductionDistances(): Float64Array {
return this.wasm?.reduction_distances() ?? new Float64Array();
}
/**
* Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the
* selection in two, there: the trackpoint ends the first part and starts the second one. The
-35
View File
@@ -96,38 +96,3 @@ export async function loadFiles(list: FileList | File[]): Promise<string[]> {
}
return ids;
}
// Helper functions for file operations
export const fileActions = {
reduce: (itemsAndPoints: Map<ListItem, TrackPoint[]>) => {
if (itemsAndPoints.size === 0) {
return;
}
fileActionManager.applyGlobal((draft) => {
let allItems = Array.from(itemsAndPoints.keys());
applyToOrderedItemsFromFile(allItems, (fileId, level, items) => {
let file = draft.get(fileId);
if (file) {
for (let item of items) {
if (item instanceof ListTrackSegmentItem) {
let trackIndex = item.getTrackIndex();
let segmentIndex = item.getSegmentIndex();
let points = itemsAndPoints.get(item);
if (points) {
file.replaceTrackPoints(
trackIndex,
segmentIndex,
0,
file.trk[trackIndex].trkseg[
segmentIndex
].getNumberOfTrackPoints() - 1,
points
);
}
}
}
}
});
});
},
};