mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-09-02 00:32:33 +00:00
simplify tool
This commit is contained in:
@@ -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" />
|
||||
|
@@ -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>
|
||||
|
173
website/src/lib/components/toolbar/tools/Reduce.svelte
Normal file
173
website/src/lib/components/toolbar/tools/Reduce.svelte
Normal 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>
|
@@ -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));
|
||||
}
|
Reference in New Issue
Block a user