mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-10 07:36:29 +00:00
reduce
This commit is contained in:
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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user