diff --git a/gpx/package-lock.json b/gpx/package-lock.json index 05299dcf..37f58da1 100644 --- a/gpx/package-lock.json +++ b/gpx/package-lock.json @@ -12,6 +12,7 @@ "ts-node": "^10.9.2" }, "devDependencies": { + "@types/geojson": "^7946.0.14", "@types/jest": "^29.5.12", "@types/node": "^20.12.7", "jest": "^29.7.0", @@ -1038,6 +1039,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/geojson": { + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", diff --git a/gpx/package.json b/gpx/package.json index ea191468..c7b7de20 100644 --- a/gpx/package.json +++ b/gpx/package.json @@ -18,10 +18,11 @@ "test": "jest" }, "devDependencies": { + "@types/geojson": "^7946.0.14", "@types/jest": "^29.5.12", "@types/node": "^20.12.7", "jest": "^29.7.0", "ts-jest": "^29.1.2", "typescript": "^5.4.5" } -} \ No newline at end of file +} diff --git a/gpx/src/gpx.ts b/gpx/src/gpx.ts index b37ac0ef..b615c6de 100644 --- a/gpx/src/gpx.ts +++ b/gpx/src/gpx.ts @@ -9,23 +9,21 @@ 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> { - statistics: GPXStatistics; + _data: { [key: string]: any } = {}; abstract isLeaf(): boolean; abstract getChildren(): T[]; - abstract computeStatistics(): GPXStatistics; - abstract refreshStatistics(): void; - abstract append(points: TrackPoint[]): void; abstract reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date): void; abstract getStartTimestamp(): Date; abstract getEndTimestamp(): Date; + abstract getStatistics(): GPXStatistics; abstract getTrackPoints(): TrackPoint[]; - abstract getTrackPointsAndStatistics(): { points: TrackPoint[], statistics: TrackPointStatistics }; + abstract getTrackPointsAndStatistics(): { points: TrackPoint[], point_statistics: TrackPointStatistics, statistics: GPXStatistics }; - abstract toGeoJSON(): any; + abstract toGeoJSON(): GeoJSON.Feature | GeoJSON.Feature[] | GeoJSON.FeatureCollection | GeoJSON.FeatureCollection[]; } // An abstract class that can be extended to facilitate functions working similarly with Tracks and TrackSegments @@ -34,22 +32,6 @@ abstract class GPXTreeNode> extends GPXTreeElement return false; } - computeStatistics(): GPXStatistics { - for (let child of this.getChildren()) { - child.computeStatistics(); - } - this.refreshStatistics(); - return this.statistics; - } - - refreshStatistics(): void { - this.statistics = new GPXStatistics(); - for (let child of this.getChildren()) { - child.refreshStatistics(); - this.statistics.mergeWith(child.statistics); - } - } - append(points: TrackPoint[]): void { let children = this.getChildren(); @@ -58,8 +40,6 @@ abstract class GPXTreeNode> extends GPXTreeElement } children[children.length - 1].append(points); - - this.refreshStatistics(); } reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date): void { @@ -80,8 +60,6 @@ abstract class GPXTreeNode> extends GPXTreeElement originalNextTimestamp = originalStartTimestamp; newPreviousTimestamp = children[i].getEndTimestamp(); } - - this.refreshStatistics(); } getStartTimestamp(): Date { @@ -96,9 +74,17 @@ abstract class GPXTreeNode> extends GPXTreeElement return this.getChildren().flatMap((child) => child.getTrackPoints()); } - getTrackPointsAndStatistics(): { points: TrackPoint[]; statistics: TrackPointStatistics; } { + getStatistics(): GPXStatistics { + let statistics = new GPXStatistics(); + for (let child of this.getChildren()) { + statistics.mergeWith(child.getStatistics()); + } + return statistics; + } + + getTrackPointsAndStatistics(): { points: TrackPoint[], point_statistics: TrackPointStatistics, statistics: GPXStatistics } { let points: TrackPoint[] = []; - let statistics: TrackPointStatistics = { + let point_statistics: TrackPointStatistics = { distance: [], time: [], speed: [], @@ -109,25 +95,25 @@ abstract class GPXTreeNode> extends GPXTreeElement }, slope: [], }; + let statistics = new GPXStatistics(); - let current = new GPXStatistics(); for (let child of this.getChildren()) { let childData = child.getTrackPointsAndStatistics(); points = points.concat(childData.points); - statistics.distance = statistics.distance.concat(childData.statistics.distance.map((distance) => distance + current.distance.total)); - statistics.time = statistics.time.concat(childData.statistics.time.map((time) => time + current.time.total)); - statistics.elevation.gain = statistics.elevation.gain.concat(childData.statistics.elevation.gain.map((gain) => gain + current.elevation.gain)); - statistics.elevation.loss = statistics.elevation.loss.concat(childData.statistics.elevation.loss.map((loss) => loss + current.elevation.loss)); + point_statistics.distance = point_statistics.distance.concat(childData.point_statistics.distance.map((distance) => distance + statistics.distance.total)); + point_statistics.time = point_statistics.time.concat(childData.point_statistics.time.map((time) => time + statistics.time.total)); + point_statistics.elevation.gain = point_statistics.elevation.gain.concat(childData.point_statistics.elevation.gain.map((gain) => gain + statistics.elevation.gain)); + point_statistics.elevation.loss = point_statistics.elevation.loss.concat(childData.point_statistics.elevation.loss.map((loss) => loss + statistics.elevation.loss)); - statistics.speed = statistics.speed.concat(childData.statistics.speed); - statistics.elevation.smoothed = statistics.elevation.smoothed.concat(childData.statistics.elevation.smoothed); - statistics.slope = statistics.slope.concat(childData.statistics.slope); + point_statistics.speed = point_statistics.speed.concat(childData.point_statistics.speed); + point_statistics.elevation.smoothed = point_statistics.elevation.smoothed.concat(childData.point_statistics.elevation.smoothed); + point_statistics.slope = point_statistics.slope.concat(childData.point_statistics.slope); - current.mergeWith(child.statistics); + statistics.mergeWith(childData.statistics); } - return { points, statistics }; + return { points, point_statistics, statistics }; } } @@ -149,18 +135,13 @@ export class GPXFiles extends GPXTreeNode { constructor(files: GPXFile[]) { super(); this.files = files; - - this.statistics = new GPXStatistics(); - for (let file of files) { - this.statistics.mergeWith(file.statistics); - } } getChildren(): GPXFile[] { return this.files; } - toGeoJSON(): any { + toGeoJSON(): GeoJSON.FeatureCollection[] { return this.getChildren().map((child) => child.toGeoJSON()); } } @@ -178,8 +159,6 @@ export class GPXFile extends GPXTreeNode{ this.metadata = cloneJSON(gpx.metadata); this.wpt = gpx.wpt ? gpx.wpt.map((waypoint) => new Waypoint(waypoint)) : []; this.trk = gpx.trk ? gpx.trk.map((track) => new Track(track)) : []; - - this.computeStatistics(); } getChildren(): Track[] { @@ -190,7 +169,7 @@ export class GPXFile extends GPXTreeNode{ return new GPXFile(structuredClone(this)); } - toGeoJSON(): any { + toGeoJSON(): GeoJSON.FeatureCollection { return { type: "FeatureCollection", features: this.getChildren().flatMap((child) => child.toGeoJSON()) @@ -234,7 +213,7 @@ export class Track extends GPXTreeNode { return this.trkseg; } - toGeoJSON(): any { + toGeoJSON(): GeoJSON.Feature[] { return this.getChildren().map((child) => { let geoJSON = child.toGeoJSON(); if (this.extensions && this.extensions['gpx_style:line']) { @@ -274,13 +253,15 @@ export class Track extends GPXTreeNode { export class TrackSegment extends GPXTreeLeaf { trkpt: TrackPoint[]; trkptStatistics: TrackPointStatistics; + statistics: GPXStatistics; constructor(segment: TrackSegmentType | TrackSegment) { super(); this.trkpt = segment.trkpt.map((point) => new TrackPoint(point)); + this._computeStatistics(); } - computeStatistics(): GPXStatistics { + _computeStatistics(): void { let statistics = new GPXStatistics(); let trkptStatistics: TrackPointStatistics = { distance: [], @@ -294,8 +275,8 @@ export class TrackSegment extends GPXTreeLeaf { slope: [], }; - trkptStatistics.elevation.smoothed = this.computeSmoothedElevation(); - trkptStatistics.slope = this.computeSlope(); + trkptStatistics.elevation.smoothed = this._computeSmoothedElevation(); + trkptStatistics.slope = this._computeSlope(); const points = this.trkpt; for (let i = 0; i < points.length; i++) { @@ -357,14 +338,9 @@ export class TrackSegment extends GPXTreeLeaf { this.statistics = statistics; this.trkptStatistics = trkptStatistics; - - return statistics; } - // Do nothing, recompute statistics after modifying the segment only - refreshStatistics(): void { } - - computeSmoothedElevation(): number[] { + _computeSmoothedElevation(): number[] { const points = this.trkpt; let smoothed = distanceWindowSmoothing(points, 100, (index) => points[index].ele, (accumulated, start, end) => accumulated / (end - start + 1)); @@ -377,7 +353,7 @@ export class TrackSegment extends GPXTreeLeaf { return smoothed; } - computeSlope(): number[] { + _computeSlope(): number[] { const points = this.trkpt; return distanceWindowSmoothingWithDistanceAccumulator(points, 50, (accumulated, start, end) => 100 * (points[end].ele - points[start].ele) / accumulated); @@ -385,7 +361,7 @@ export class TrackSegment extends GPXTreeLeaf { append(points: TrackPoint[]): void { this.trkpt = this.trkpt.concat(points); - this.computeStatistics(); + this._computeStatistics(); } reverse(originalNextTimestamp: Date | undefined, newPreviousTimestamp: Date | undefined): void { @@ -405,7 +381,7 @@ export class TrackSegment extends GPXTreeLeaf { } else { this.trkpt.reverse(); } - this.computeStatistics(); + this._computeStatistics(); } getStartTimestamp(): Date { @@ -420,14 +396,19 @@ export class TrackSegment extends GPXTreeLeaf { return this.trkpt; } - getTrackPointsAndStatistics(): { points: TrackPoint[], statistics: TrackPointStatistics } { + getStatistics(): GPXStatistics { + return this.statistics; + } + + getTrackPointsAndStatistics(): { points: TrackPoint[], point_statistics: TrackPointStatistics, statistics: GPXStatistics } { return { points: this.trkpt, - statistics: this.trkptStatistics + point_statistics: this.trkptStatistics, + statistics: this.statistics, }; } - toGeoJSON(): any { + toGeoJSON(): GeoJSON.Feature { return { type: "Feature", geometry: { @@ -454,6 +435,7 @@ export class TrackPoint { ele?: number; time?: Date; extensions?: TrackPointExtensions; + _data: {} = {}; constructor(point: TrackPointType | TrackPoint) { this.attributes = cloneJSON(point.attributes); diff --git a/website/src/lib/components/App.svelte b/website/src/lib/components/App.svelte index 05a61467..027fe587 100644 --- a/website/src/lib/components/App.svelte +++ b/website/src/lib/components/App.svelte @@ -1,5 +1,5 @@ - - diff --git a/website/src/lib/components/GPXMapLayers.svelte b/website/src/lib/components/GPXMapLayers.svelte deleted file mode 100644 index 20b283b2..00000000 --- a/website/src/lib/components/GPXMapLayers.svelte +++ /dev/null @@ -1,9 +0,0 @@ - - -{#each $files as file} - -{/each} diff --git a/website/src/lib/components/gpx-layer/GPXMapLayers.svelte b/website/src/lib/components/gpx-layer/GPXMapLayers.svelte new file mode 100644 index 00000000..943ae59a --- /dev/null +++ b/website/src/lib/components/gpx-layer/GPXMapLayers.svelte @@ -0,0 +1,29 @@ + diff --git a/website/src/lib/components/gpx-layer/GPXMapLayers.ts b/website/src/lib/components/gpx-layer/GPXMapLayers.ts new file mode 100644 index 00000000..08c37ed1 --- /dev/null +++ b/website/src/lib/components/gpx-layer/GPXMapLayers.ts @@ -0,0 +1,158 @@ +import type { GPXFile } from "gpx"; +import { map, selectFiles } from "$lib/stores"; +import { get, type Writable } from "svelte/store"; +import type mapboxgl from "mapbox-gl"; + +let id = 0; +function getLayerId() { + return `gpx-${id++}`; +} + +let defaultWeight = 6; +let defaultOpacity = 1; + +const colors = [ + '#ff0000', + '#0000ff', + '#46e646', + '#00ccff', + '#ff9900', + '#ff00ff', + '#ffff32', + '#288228', + '#9933ff', + '#50f0be', + '#8c645a' +]; + +const colorCount: { [key: string]: number } = {}; +for (let color of colors) { + colorCount[color] = 0; +} + +// Get the color with the least amount of uses +function getColor() { + let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b)); + colorCount[color]++; + return color; +} + +function decrementColor(color: string) { + colorCount[color]--; +} + +export class GPXMapLayer { + map: mapboxgl.Map; + file: Writable; + layerId: string; + layerColor: string; + unsubscribe: () => void; + + constructor(map: mapboxgl.Map, file: Writable) { + this.map = map; + this.file = file; + this.layerId = getLayerId(); + this.layerColor = getColor(); + this.unsubscribe = file.subscribe(this.updateData.bind(this)); + + get(this.file)._data = { + layerId: this.layerId, + layerColor: this.layerColor + }; + + this.add(); + this.map.on('style.load', this.add.bind(this)); + } + + add() { + if (!this.map.getSource(this.layerId)) { + let data = this.getGeoJSON(); + + this.map.addSource(this.layerId, { + type: 'geojson', + data + }); + } + + if (!this.map.getLayer(this.layerId)) { + this.map.addLayer({ + id: this.layerId, + type: 'line', + source: this.layerId, + layout: { + 'line-join': 'round', + 'line-cap': 'round' + }, + paint: { + 'line-color': ['get', 'color'], + 'line-width': ['get', 'weight'], + 'line-opacity': ['get', 'opacity'] + } + }); + + this.map.on('click', this.layerId, this.selectOnClick.bind(this)); + this.map.on('mouseenter', this.layerId, toPointerCursor); + this.map.on('mouseleave', this.layerId, toDefaultCursor); + } + } + + updateData() { + let source = this.map.getSource(this.layerId); + if (source) { + source.setData(this.getGeoJSON()); + } + } + + remove() { + this.map.off('click', this.layerId, this.selectOnClick.bind(this)); + this.map.off('mouseenter', this.layerId, toPointerCursor); + this.map.off('mouseleave', this.layerId, toDefaultCursor); + this.map.off('style.load', this.add.bind(this)); + + this.map.removeLayer(this.layerId); + this.map.removeSource(this.layerId); + + this.unsubscribe(); + + decrementColor(this.layerColor); + } + + moveToFront() { + this.map.moveLayer(this.layerId); + } + + selectOnClick(e: any) { + if (e.originalEvent.shiftKey) { + get(selectFiles).addSelect(get(this.file)); + } else { + get(selectFiles).select(get(this.file)); + } + } + + getGeoJSON(): GeoJSON.FeatureCollection { + let data = get(this.file).toGeoJSON(); + for (let feature of data.features) { + if (!feature.properties) { + feature.properties = {}; + } + if (!feature.properties.color) { + feature.properties.color = this.layerColor; + } + if (!feature.properties.weight) { + feature.properties.weight = defaultWeight; + } + if (!feature.properties.opacity) { + feature.properties.opacity = defaultOpacity; + } + } + return data; + } +} + +function toPointerCursor() { + get(map).getCanvas().style.cursor = 'pointer'; +} + +function toDefaultCursor() { + get(map).getCanvas().style.cursor = ''; +} diff --git a/website/src/lib/components/toolbar/routing/Routing.svelte b/website/src/lib/components/toolbar/routing/Routing.svelte index e5e623aa..3627d21d 100644 --- a/website/src/lib/components/toolbar/routing/Routing.svelte +++ b/website/src/lib/components/toolbar/routing/Routing.svelte @@ -106,7 +106,7 @@ $map.off('move', toggleMarkersForZoomLevelAndBounds); $map.off('click', extendFile); if (file) { - $map.off('mouseover', file.layerId, showInsertableMarker); + $map.off('mouseover', file._data.layerId, showInsertableMarker); } if (insertableMarker) { insertableMarker.remove(); @@ -115,7 +115,7 @@ kdbush = null; } - $: if ($selectedFiles.size == 1 && $map) { + $: if ($selectedFiles.size == 1) { let selectedFile = $selectedFiles.values().next().value; if (selectedFile !== file) { @@ -124,6 +124,9 @@ } else { // update markers } + } else { + clean(); + file = null; } $: if ($map && file) { @@ -140,7 +143,7 @@ $map.on('zoom', toggleMarkersForZoomLevelAndBounds); $map.on('move', toggleMarkersForZoomLevelAndBounds); $map.on('click', extendFile); - $map.on('mouseover', file.layerId, showInsertableMarker); + $map.on('mouseover', file._data.layerId, showInsertableMarker); let points = file.getTrackPoints(); @@ -152,8 +155,6 @@ kdbush.finish(); end = performance.now(); console.log('Time to create kdbush: ' + (end - start) + 'ms'); - } else { - clean(); } onDestroy(() => { diff --git a/website/src/lib/stores.ts b/website/src/lib/stores.ts index 8faf692f..c400ca0e 100644 --- a/website/src/lib/stores.ts +++ b/website/src/lib/stores.ts @@ -68,10 +68,32 @@ export function triggerFileInput() { } export async function loadFiles(list: FileList) { + let bounds = new mapboxgl.LngLatBounds(); + let mapBounds = new mapboxgl.LngLatBounds([180, 90, -180, -90]); + if (get(files).length > 0) { + mapBounds = get(map)?.getBounds() ?? mapBounds; + bounds.extend(mapBounds); + } for (let i = 0; i < list.length; i++) { let file = await loadFile(list[i]); - if (i == 0 && file) { - get(selectFiles).select(get(file)); + if (file) { + if (i == 0) { + get(selectFiles).select(get(file)); + } + + let fileBounds = get(file).getStatistics().bounds; + bounds.extend(fileBounds.southWest); + bounds.extend(fileBounds.northEast); + bounds.extend([fileBounds.southWest.lon, fileBounds.northEast.lat]); + bounds.extend([fileBounds.northEast.lon, fileBounds.southWest.lat]); + + if (!mapBounds.contains(bounds.getSouthWest()) || !mapBounds.contains(bounds.getNorthEast()) || !mapBounds.contains(bounds.getSouthEast()) || !mapBounds.contains(bounds.getNorthWest())) { + get(map)?.fitBounds(bounds, { + padding: 80, + linear: true, + easing: () => 1 + }); + } } } }