simplify tool

This commit is contained in:
vcoppe
2024-06-11 19:08:46 +02:00
parent 9c5ca84004
commit afbf60c5b2
10 changed files with 360 additions and 119 deletions

View File

@@ -48,7 +48,7 @@
</ToolbarItem>
<ToolbarItem tool={Tool.REDUCE}>
<Filter slot="icon" size="18" />
<span slot="tooltip">{$_('toolbar.reduce_tooltip')}</span>
<span slot="tooltip">{$_('toolbar.reduce.tooltip')}</span>
</ToolbarItem>
<ToolbarItem tool={Tool.CLEAN}>
<SquareDashedMousePointer slot="icon" size="18" />

View File

@@ -7,6 +7,7 @@
import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
import Merge from '$lib/components/toolbar/tools/Merge.svelte';
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
import Reduce from '$lib/components/toolbar/tools/Reduce.svelte';
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
import { onMount } from 'svelte';
import mapboxgl from 'mapbox-gl';
@@ -42,6 +43,8 @@
<Merge />
{:else if $currentTool === Tool.CLEAN}
<Clean />
{:else if $currentTool === Tool.REDUCE}
<Reduce />
{/if}
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,173 @@
<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 { selection } from '$lib/components/file-list/Selection';
import { ListItem, ListRootItem, ListTrackSegmentItem } from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte';
import { Filter } from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import WithUnits from '$lib/components/WithUnits.svelte';
import { ramerDouglasPeucker, type SimplifiedTrackPoint } from '$lib/simplify';
import { dbUtils, fileObservers } from '$lib/db';
import { map } from '$lib/stores';
import { onDestroy } from 'svelte';
import { TrackPoint } from 'gpx';
import { derived } from 'svelte/store';
let sliderValue = [50];
let maxPoints = 0;
let currentPoints = 0;
$: validSelection = $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
$: tolerance = 2 ** (sliderValue[0] / (100 / Math.log2(10000)));
let simplified = new Map<string, [ListItem, number, SimplifiedTrackPoint[]]>();
let unsubscribes = new Map<string, () => void>();
function update() {
maxPoints = 0;
currentPoints = 0;
let data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: []
};
simplified.forEach(([item, maxPts, points], itemFullId) => {
maxPoints += maxPts;
let current = points.filter(
(point) => point.distance === undefined || point.distance >= tolerance
);
currentPoints += current.length;
data.features.push({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: current.map((point) => [
point.point.getLongitude(),
point.point.getLatitude()
])
},
properties: {}
});
});
if ($map) {
let source = $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');
}
}
}
$: if ($fileObservers) {
unsubscribes.forEach((unsubscribe, fileId) => {
if (!$fileObservers.has(fileId)) {
unsubscribe();
unsubscribes.delete(fileId);
}
});
$fileObservers.forEach((fileStore, fileId) => {
if (!unsubscribes.has(fileId)) {
let unsubscribe = derived([fileStore, selection], ([fs, sel]) => [fs, sel]).subscribe(
([fs, sel]) => {
if (fs) {
fs.file.forEachSegment((segment, trackIndex, segmentIndex) => {
let segmentItem = new ListTrackSegmentItem(fileId, trackIndex, segmentIndex);
if (sel.hasAnyParent(segmentItem)) {
let statistics = fs.statistics.getStatisticsFor(segmentItem);
simplified.set(segmentItem.getFullId(), [
segmentItem,
statistics.local.points.length,
ramerDouglasPeucker(statistics.local.points, 1)
]);
update();
} else if (simplified.has(segmentItem.getFullId())) {
simplified.delete(segmentItem.getFullId());
update();
}
});
}
}
);
unsubscribes.set(fileId, unsubscribe);
}
});
}
$: if (tolerance) {
update();
}
onDestroy(() => {
if ($map) {
if ($map.getLayer('simplified')) {
$map.removeLayer('simplified');
}
if ($map.getSource('simplified')) {
$map.removeSource('simplified');
}
}
});
function reduce() {
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
simplified.forEach(([item, maxPts, points], itemFullId) => {
itemsAndPoints.set(
item,
points
.filter((point) => point.distance === undefined || point.distance >= tolerance)
.map((point) => point.point)
);
});
dbUtils.reduce(itemsAndPoints);
}
</script>
<div class="flex flex-col gap-3 max-w-80">
<div class="p-2">
<Slider bind:value={sliderValue} min={0} max={100} step={1} />
</div>
<Label class="flex flex-row justify-between">
<span>{$_('toolbar.reduce.tolerance')}</span>
<WithUnits value={tolerance / 1000} type="distance" decimals={3} />
</Label>
<Label class="flex flex-row justify-between">
<span>{$_('toolbar.reduce.number_of_points')}</span>
<span>{currentPoints}/{maxPoints}</span>
</Label>
<Button variant="outline" disabled={!validSelection} on:click={reduce}>
<Filter size="16" class="mr-1" />
{$_('toolbar.reduce.button')}
</Button>
<Help>
{#if validSelection}
{$_('toolbar.reduce.help')}
{:else}
{$_('toolbar.reduce.help_no_selection')}
{/if}
</Help>
</div>

View File

@@ -1,8 +1,5 @@
import type { Coordinates, GPXFile, TrackPoint, TrackSegment } from "gpx";
type SimplifiedTrackPoint = { point: TrackPoint, distance?: number };
const earthRadius = 6371008.8;
import { earthRadius, ramerDouglasPeucker } from "$lib/simplify";
import type { GPXFile, TrackSegment } from "gpx";
export function getZoomLevelForDistance(latitude: number, distance?: number): number {
if (distance === undefined) {
@@ -49,96 +46,3 @@ function computeAnchorPoints(segment: TrackSegment) {
segment._data.anchors = true;
}
export function ramerDouglasPeucker(points: readonly TrackPoint[], epsilon: number = 50, start: number = 0, end: number = points.length - 1): SimplifiedTrackPoint[] {
if (points.length == 0) {
return [];
} else if (points.length == 1) {
return [{
point: points[0]
}];
}
let simplified = [{
point: points[start]
}];
ramerDouglasPeuckerRecursive(points, epsilon, start, end, simplified);
simplified.push({
point: points[end]
});
return simplified;
}
function ramerDouglasPeuckerRecursive(points: readonly TrackPoint[], epsilon: number, start: number, end: number, simplified: SimplifiedTrackPoint[]) {
let largest = {
index: 0,
distance: 0
};
for (let i = start + 1; i < end; i++) {
let distance = crossarc(points[start].getCoordinates(), points[end].getCoordinates(), points[i].getCoordinates());
if (distance > largest.distance) {
largest.index = i;
largest.distance = distance;
}
}
if (largest.distance > epsilon) {
ramerDouglasPeuckerRecursive(points, epsilon, start, largest.index, simplified);
simplified.push({ point: points[largest.index], distance: largest.distance });
ramerDouglasPeuckerRecursive(points, epsilon, largest.index, end, simplified);
}
}
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
// Calculates the shortest distance in meters
// between an arc (defined by p1 and p2) and a third point, p3.
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
const rad = Math.PI / 180;
const lat1 = coord1.lat * rad;
const lat2 = coord2.lat * rad;
const lat3 = coord3.lat * rad;
const lon1 = coord1.lon * rad;
const lon2 = coord2.lon * rad;
const lon3 = coord3.lon * rad;
// Prerequisites for the formulas
const bear12 = bearing(lat1, lon1, lat2, lon2);
const bear13 = bearing(lat1, lon1, lat3, lon3);
let dis13 = distance(lat1, lon1, lat3, lon3);
let diff = Math.abs(bear13 - bear12);
if (diff > Math.PI) {
diff = 2 * Math.PI - diff;
}
// Is relative bearing obtuse?
if (diff > (Math.PI / 2)) {
return dis13;
}
// Find the cross-track distance.
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
// Is p4 beyond the arc?
let dis12 = distance(lat1, lon1, lat2, lon2);
let dis14 = Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
if (dis14 > dis12) {
return distance(lat2, lon2, lat3, lon3);
} else {
return Math.abs(dxt);
}
}
function distance(latA: number, lonA: number, latB: number, lonB: number): number {
// Finds the distance between two lat / lon points.
return Math.acos(Math.sin(latA) * Math.sin(latB) + Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)) * earthRadius;
}
function bearing(latA: number, lonA: number, latB: number, lonB: number): number {
// Finds the bearing from one lat / lon point to another.
return Math.atan2(Math.sin(lonB - lonA) * Math.cos(latB),
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA));
}