diff --git a/README.md b/README.md index 04be098d3..bd0960d26 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Any help is greatly appreciated! The code is split into two parts: -- `gpx`: a Typescript library for parsing and manipulating GPX files, -- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application. +- `gpx`: a Typescript library for parsing and manipulating GPX files, +- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application. You will need [Node.js](https://nodejs.org/) to build and run these two parts. @@ -55,25 +55,25 @@ npm run dev This project has been made possible thanks to the following open source projects: -- Development: - - [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience - - [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation -- Design: - - [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components - - [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons - - [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling - - [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts -- Logic: - - [immer](https://github.com/immerjs/immer) — complex state management - - [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper - - [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing - - [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree -- Mapping: - - [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps - - [brouter](https://github.com/abrensch/brouter) — routing engine - - [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter -- Search: - - [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation +- Development: + - [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience + - [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation +- Design: + - [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components + - [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons + - [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling + - [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts +- Logic: + - [immer](https://github.com/immerjs/immer) — complex state management + - [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper + - [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing + - [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree +- Mapping: + - [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps + - [GraphHopper](https://github.com/graphhopper/graphhopper) — routing engine + - [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and GraphHopper +- Search: + - [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation ## License diff --git a/gpx/src/gpx.ts b/gpx/src/gpx.ts index ef82f91e4..b7ab9da6b 100644 --- a/gpx/src/gpx.ts +++ b/gpx/src/gpx.ts @@ -1398,10 +1398,7 @@ export class TrackPoint { : undefined; } - setExtensions(extensions: Record) { - if (Object.keys(extensions).length === 0) { - return; - } + setExtension(key: string, value: string) { if (!this.extensions) { this.extensions = {}; } @@ -1411,8 +1408,12 @@ export class TrackPoint { if (!this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']) { this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] = {}; } + this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value; + } + + setExtensions(extensions: Record) { Object.entries(extensions).forEach(([key, value]) => { - this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value; + this.setExtension(key, value); }); } diff --git a/website/src/lib/components/toolbar/tools/routing/Routing.svelte b/website/src/lib/components/toolbar/tools/routing/Routing.svelte index e59570d5f..41381e74e 100644 --- a/website/src/lib/components/toolbar/tools/routing/Routing.svelte +++ b/website/src/lib/components/toolbar/tools/routing/Routing.svelte @@ -21,7 +21,7 @@ SquareArrowUpLeft, SquareArrowOutDownRight, } from '@lucide/svelte'; - import { brouterProfiles } from '$lib/components/toolbar/tools/routing/routing'; + import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing'; import { i18n } from '$lib/i18n.svelte'; import { slide } from 'svelte/transition'; import { @@ -167,7 +167,7 @@ {i18n._(`toolbar.routing.activities.${$routingProfile}`)} - {#each Object.keys(brouterProfiles) as profile} + {#each Object.keys(routingProfiles) as profile} {i18n._( `toolbar.routing.activities.${profile}` diff --git a/website/src/lib/components/toolbar/tools/routing/routing-controls.ts b/website/src/lib/components/toolbar/tools/routing/routing-controls.ts index 57fb3be89..6db0a4e38 100644 --- a/website/src/lib/components/toolbar/tools/routing/routing-controls.ts +++ b/website/src/lib/components/toolbar/tools/routing/routing-controls.ts @@ -731,17 +731,7 @@ export class RoutingControls { try { response = await route(targetCoordinates); } catch (e: any) { - if (e.message.includes('from-position not mapped in existing datafile')) { - toast.error(i18n._('toolbar.routing.error.from')); - } else if (e.message.includes('via1-position not mapped in existing datafile')) { - toast.error(i18n._('toolbar.routing.error.via')); - } else if (e.message.includes('to-position not mapped in existing datafile')) { - toast.error(i18n._('toolbar.routing.error.to')); - } else if (e.message.includes('Time-out')) { - toast.error(i18n._('toolbar.routing.error.timeout')); - } else { - toast.error(e.message); - } + toast.error(i18n._(e.message, e.message)); return false; } diff --git a/website/src/lib/components/toolbar/tools/routing/routing.ts b/website/src/lib/components/toolbar/tools/routing/routing.ts index 6c8d9df6d..df9a4398a 100644 --- a/website/src/lib/components/toolbar/tools/routing/routing.ts +++ b/website/src/lib/components/toolbar/tools/routing/routing.ts @@ -6,37 +6,213 @@ import { get } from 'svelte/store'; const { routing, routingProfile, privateRoads } = settings; -export const brouterProfiles: { [key: string]: string } = { - bike: 'Trekking-dry', - racing_bike: 'fastbike', - gravel_bike: 'gravel', - mountain_bike: 'MTB', - foot: 'Hiking-Alpine-SAC6', - motorcycle: 'Car-FastEco', - water: 'river', - railway: 'rail', +export type RoutingProfile = { + engine: 'graphhopper' | 'brouter'; + profile: string; +}; + +export const routingProfiles: { [key: string]: RoutingProfile } = { + bike: { engine: 'graphhopper', profile: 'bike' }, + racing_bike: { engine: 'graphhopper', profile: 'racingbike' }, + gravel_bike: { engine: 'graphhopper', profile: 'gravelbike' }, + mountain_bike: { engine: 'graphhopper', profile: 'mtb' }, + foot: { engine: 'graphhopper', profile: 'foot' }, + motorcycle: { engine: 'graphhopper', profile: 'motorcycle' }, + water: { engine: 'brouter', profile: 'river' }, + railway: { engine: 'brouter', profile: 'rail' }, }; export function route(points: Coordinates[]): Promise { if (get(routing)) { - return getRoute(points, brouterProfiles[get(routingProfile)], get(privateRoads)); + const profile = routingProfiles[get(routingProfile)]; + if (profile.engine === 'graphhopper') { + return getGraphHopperRoute(points, profile.profile, get(privateRoads)); + } else { + return getBRouterRoute(points, profile.profile); + } } else { return getIntermediatePoints(points); } } -async function getRoute( +const graphhopperDetails = ['road_class', 'surface', 'hike_rating', 'mtb_rating']; +const hikeRatingToSACScale: { [key: string]: string } = { + '1': 'hiking', + '2': 'mountain_hiking', + '3': 'demanding_mountain_hiking', + '4': 'alpine_hiking', + '5': 'demanding_alpine_hiking', + '6': 'difficult_alpine_hiking', +}; +const mtbRatingToScale: { [key: string]: string } = { + '1': '0', + '2': '1', + '3': '2', + '4': '3', + '5': '4', + '6': '5', + '7': '6', +}; + +const graphhopperBlockPrivateCustomModels: { [key: string]: any } = { + bike: { + priority: [ + { + if: 'bike_road_access == PRIVATE', + multiply_by: '0.0', + }, + ], + }, + racingbike: { + priority: [ + { + if: 'bike_road_access == PRIVATE', + multiply_by: '0.0', + }, + ], + }, + gravelbike: { + priority: [ + { + if: 'bike_road_access == PRIVATE', + multiply_by: '0.0', + }, + ], + }, + mtb: { + priority: [ + { + if: 'bike_road_access == PRIVATE', + multiply_by: '0.0', + }, + ], + }, + foot: { + priority: [ + { + if: 'foot_road_access == PRIVATE', + multiply_by: '0.0', + }, + ], + }, + motorcycle: { + priority: [ + { + if: 'road_access == PRIVATE', + multiply_by: '0.0', + }, + ], + }, +}; +async function getGraphHopperRoute( points: Coordinates[], - brouterProfile: string, + graphHopperProfile: string, privateRoads: boolean ): Promise { - let url = `https://brouter.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('https://graphhopper.gpx.studio/route', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + points: points.map((point) => [point.lon, point.lat]), + profile: graphHopperProfile, + elevation: true, + points_encoded: false, + details: graphhopperDetails, + custom_model: privateRoads + ? {} + : graphhopperBlockPrivateCustomModels[graphHopperProfile] || {}, + }), + }); + + if (!response.ok) { + const error = await response.json(); + if (error.message.includes('Cannot find point 0')) { + throw new Error('toolbar.routing.error.from'); + } else if (error.message.includes('Cannot find point 1')) { + if (points.length == 3) { + throw new Error('toolbar.routing.error.via'); + } else { + throw new Error('toolbar.routing.error.to'); + } + } else if (error.hints[0].details.includes('PointDistanceExceededException')) { + throw new Error('toolbar.routing.error.distance'); + } else if (error.hints[0].details.includes('ConnectionNotFoundException')) { + throw new Error('toolbar.routing.error.connection'); + } else { + throw new Error(error.message); + } + } + + let json = await response.json(); + + let route: TrackPoint[] = []; + let coordinates = json.paths[0].points.coordinates; + let details = json.paths[0].details; + + for (let i = 0; i < coordinates.length; i++) { + route.push( + new TrackPoint({ + attributes: { + lat: coordinates[i][1], + lon: coordinates[i][0], + }, + ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0), + extensions: {}, + }) + ); + } + + for (let key of graphhopperDetails) { + let detail = details[key]; + for (let i = 0; i < detail.length; i++) { + for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1); j++) { + if (detail[i][2] !== undefined && detail[i][2] !== 'missing') { + if (key === 'road_class') { + route[j].setExtension('highway', detail[i][2]); + } else if (key === 'hike_rating') { + const sacScale = hikeRatingToSACScale[detail[i][2]]; + if (sacScale) { + route[j].setExtension('sac_scale', sacScale); + } + } else if (key === 'mtb_rating') { + const mtbScale = mtbRatingToScale[detail[i][2]]; + if (mtbScale) { + route[j].setExtension('mtb_scale', mtbScale); + } + } else if (key === 'surface' && detail[i][2] !== 'other') { + route[j].setExtension('surface', detail[i][2]); + } + } + } + } + } + + return route; +} + +async function getBRouterRoute( + points: Coordinates[], + brouterProfile: string +): Promise { + let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`; let response = await fetch(url); - // Check if the response is ok if (!response.ok) { - throw new Error(`${await response.text()}`); + const error = await response.text(); + if (error.includes('from-position not mapped in existing datafile')) { + throw new Error('toolbar.routing.error.from'); + } else if (error.includes('via1-position not mapped in existing datafile')) { + throw new Error('toolbar.routing.error.via'); + } else if (error.includes('to-position not mapped in existing datafile')) { + throw new Error('toolbar.routing.error.to'); + } else if (error.includes('Time-out')) { + throw new Error('toolbar.routing.error.timeout'); + } else { + throw new Error(error); + } } let geojson = await response.json(); @@ -52,14 +228,13 @@ async function getRoute( let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {}; for (let i = 0; i < coordinates.length; i++) { - let coord = coordinates[i]; route.push( new TrackPoint({ attributes: { - lat: coord[1], - lon: coord[0], + lat: coordinates[i][1], + lon: coordinates[i][0], }, - ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0), + ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0), }) ); diff --git a/website/src/locales/en.json b/website/src/locales/en.json index 4bf445f04..b5fa2853d 100644 --- a/website/src/locales/en.json +++ b/website/src/locales/en.json @@ -190,6 +190,8 @@ "from": "The start point is too far from the nearest road", "via": "The via point is too far from the nearest road", "to": "The end point is too far from the nearest road", + "distance": "The end point is to far from the start point", + "connection": "No connection found between the points", "timeout": "Route calculation took too long, try adding points closer together" } },