add elevation tool

This commit is contained in:
vcoppe
2024-09-04 19:11:56 +02:00
parent 9ba07ce1ed
commit 8985623639
12 changed files with 1390 additions and 88 deletions

View File

@@ -372,14 +372,14 @@ export class GPXFile extends GPXTreeNode<Track>{
}); });
} }
addElevation(callback: (Coordinates) => number, trackIndices?: number[], segmentIndices?: number[], waypointIndices?: number[]) { addElevation(elevations: number[], trackIndices?: number[], segmentIndices?: number[], waypointIndices?: number[]) {
let og = getOriginal(this); // Read as much as possible from the original object because it is faster let index = 0;
this.trk.forEach((track, trackIndex) => { this.trk.forEach((track, trackIndex) => {
if (trackIndices === undefined || trackIndices.includes(trackIndex)) { if (trackIndices === undefined || trackIndices.includes(trackIndex)) {
track.trkseg.forEach((segment, segmentIndex) => { track.trkseg.forEach((segment, segmentIndex) => {
if (segmentIndices === undefined || segmentIndices.includes(segmentIndex)) { if (segmentIndices === undefined || segmentIndices.includes(segmentIndex)) {
segment.trkpt.forEach((point, pointIndex) => { segment.trkpt.forEach((point) => {
point.ele = callback(og.trk[trackIndex].trkseg[segmentIndex].trkpt[pointIndex].attributes); point.ele = elevations[index++];
}); });
} }
}); });
@@ -387,7 +387,7 @@ export class GPXFile extends GPXTreeNode<Track>{
}); });
this.wpt.forEach((waypoint, waypointIndex) => { this.wpt.forEach((waypoint, waypointIndex) => {
if (waypointIndices === undefined || waypointIndices.includes(waypointIndex)) { if (waypointIndices === undefined || waypointIndices.includes(waypointIndex)) {
waypoint.ele = callback(og.wpt[waypointIndex].attributes); waypoint.ele = elevations[index++];
} }
}); });
} }

1260
website/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,8 +21,10 @@
"@types/eslint": "^8.56.10", "@types/eslint": "^8.56.10",
"@types/events": "^3.0.3", "@types/events": "^3.0.3",
"@types/mapbox__mapbox-gl-geocoder": "^5.0.0", "@types/mapbox__mapbox-gl-geocoder": "^5.0.0",
"@types/mapbox__tilebelt": "^1.0.4",
"@types/mapbox-gl": "^3.1.0", "@types/mapbox-gl": "^3.1.0",
"@types/node": "^20.14.6", "@types/node": "^20.14.6",
"@types/png.js": "^0.2.3",
"@types/sanitize-html": "^2.11.0", "@types/sanitize-html": "^2.11.0",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/eslint-plugin": "^7.13.1",
@@ -43,13 +45,15 @@
"tslib": "^2.6.3", "tslib": "^2.6.3",
"tsx": "^4.15.7", "tsx": "^4.15.7",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.3.1" "vite": "^5.3.1",
"vite-plugin-node-polyfills": "^0.22.0"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@internationalized/date": "^3.5.4", "@internationalized/date": "^3.5.4",
"@mapbox/mapbox-gl-geocoder": "^5.0.2", "@mapbox/mapbox-gl-geocoder": "^5.0.2",
"@mapbox/sphericalmercator": "^1.2.0", "@mapbox/sphericalmercator": "^1.2.0",
"@mapbox/tilebelt": "^1.0.2",
"@types/mapbox__sphericalmercator": "^1.2.3", "@types/mapbox__sphericalmercator": "^1.2.3",
"bits-ui": "^0.21.12", "bits-ui": "^0.21.12",
"chart.js": "^4.4.3", "chart.js": "^4.4.3",
@@ -63,6 +67,7 @@
"mapbox-gl": "^3.4.0", "mapbox-gl": "^3.4.0",
"mapillary-js": "^4.1.2", "mapillary-js": "^4.1.2",
"mode-watcher": "^0.3.1", "mode-watcher": "^0.3.1",
"png.js": "^0.2.1",
"sanitize-html": "^2.13.0", "sanitize-html": "^2.13.0",
"sortablejs": "^1.15.2", "sortablejs": "^1.15.2",
"svelte-i18n": "^4.0.0", "svelte-i18n": "^4.0.0",

View File

@@ -1,11 +1,11 @@
import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer } from "lucide-svelte"; import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer, MountainSnow } from "lucide-svelte";
import type { ComponentType } from "svelte"; import type { ComponentType } from "svelte";
export const guides: Record<string, string[]> = { export const guides: Record<string, string[]> = {
'getting-started': [], 'getting-started': [],
menu: ['file', 'edit', 'view', 'settings'], menu: ['file', 'edit', 'view', 'settings'],
'files-and-stats': [], 'files-and-stats': [],
toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'minify', 'clean'], toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'elevation', 'minify', 'clean'],
'map-controls': [], 'map-controls': [],
'gpx': [], 'gpx': [],
'integration': [], 'integration': [],
@@ -27,6 +27,7 @@ export const guideIcons: Record<string, string | ComponentType<Icon>> = {
"time": CalendarClock, "time": CalendarClock,
"merge": Group, "merge": Group,
"extract": Ungroup, "extract": Ungroup,
"elevation": MountainSnow,
"minify": Filter, "minify": Filter,
"clean": SquareDashedMousePointer, "clean": SquareDashedMousePointer,
"map-controls": "🗺", "map-controls": "🗺",

View File

@@ -261,14 +261,16 @@ export class GPXLayer {
marker.on('dragend', (e) => { marker.on('dragend', (e) => {
resetCursor(); resetCursor();
marker.getElement().style.cursor = ''; marker.getElement().style.cursor = '';
dbUtils.applyToFile(this.fileId, (file) => { getElevation([marker._waypoint]).then((ele) => {
let latLng = marker.getLngLat(); dbUtils.applyToFile(this.fileId, (file) => {
let wpt = file.wpt[marker._waypoint._data.index]; let latLng = marker.getLngLat();
wpt.setCoordinates({ let wpt = file.wpt[marker._waypoint._data.index];
lat: latLng.lat, wpt.setCoordinates({
lon: latLng.lng lat: latLng.lat,
lon: latLng.lng
});
wpt.ele = ele[0];
}); });
wpt.ele = getElevation(this.map, wpt.getCoordinates());
}); });
dragEndTimestamp = Date.now() dragEndTimestamp = Date.now()
}); });

View File

@@ -125,13 +125,11 @@ function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
} }
})); }));
let m = get(map); return getElevation(route).then((elevations) => {
route.forEach((point) => { route.forEach((point, i) => {
point.setSurface("unknown"); point.setSurface("unknown");
if (m) { point.ele = elevations[i];
point.ele = getElevation(m, point.getCoordinates()); });
} return route;
}); });
return new Promise((resolve) => resolve(route));
} }

