diff --git a/gpx/src/gpx.ts b/gpx/src/gpx.ts index 35823706..3fcb09ea 100644 --- a/gpx/src/gpx.ts +++ b/gpx/src/gpx.ts @@ -8,7 +8,7 @@ function cloneJSON(obj: T): T { } // An abstract class that groups functions that need to be computed recursively in the GPX file hierarchy -abstract class GPXTreeElement> { +export abstract class GPXTreeElement> { _data: { [key: string]: any } = {}; abstract isLeaf(): boolean; @@ -27,6 +27,8 @@ abstract class GPXTreeElement> { abstract toGeoJSON(): GeoJSON.Feature | GeoJSON.Feature[] | GeoJSON.FeatureCollection | GeoJSON.FeatureCollection[]; } +export type AnyGPXTreeElement = GPXTreeElement>; + // An abstract class that can be extended to facilitate functions working similarly with Tracks and TrackSegments abstract class GPXTreeNode> extends GPXTreeElement { isLeaf(): boolean { @@ -286,6 +288,7 @@ export class TrackSegment extends GPXTreeLeaf { const points = this.trkpt; for (let i = 0; i < points.length; i++) { points[i]._data['index'] = i; + points[i]._data['segment'] = this; // distance let dist = 0; @@ -370,6 +373,11 @@ export class TrackSegment extends GPXTreeLeaf { this._computeStatistics(); } + replace(start: number, end: number, points: TrackPoint[]): void { + this.trkpt.splice(start, end - start + 1, ...points); + this._computeStatistics(); + } + reverse(originalNextTimestamp: Date | undefined, newPreviousTimestamp: Date | undefined): void { if (originalNextTimestamp !== undefined && newPreviousTimestamp !== undefined) { let originalEndTimestamp = this.getEndTimestamp(); @@ -460,6 +468,10 @@ export class TrackPoint { return this.attributes; } + setCoordinates(coordinates: Coordinates): void { + this.attributes = coordinates; + } + getLatitude(): number { return this.attributes.lat; } @@ -599,7 +611,7 @@ export type TrackPointStatistics = { } const earthRadius = 6371008.8; -function distance(coord1: Coordinates, coord2: Coordinates): number { +export function distance(coord1: Coordinates, coord2: Coordinates): number { const rad = Math.PI / 180; const lat1 = coord1.lat * rad; const lat2 = coord2.lat * rad; diff --git a/website/src/lib/components/routing/Routing.ts b/website/src/lib/components/routing/Routing.ts index 137b9f56..66ef7e2e 100644 --- a/website/src/lib/components/routing/Routing.ts +++ b/website/src/lib/components/routing/Routing.ts @@ -35,7 +35,7 @@ export function route(points: Coordinates[]): Promise { } async function getRoute(points: Coordinates[], brouterProfile: string, privateRoads: boolean): Promise { - let url = `https://routing.gpx.studio?lonlats=${points.map(point => `${point.lon},${point.lat}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`; + let url = `https://routing.gpx.studio?lonlats=${points.map(point => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`; let response = await fetch(url); let geojson = await response.json(); diff --git a/website/src/lib/components/routing/RoutingControls.ts b/website/src/lib/components/routing/RoutingControls.ts index dba227bd..ce505f1d 100644 --- a/website/src/lib/components/routing/RoutingControls.ts +++ b/website/src/lib/components/routing/RoutingControls.ts @@ -1,9 +1,9 @@ -import type { Coordinates, GPXFile } from "gpx"; +import { distance, type Coordinates, type GPXFile, type TrackSegment } from "gpx"; import { get, type Writable } from "svelte/store"; -import { computeAnchorPoints } from "./Simplify"; +import { computeAnchorPoints, type SimplifiedTrackPoint } from "./Simplify"; import mapboxgl from "mapbox-gl"; import { route } from "./Routing"; -import { applyToFileStore } from "$lib/stores"; +import { applyToFileElement, applyToFileStore } from "$lib/stores"; export class RoutingControls { map: mapboxgl.Map; @@ -17,14 +17,10 @@ export class RoutingControls { constructor(map: mapboxgl.Map, file: Writable) { this.map = map; this.file = file; - - computeAnchorPoints(get(file)); - this.createMarkers(); this.add(); } add() { - this.toggleMarkersForZoomLevelAndBounds(); this.map.on('zoom', this.toggleMarkersForZoomLevelAndBoundsBinded); this.map.on('move', this.toggleMarkersForZoomLevelAndBoundsBinded); this.map.on('click', this.extendFileBinded); @@ -34,7 +30,28 @@ export class RoutingControls { updateControls() { // Update controls - console.log('updateControls'); + for (let segment of get(this.file).getSegments()) { + if (!segment._data.anchors) { // New segment + computeAnchorPoints(segment); + this.createMarkers(segment); + continue; + } + + let anchors = segment._data.anchors; + for (let i = 0; i < anchors.length;) { + let anchor = anchors[i]; + if (anchor.point._data.index >= segment.trkpt.length || anchor.point !== segment.trkpt[anchor.point._data.index]) { // Point removed + anchors.splice(i, 1); + let markerIndex = this.markers.findIndex(marker => marker._simplified === anchor); + this.markers[markerIndex].remove(); + this.markers.splice(markerIndex, 1); + continue; + } + i++; + } + } + + this.toggleMarkersForZoomLevelAndBounds(); } remove() { @@ -48,35 +65,138 @@ export class RoutingControls { this.unsubscribe(); } - createMarkers() { - for (let segment of get(this.file).getSegments()) { - for (let anchor of segment._data.anchors) { - let marker = getMarker(anchor.point.getCoordinates(), true); - Object.defineProperty(marker, '_simplified', { - value: anchor - }); - this.markers.push(marker); - } + createMarkers(segment: TrackSegment) { + for (let anchor of segment._data.anchors) { + this.createMarker(anchor); } } + createMarker(anchor: SimplifiedTrackPoint) { + let element = document.createElement('div'); + element.className = `h-3 w-3 rounded-full bg-background border-2 border-black cursor-pointer`; + + let marker = new mapboxgl.Marker({ + draggable: true, + element + }).setLngLat(anchor.point.getCoordinates()); + + Object.defineProperty(marker, '_simplified', { + value: anchor + }); + anchor.marker = marker; + + marker.on('dragend', this.updateAnchor.bind(this)); + + this.markers.push(marker); + } + toggleMarkersForZoomLevelAndBounds() { let zoom = this.map.getZoom(); this.markers.forEach((marker) => { if (marker._simplified.zoom <= zoom && this.map.getBounds().contains(marker.getLngLat())) { marker.addTo(this.map); + Object.defineProperty(marker, '_inZoom', { + value: true, + writable: true + }); } else { marker.remove(); + Object.defineProperty(marker, '_inZoom', { + value: false, + writable: true + }); } }); } + updateAnchor(e: any) { + let marker = e.target; + let anchor = marker._simplified; + + let latlng = marker.getLngLat(); + let coordinates = { + lat: latlng.lat, + lon: latlng.lng + }; + + let segment = anchor.point._data.segment; + let anchors = segment._data.anchors; + + let previousAnchor: SimplifiedTrackPoint | null = null; + let nextAnchor: SimplifiedTrackPoint | null = null; + + for (let i = 0; i < anchors.length; i++) { + if (anchors[i].point._data.index < anchor.point._data.index && anchors[i].marker._inZoom) { + if (!previousAnchor || anchors[i].point._data.index > previousAnchor.point._data.index) { + previousAnchor = anchors[i]; + } + } else if (anchors[i].point._data.index > anchor.point._data.index && anchors[i].marker._inZoom) { + if (!nextAnchor || anchors[i].point._data.index < nextAnchor.point._data.index) { + nextAnchor = anchors[i]; + } + } + } + + let routeCoordinates = []; + if (previousAnchor) { + routeCoordinates.push(previousAnchor.point.getCoordinates()); + } + routeCoordinates.push(coordinates); + if (nextAnchor) { + routeCoordinates.push(nextAnchor.point.getCoordinates()); + } + + let start = previousAnchor ? previousAnchor.point._data.index + 1 : anchor.point._data.index; + let end = nextAnchor ? nextAnchor.point._data.index - 1 : anchor.point._data.index; + + if (routeCoordinates.length === 1) { + return; + } else { + route(routeCoordinates).then((response) => { + if (previousAnchor) { + previousAnchor.zoom = 0; + } else { + anchor.zoom = 0; + anchor.point = response[0]; + } + if (nextAnchor) { + nextAnchor.zoom = 0; + } else { + anchor.zoom = 0; + anchor.point = response[response.length - 1]; + } + + // find closest point to the dragged marker + // and transfer the marker to that point + if (previousAnchor && nextAnchor) { + let minDistance = Number.MAX_VALUE; + for (let i = 1; i < response.length - 1; i++) { + let dist = distance(response[i].getCoordinates(), anchor.point.getCoordinates()); + if (dist < minDistance) { + minDistance = dist; + anchor.zoom = 0; + anchor.point = response[i]; + } + } + } + + marker.setLngLat(anchor.point.getCoordinates()); + + applyToFileElement(this.file, segment, (segment) => { + segment.replace(start, end, response); + }, true); + }); + } + } + async extendFile(e: mapboxgl.MapMouseEvent) { let segments = get(this.file).getSegments(); if (segments.length === 0) { return; } - let anchors = segments[segments.length - 1]._data.anchors; + + let segment = segments[segments.length - 1]; + let anchors = segment._data.anchors; let lastAnchor = anchors[anchors.length - 1]; let newPoint = { @@ -86,15 +206,13 @@ export class RoutingControls { let response = await route([lastAnchor.point.getCoordinates(), newPoint]); + let anchor = { + point: response[response.length - 1], + zoom: 0 + }; + segment._data.anchors.push(anchor); + this.createMarker(anchor); + applyToFileStore(this.file, (f) => f.append(response), true); } -} - -export function getMarker(coordinates: Coordinates, draggable: boolean = false): mapboxgl.Marker { - let element = document.createElement('div'); - element.className = `h-3 w-3 rounded-full bg-background border-2 border-black cursor-pointer`; - return new mapboxgl.Marker({ - draggable, - element - }).setLngLat(coordinates); } \ No newline at end of file diff --git a/website/src/lib/components/routing/Simplify.ts b/website/src/lib/components/routing/Simplify.ts index 15c79d90..16342a2a 100644 --- a/website/src/lib/components/routing/Simplify.ts +++ b/website/src/lib/components/routing/Simplify.ts @@ -1,6 +1,6 @@ -import type { Coordinates, GPXFile, TrackPoint } from "gpx"; +import type { Coordinates, TrackPoint, TrackSegment } from "gpx"; -export type SimplifiedTrackPoint = { point: TrackPoint, distance?: number, zoom?: number }; +export type SimplifiedTrackPoint = { point: TrackPoint, distance?: number, zoom?: number, marker?: mapboxgl.Marker }; const earthRadius = 6371008.8; @@ -15,27 +15,22 @@ export function getZoomLevelForDistance(latitude: number, distance?: number): nu return Math.min(20, Math.max(0, Math.floor(Math.log2((earthRadius * Math.cos(lat)) / distance)))); } -export function computeAnchorPoints(file: GPXFile) { - for (let segment of file.getSegments()) { - let points = segment.trkpt; - let anchors = ramerDouglasPeucker(points); - anchors.forEach((point) => { - point.zoom = getZoomLevelForDistance(point.point.getLatitude(), point.distance); - }); - segment._data['anchors'] = anchors; - } +export function computeAnchorPoints(segment: TrackSegment) { + let points = segment.trkpt; + let anchors = ramerDouglasPeucker(points); + anchors.forEach((point) => { + point.zoom = getZoomLevelForDistance(point.point.getLatitude(), point.distance); + }); + segment._data['anchors'] = anchors; } export function ramerDouglasPeucker(points: TrackPoint[], epsilon: number = 50, start: number = 0, end: number = points.length - 1): SimplifiedTrackPoint[] { let simplified = [{ - point: points[start], - index: start, - + point: points[start] }]; ramerDouglasPeuckerRecursive(points, epsilon, start, end, simplified); simplified.push({ - point: points[end], - index: end + point: points[end] }); return simplified; } diff --git a/website/src/lib/stores.ts b/website/src/lib/stores.ts index 3bc3b140..680ae338 100644 --- a/website/src/lib/stores.ts +++ b/website/src/lib/stores.ts @@ -1,7 +1,7 @@ import { writable, get, type Writable } from 'svelte/store'; import mapboxgl from 'mapbox-gl'; -import { GPXFile, buildGPX, parseGPX } from 'gpx'; +import { GPXFile, buildGPX, parseGPX, type AnyGPXTreeElement } from 'gpx'; export const map = writable(null); export const files = writable[]>([]); @@ -26,6 +26,16 @@ export function getFileIndex(file: GPXFile): number { return get(files).findIndex(store => get(store) === file); } +export function applyToFileElement(store: Writable, element: T, callback: (element: T) => void, updateSelected: boolean) { + store.update($file => { + callback(element); + return $file; + }); + if (updateSelected) { + selectedFiles.update($selected => $selected); + } +} + export function applyToFile(file: GPXFile, callback: (file: GPXFile) => void, updateSelected: boolean) { let store = getFileStore(file); applyToFileStore(store, callback, updateSelected);