fix tools

This commit is contained in:
vcoppe
2025-10-18 16:10:08 +02:00
parent 9fa8fe5767
commit c59cd66141
60 changed files with 1289 additions and 1161 deletions

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import { Slider } from '$lib/components/ui/slider';
import { ListItem, 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 './reduce';
let props: { class?: string } = $props();
let sliderValue = $state([50]);
let maxPoints = $state(0);
let currentPoints = $state(0);
const maxTolerance = 10000;
let validSelection = $derived(
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints'])
);
let reducedLayers = new ReducedGPXLayerCollection();
$effect(() => {
tolerance.set(
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)))
);
});
onDestroy(() => {
reducedLayers.destroy();
});
</script>
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
<div class="p-2">
<Slider bind:value={sliderValue} min={0} max={100} step={1} type="multiple" />
</div>
<Label class="flex flex-row justify-between">
<span>{i18n._('toolbar.reduce.tolerance')}</span>
<WithUnits value={$tolerance / 1000} type="distance" decimals={4} class="font-normal" />
</Label>
<Label class="flex flex-row justify-between">
<span>{i18n._('toolbar.reduce.number_of_points')}</span>
<span class="font-normal">{currentPoints}/{maxPoints}</span>
</Label>
<Button variant="outline" disabled={!validSelection} onclick={() => reducedLayers.reduce()}>
<Funnel size="16" class="mr-1" />
{i18n._('toolbar.reduce.button')}
</Button>
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/minify')}>
{#if validSelection}
{i18n._('toolbar.reduce.help')}
{:else}
{i18n._('toolbar.reduce.help_no_selection')}
{/if}
</Help>
</div>

View File

@@ -0,0 +1,187 @@
import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list';
import { map } from '$lib/components/map/map';
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 { GeoJSONSource } from 'mapbox-gl';
import { get, writable } from 'svelte/store';
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;
const stats = this._fileState.statistics;
if (!file || !stats) {
return;
}
file.forEachSegment((segment, trackIndex, segmentIndex) => {
let segmentItem = new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex);
let statistics = stats.getStatisticsFor(segmentItem);
this._updateSimplified(segmentItem.getFullId(), [
segmentItem,
statistics.local.points.length,
ramerDouglasPeucker(statistics.local.points, 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 _fileStateCollectionOberver: GPXFileStateCollectionObserver;
private _updateSimplified = this.updateSimplified.bind(this);
private _unsubscribes: (() => void)[] = [];
constructor() {
this._layers = new Map();
this._simplified = new Map();
this._fileStateCollectionOberver = new GPXFileStateCollectionObserver(
(fileId, fileState) => {
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();
}
);
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() {
let maxPoints = 0;
let currentPoints = 0;
let data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
};
this._simplified.forEach(([item, maxPts, points], itemFullId) => {
if (!get(selection).hasAnyParent(item)) {
return;
}
maxPoints += maxPts;
let current = points.filter(
(point) => point.distance === undefined || point.distance >= get(tolerance)
);
currentPoints += current.length;
data.features.push({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: current.map((point) => [
point.point.getLongitude(),
point.point.getLatitude(),
]),
},
properties: {},
});
});
const map_ = get(map);
if (!map_) {
return;
}
let source: GeoJSONSource | undefined = map_.getSource('simplified');
if (source) {
source.setData(data);
} else {
map_.addSource('simplified', {
type: 'geojson',
data: data,
});
}
if (!map_.getLayer('simplified')) {
map_.addLayer({
id: 'simplified',
type: 'line',
source: 'simplified',
paint: {
'line-color': 'white',
'line-width': 3,
},
});
} else {
map_.moveLayer('simplified');
}
}
reduce() {
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
this._simplified.forEach(([item, maxPts, points], itemFullId) => {
itemsAndPoints.set(
item,
points
.filter(
(point) => point.distance === undefined || point.distance >= get(tolerance)
)
.map((point) => point.point)
);
});
fileActions.reduce(itemsAndPoints);
}
destroy() {
this._fileStateCollectionOberver.destroy();
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
const map_ = get(map);
if (!map_) {
return;
}
if (map_.getLayer('simplified')) {
map_.removeLayer('simplified');
}
if (map_.getSource('simplified')) {
map_.removeSource('simplified');
}
}
}