diff --git a/website/src/lib/components/WithUnits.svelte b/website/src/lib/components/WithUnits.svelte index 86845344..3212872c 100644 --- a/website/src/lib/components/WithUnits.svelte +++ b/website/src/lib/components/WithUnits.svelte @@ -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; @@ -20,26 +21,27 @@ {#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') : ''} diff --git a/website/src/lib/components/file-list/FileList.ts b/website/src/lib/components/file-list/FileList.ts index 53004fd7..0e8db1b8 100644 --- a/website/src/lib/components/file-list/FileList.ts +++ b/website/src/lib/components/file-list/FileList.ts @@ -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: diff --git a/website/src/lib/components/file-list/Selection.ts b/website/src/lib/components/file-list/Selection.ts index 52279432..709199ed 100644 --- a/website/src/lib/components/file-list/Selection.ts +++ b/website/src/lib/components/file-list/Selection.ts @@ -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); } \ No newline at end of file diff --git a/website/src/lib/components/toolbar/Toolbar.svelte b/website/src/lib/components/toolbar/Toolbar.svelte index 51bf8512..75779ffc 100644 --- a/website/src/lib/components/toolbar/Toolbar.svelte +++ b/website/src/lib/components/toolbar/Toolbar.svelte @@ -48,7 +48,7 @@ - {$_('toolbar.reduce_tooltip')} + {$_('toolbar.reduce.tooltip')} diff --git a/website/src/lib/components/toolbar/ToolbarItemMenu.svelte b/website/src/lib/components/toolbar/ToolbarItemMenu.svelte index 4dd34230..7b32af6e 100644 --- a/website/src/lib/components/toolbar/ToolbarItemMenu.svelte +++ b/website/src/lib/components/toolbar/ToolbarItemMenu.svelte @@ -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 @@ {:else if $currentTool === Tool.CLEAN} + {:else if $currentTool === Tool.REDUCE} + {/if} diff --git a/website/src/lib/components/toolbar/tools/Reduce.svelte b/website/src/lib/components/toolbar/tools/Reduce.svelte new file mode 100644 index 00000000..5e74d4d3 --- /dev/null +++ b/website/src/lib/components/toolbar/tools/Reduce.svelte @@ -0,0 +1,173 @@ + + +
+
+ +
+ + + + + + {#if validSelection} + {$_('toolbar.reduce.help')} + {:else} + {$_('toolbar.reduce.help_no_selection')} + {/if} + +
diff --git a/website/src/lib/components/toolbar/tools/routing/Simplify.ts b/website/src/lib/components/toolbar/tools/routing/Simplify.ts index ae0b9150..1cf42a82 100644 --- a/website/src/lib/components/toolbar/tools/routing/Simplify.ts +++ b/website/src/lib/components/toolbar/tools/routing/Simplify.ts @@ -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)); -} \ No newline at end of file diff --git a/website/src/lib/db.ts b/website/src/lib/db.ts index 83c82fc9..3858b57a 100644 --- a/website/src/lib/db.ts +++ b/website/src/lib/db.ts @@ -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) => { + 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; diff --git a/website/src/lib/simplify.ts b/website/src/lib/simplify.ts new file mode 100644 index 00000000..4b0669c7 --- /dev/null +++ b/website/src/lib/simplify.ts @@ -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)); +} \ No newline at end of file diff --git a/website/src/locales/en.json b/website/src/locales/en.json index b58387ec..4add8aee 100644 --- a/website/src/locales/en.json +++ b/website/src/locales/en.json @@ -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" },