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

@@ -13,6 +13,7 @@
export let value: number;
export let type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
export let showUnits: boolean = true;
export let decimals: number | undefined = undefined;
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
</script>
@@ -20,26 +21,27 @@
<span class={$$props.class}>
{#if type === 'distance'}
{#if $distanceUnits === 'metric'}
{value.toFixed(2)} {showUnits ? $_('units.kilometers') : ''}
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers') : ''}
{:else}
{kilometersToMiles(value).toFixed(2)} {showUnits ? $_('units.miles') : ''}
{kilometersToMiles(value).toFixed(decimals ?? 2)} {showUnits ? $_('units.miles') : ''}
{/if}
{:else if type === 'elevation'}
{#if $distanceUnits === 'metric'}
{value.toFixed(0)} {showUnits ? $_('units.meters') : ''}
{value.toFixed(decimals ?? 0)} {showUnits ? $_('units.meters') : ''}
{:else}
{metersToFeet(value).toFixed(0)} {showUnits ? $_('units.feet') : ''}
{metersToFeet(value).toFixed(decimals ?? 0)} {showUnits ? $_('units.feet') : ''}
{/if}
{:else if type === 'speed'}
{#if $distanceUnits === 'metric'}
{#if $velocityUnits === 'speed'}
{value.toFixed(2)} {showUnits ? $_('units.kilometers_per_hour') : ''}
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers_per_hour') : ''}
{:else}
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(value))}
{showUnits ? $_('units.minutes_per_kilometer') : ''}
{/if}
{:else if $velocityUnits === 'speed'}
{kilometersToMiles(value).toFixed(2)} {showUnits ? $_('units.miles_per_hour') : ''}
{kilometersToMiles(value).toFixed(decimals ?? 2)}
{showUnits ? $_('units.miles_per_hour') : ''}
{:else}
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(kilometersToMiles(value)))}
{showUnits ? $_('units.minutes_per_mile') : ''}

View File

@@ -1,8 +1,7 @@
import { dbUtils, fileObservers } from "$lib/db";
import { dbUtils } from "$lib/db";
import { castDraft, freeze } from "immer";
import { Track, TrackSegment, Waypoint } from "gpx";
import { selection } from "./Selection";
import { get } from "svelte/store";
import { newGPXFile } from "$lib/stores";
export enum ListLevel {
@@ -22,6 +21,7 @@ export abstract class ListItem {
}
abstract getId(): string | number;
abstract getFullId(): string;
abstract getIdAtLevel(level: ListLevel): string | number | undefined;
abstract getFileId(): string;
abstract extend(id: string | number): ListItem;
@@ -36,6 +36,10 @@ export class ListRootItem extends ListItem {
return 'root';
}
getFullId(): string {
return 'root';
}
getIdAtLevel(level: ListLevel): string | number | undefined {
return undefined;
}
@@ -61,6 +65,10 @@ export class ListFileItem extends ListItem {
return this.fileId;
}
getFullId(): string {
return this.fileId;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
@@ -97,6 +105,10 @@ export class ListTrackItem extends ListItem {
return this.trackIndex;
}
getFullId(): string {
return `${this.fileId}-track-${this.trackIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
@@ -137,6 +149,10 @@ export class ListTrackSegmentItem extends ListItem {
return this.segmentIndex;
}
getFullId(): string {
return `${this.fileId}-track-${this.trackIndex}--${this.segmentIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
@@ -179,6 +195,10 @@ export class ListWaypointsItem extends ListItem {
return 'waypoints';
}
getFullId(): string {
return `${this.fileId}-waypoints`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
@@ -213,6 +233,10 @@ export class ListWaypointItem extends ListItem {
return this.waypointIndex;
}
getFullId(): string {
return `${this.fileId}-waypoint-${this.waypointIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:

View File

@@ -200,11 +200,11 @@ export function selectAll() {
});
}
export function applyToOrderedSelectedItemsFromFile(callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
get(settings.fileOrder).forEach((fileId) => {
let level: ListLevel | undefined = undefined;
let items: ListItem[] = [];
get(selection).forEach((item) => {
selectedItems.forEach((item) => {
if (item.getFileId() === fileId) {
level = item.level;
if (item instanceof ListFileItem || item instanceof ListTrackItem || item instanceof ListTrackSegmentItem || item instanceof ListWaypointsItem || item instanceof ListWaypointItem) {
@@ -218,4 +218,8 @@ export function applyToOrderedSelectedItemsFromFile(callback: (fileId: string, l
callback(fileId, level, items);
}
});
}
export function applyToOrderedSelectedItemsFromFile(callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse);
}

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));
}

View File

@@ -5,7 +5,7 @@ import { writable, get, derived, type Readable, type Writable } from 'svelte/sto
import { initTargetMapBounds, splitAs, updateTargetMapBounds } from './stores';
import { mode } from 'mode-watcher';
import { defaultBasemap, defaultBasemapTree, defaultOverlayTree, defaultOverlays } from './assets/layers';
import { applyToOrderedSelectedItemsFromFile, selection } from '$lib/components/file-list/Selection';
import { applyToOrderedItemsFromFile, applyToOrderedSelectedItemsFromFile, selection } from '$lib/components/file-list/Selection';
import { ListFileItem, ListItem, ListTrackItem, ListLevel, ListTrackSegmentItem, ListWaypointItem, ListRootItem } from '$lib/components/file-list/FileList';
import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simplify';
import { SplitType } from '$lib/components/toolbar/tools/Scissors.svelte';
@@ -676,6 +676,31 @@ export const dbUtils = {
});
});
},
reduce: (itemsAndPoints: Map<ListItem, TrackPoint[]>) => {
if (itemsAndPoints.size === 0) {
return;
}
applyGlobal((draft) => {
let allItems = Array.from(itemsAndPoints.keys());
applyToOrderedItemsFromFile(allItems, (fileId, level, items) => {
let file = original(draft)?.get(fileId);
if (file) {
let newFile = 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) {
newFile = newFile.replaceTrackPoints(trackIndex, segmentIndex, 0, file.trk[trackIndex].trkseg[segmentIndex].getNumberOfTrackPoints() - 1, points);
}
}
}
draft.set(newFile._data.id, freeze(newFile));
}
});
});
},
deleteSelection: () => {
if (get(selection).size === 0) {
return;

View File

@@ -0,0 +1,99 @@
import type { Coordinates, TrackPoint } from "gpx";
export type SimplifiedTrackPoint = { point: TrackPoint, distance?: number };
export const earthRadius = 6371008.8;
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 && largest.index != 0) {
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));
}

View File

@@ -60,8 +60,8 @@
"button": "Round trip",
"tooltip": "Return to the starting point by the same route"
},
"help_no_file": "Select a file element to use the routing tool, or create a new file from the menu",
"help_multiple_files": "Select a single file element to use the routing tool",
"help_no_file": "Select a file item to use the routing tool, or create a new file from the menu",
"help_multiple_files": "Select a single file item to use the routing tool",
"help": "Click on the map to add a new anchor point, or drag existing ones to change the route",
"activities": {
"bike": "Bike",
@@ -114,7 +114,7 @@
"tooltip": "Crop or split traces",
"crop": "Crop",
"split_as": "Split the trace into",
"help_invalid_selection": "Select a file element to crop or split",
"help_invalid_selection": "Select a file item to crop or split",
"help": "Use the slider to crop the trace, or click on the map to split it at the selected point"
},
"time_tooltip": "Manage time and speed data",
@@ -122,15 +122,22 @@
"merge_traces": "Connect the traces",
"merge_contents": "Merge the contents and keep the traces disconnected",
"merge_selection": "Merge selection",
"tooltip": "Merge file elements together",
"help_merge_traces": "Connecting the selected traces will result in a single file containing a single continuous trace",
"tooltip": "Merge file items together",
"help_merge_traces": "Connecting the selected traces will create a single file containing a single continuous trace",
"help_cannot_merge_traces": "Your selection needs to contain several traces to connect them",
"help_merge_contents": "Merging the contents of the selected file elements will group all the contents inside the first file element",
"help_cannot_merge_contents": "Your selection needs to contain several file elements to merge their contents"
"help_merge_contents": "Merging the contents of the selected file items will group all the contents inside the first file item",
"help_cannot_merge_contents": "Your selection needs to contain several file items to merge their contents"
},
"extract_tooltip": "Extract inner tracks or segments",
"waypoint_tooltip": "Create and edit points of interest",
"reduce_tooltip": "Reduce the number of GPS points",
"reduce": {
"tooltip": "Reduce the number of GPS points",
"tolerance": "Tolerance",
"number_of_points": "Number of GPS points",
"button": "Reduce",
"help": "Use the slider to choose the number of GPS points to keep",
"help_no_selection": "Select a file item to reduce the number of its GPS points"
},
"clean": {
"tooltip": "Clean GPS points and points of interest with a rectangle selection",
"delete_trackpoints": "Delete GPS points",
@@ -139,7 +146,7 @@
"delete_outside": "Delete outside selection",
"button": "Delete",
"help": "Select a rectangle area on the map to remove GPS points and points of interest",
"help_no_selection": "Select a file element to use the tool"
"help_no_selection": "Select a file item to clean GPS points and points of interest"
},
"style_tooltip": "Change the style of the trace"
},