View File

@@ -10,7 +10,7 @@ import { dbUtils, type GPXFileWithStatistics } from "$lib/db";
import { getOrderedSelection, selection } from "$lib/components/file-list/Selection"; import { getOrderedSelection, selection } from "$lib/components/file-list/Selection";
import { ListFileItem, ListTrackItem, ListTrackSegmentItem } from "$lib/components/file-list/FileList"; import { ListFileItem, ListTrackItem, ListTrackSegmentItem } from "$lib/components/file-list/FileList";
import { currentTool, streetViewEnabled, Tool } from "$lib/stores"; import { currentTool, streetViewEnabled, Tool } from "$lib/stores";
import { getClosestLinePoint, getElevation, resetCursor, setGrabbingCursor } from "$lib/utils"; import { getClosestLinePoint, resetCursor, setGrabbingCursor } from "$lib/utils";
export const canChangeStart = writable(false); export const canChangeStart = writable(false);

View File

@@ -8,7 +8,7 @@ import { applyToOrderedItemsFromFile, applyToOrderedSelectedItemsFromFile, selec
import { ListFileItem, ListItem, ListTrackItem, ListLevel, ListTrackSegmentItem, ListWaypointItem, ListRootItem } from '$lib/components/file-list/FileList'; import { ListFileItem, ListItem, ListTrackItem, ListLevel, ListTrackSegmentItem, ListWaypointItem, ListRootItem } from '$lib/components/file-list/FileList';
import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simplify'; import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simplify';
import { SplitType } from '$lib/components/toolbar/tools/scissors/Scissors.svelte'; import { SplitType } from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import { getClosestLinePoint, getElevation, getPreciseElevations } from '$lib/utils'; import { getClosestLinePoint, getElevation } from '$lib/utils';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import type mapboxgl from 'mapbox-gl'; import type mapboxgl from 'mapbox-gl';
@@ -911,29 +911,30 @@ export const dbUtils = {
if (m === null) { if (m === null) {
return; return;
} }
let ele = getElevation(m, waypoint.attributes); getElevation([waypoint.attributes]).then((elevation) => {
if (item) { if (item) {
dbUtils.applyToFile(item.getFileId(), (file) => { dbUtils.applyToFile(item.getFileId(), (file) => {
let wpt = file.wpt[item.getWaypointIndex()]; let wpt = file.wpt[item.getWaypointIndex()];
wpt.name = waypoint.name; wpt.name = waypoint.name;
wpt.desc = waypoint.desc; wpt.desc = waypoint.desc;
wpt.cmt = waypoint.cmt; wpt.cmt = waypoint.cmt;
wpt.sym = waypoint.sym; wpt.sym = waypoint.sym;
wpt.link = waypoint.link; wpt.link = waypoint.link;
wpt.setCoordinates(waypoint.attributes); wpt.setCoordinates(waypoint.attributes);
wpt.ele = ele; wpt.ele = elevation[0];
}); });
} else { } else {
let fileIds = new Set<string>(); let fileIds = new Set<string>();
get(selection).getSelected().forEach((item) => { get(selection).getSelected().forEach((item) => {
fileIds.add(item.getFileId()); fileIds.add(item.getFileId());
}); });
let wpt = new Waypoint(waypoint); let wpt = new Waypoint(waypoint);
wpt.ele = ele; wpt.ele = elevation[0];
dbUtils.applyToFiles(Array.from(fileIds), (file) => dbUtils.applyToFiles(Array.from(fileIds), (file) =>
file.replaceWaypoints(file.wpt.length, file.wpt.length, [wpt]) file.replaceWaypoints(file.wpt.length, file.wpt.length, [wpt])
); );
} }
});
}, },
setStyleToSelection: (style: LineStyleExtension) => { setStyleToSelection: (style: LineStyleExtension) => {
if (get(selection).size === 0) { if (get(selection).size === 0) {
@@ -1052,27 +1053,25 @@ export const dbUtils = {
} }
}); });
getPreciseElevations(map, points).then((elevations) => { getElevation(points).then((elevations) => {
let callback = (coordinates: Coordinates) => elevations.get(`${coordinates.lat},${coordinates.lon}`) ?? 0;
applyGlobal((draft) => { applyGlobal((draft) => {
applyToOrderedSelectedItemsFromFile((fileId, level, items) => { applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = draft.get(fileId); let file = draft.get(fileId);
if (file) { if (file) {
if (level === ListLevel.FILE) { if (level === ListLevel.FILE) {
file.addElevation(callback); file.addElevation(elevations);
} else if (level === ListLevel.TRACK) { } else if (level === ListLevel.TRACK) {
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex()); let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
file.addElevation(callback, trackIndices, undefined, []); file.addElevation(elevations, trackIndices, undefined, []);
} else if (level === ListLevel.SEGMENT) { } else if (level === ListLevel.SEGMENT) {
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()]; let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex()); let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
file.addElevation(callback, trackIndices, segmentIndices, []); file.addElevation(elevations, trackIndices, segmentIndices, []);
} else if (level === ListLevel.WAYPOINTS) { } else if (level === ListLevel.WAYPOINTS) {
file.addElevation(callback, [], [], undefined); file.addElevation(elevations, [], [], undefined);
} else if (level === ListLevel.WAYPOINT) { } else if (level === ListLevel.WAYPOINT) {
let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex()); let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex());
file.addElevation(callback, [], [], waypointIndices); file.addElevation(elevations, [], [], waypointIndices);
} }
} }
}); });

View File

@@ -31,9 +31,4 @@ Someone more experienced with OpenStreetMap will then review your note and make
More information on how to contribute to OpenStreetMap can be found <a href="https://wiki.openstreetmap.org/wiki/How_to_contribute" target="_blank">here</a>. More information on how to contribute to OpenStreetMap can be found <a href="https://wiki.openstreetmap.org/wiki/How_to_contribute" target="_blank">here</a>.
</DocsNote> </DocsNote>
### Why is the elevation profile for my GPX file empty?
If the elevation profile for your GPX file is empty, it means that the GPX file does not contain elevation data.
You can add elevation data to your GPX file by using <a href="https://www.gpsvisualizer.com/elevation" target="_blank">GPS Visualizer</a>.

View File

@@ -0,0 +1,20 @@
---
title: Elevation
---
<script>
import { MountainSnow } from 'lucide-svelte';
import Elevation from '$lib/components/toolbar/tools/Elevation.svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# <MountainSnow size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
This tool allows you to add elevation data to traces and [points of interest](../gpx), or to replace the existing data.
<div class="flex flex-row justify-center">
<Elevation class="text-foreground p-3 border rounded-md shadow-lg" />
</div>
Elevation data is provided by <a href="https://mapbox.com" target="_blank">Mapbox</a>.
You can learn more about its origin and accuracy in the <a href="https://docs.mapbox.com/data/tilesets/reference/mapbox-terrain-dem-v1/#elevation-data" target="_blank">documentation</a>.

View File

@@ -7,8 +7,11 @@ import { map } from "./stores";
import { base } from "$app/paths"; import { base } from "$app/paths";
import { languages } from "$lib/languages"; import { languages } from "$lib/languages";
import { locale } from "svelte-i18n"; import { locale } from "svelte-i18n";
import type mapboxgl from "mapbox-gl"; import { TrackPoint, Waypoint, type Coordinates, crossarcDistance, distance } from "gpx";
import { type TrackPoint, type Waypoint, type Coordinates, crossarcDistance, distance } from "gpx"; import mapboxgl from "mapbox-gl";
import tilebelt from "@mapbox/tilebelt";
import { PUBLIC_MAPBOX_TOKEN } from "$env/static/public";
import PNGReader from "png.js";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@@ -90,32 +93,52 @@ export function getClosestLinePoint(points: TrackPoint[], point: TrackPoint | Co
return closest; return closest;
} }
export function getElevation(map: mapboxgl.Map, coordinates: Coordinates): number { export function getElevation(points: (TrackPoint | Waypoint | Coordinates)[], ELEVATION_ZOOM: number = 13, tileSize = 512): Promise<number[]> {
let elevation = map.queryTerrainElevation(coordinates, { exaggerated: false }); let coordinates = points.map((point) => (point instanceof TrackPoint || point instanceof Waypoint) ? point.getCoordinates() : point);
return elevation === null ? 0 : elevation; let bbox = new mapboxgl.LngLatBounds();
} coordinates.forEach((coord) => bbox.extend(coord));
export async function getPreciseElevation(map: mapboxgl.Map, coordinates: Coordinates | mapboxgl.LngLat): Promise<number> { let tiles = coordinates.map((coord) => tilebelt.pointToTile(coord.lon, coord.lat, ELEVATION_ZOOM));
if (!map.getBounds().contains(coordinates) || map.getZoom() < 14) { let uniqueTiles = Array.from(new Set(tiles.map((tile) => tile.join(',')))).map((tile) => tile.split(',').map((x) => parseInt(x)));
map.flyTo({ center: coordinates, zoom: 14 }); let pngs = new Map<string, PNGReader>();
await map.once('idle');
}
let elevation = map.queryTerrainElevation(coordinates, { exaggerated: false });
return elevation === null ? 0 : elevation;
}
export async function getPreciseElevations(map: mapboxgl.Map, points: (TrackPoint | Waypoint)[]): Promise<Map<string, number>> { let promises = uniqueTiles.map((tile) => fetch(`https://api.mapbox.com/v4/mapbox.mapbox-terrain-dem-v1/${ELEVATION_ZOOM}/${tile[0]}/${tile[1]}@2x.pngraw?access_token=${PUBLIC_MAPBOX_TOKEN}`, { cache: 'force-cache' }).then((response) => response.arrayBuffer()).then((buffer) => new Promise((resolve, reject) => {
let elevations = new Map<string, number>(); let png = new PNGReader(new Uint8Array(buffer));
png.parse((err, png) => {
if (err) {
reject(err);
} else {
resolve(png);
}
});
})).then((png) => {
pngs.set(tile.join(','), png);
}));
for (let point of points) { return Promise.all(promises).then(() => coordinates.map((coord, index) => {
let key = `${point.getLatitude()},${point.getLongitude()}`; let tile = tiles[index];
if (elevations.has(key)) { let tf = tilebelt.pointToTileFraction(coord.lon, coord.lat, ELEVATION_ZOOM);
continue; let x = tileSize * (tf[0] - tile[0]);
} let y = tileSize * (tf[1] - tile[1]);
elevations.set(key, await getPreciseElevation(map, point.getCoordinates())); let _x = Math.floor(x);
} let _y = Math.floor(y);
let dx = x - _x;
let dy = y - _y;
return elevations; // Get the four pixels surrounding the point
let png = pngs.get(tile.join(','))!;
const p00 = png.getPixel(_x, _y);
const p01 = png.getPixel(_x, _y + (_y + 1 == tileSize ? 0 : 1));
const p10 = png.getPixel(_x + (_x + 1 == tileSize ? 0 : 1), _y);
const p11 = png.getPixel(_x + (_x + 1 == tileSize ? 0 : 1), _y + (_y + 1 == tileSize ? 0 : 1));
let ele00 = -10000 + ((p00[0] * 256 * 256 + p00[1] * 256 + p00[2]) * 0.1);
let ele01 = -10000 + ((p01[0] * 256 * 256 + p01[1] * 256 + p01[2]) * 0.1);
let ele10 = -10000 + ((p10[0] * 256 * 256 + p10[1] * 256 + p10[2]) * 0.1);
let ele11 = -10000 + ((p11[0] * 256 * 256 + p11[1] * 256 + p11[2]) * 0.1);
return ele00 * (1 - dx) * (1 - dy) + ele01 * (1 - dx) * dy + ele10 * dx * (1 - dy) + ele11 * dx * dy;
}));
} }
let previousCursors: string[] = []; let previousCursors: string[] = [];

View File

@@ -1,7 +1,8 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { enhancedImages } from '@sveltejs/enhanced-img'; import { enhancedImages } from '@sveltejs/enhanced-img';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig({ export default defineConfig({
plugins: [enhancedImages(), sveltekit()] plugins: [nodePolyfills(), enhancedImages(), sveltekit()]
}); });