From 7c2e24bbc42747f2c050c15c1823d80e5449f452 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Tue, 23 Dec 2025 16:49:47 +0100 Subject: [PATCH 1/8] draft support for graphhopper --- gpx/src/gpx.ts | 11 +- .../toolbar/tools/routing/Routing.svelte | 4 +- .../toolbar/tools/routing/routing.ts | 141 +++++++++++++++--- 3 files changed, 131 insertions(+), 25 deletions(-) diff --git a/gpx/src/gpx.ts b/gpx/src/gpx.ts index 94c210603..24051b180 100644 --- a/gpx/src/gpx.ts +++ b/gpx/src/gpx.ts @@ -1375,10 +1375,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 = {}; } @@ -1388,8 +1385,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.ts b/website/src/lib/components/toolbar/tools/routing/routing.ts index 6c8d9df6d..c5ad8f400 100644 --- a/website/src/lib/components/toolbar/tools/routing/routing.ts +++ b/website/src/lib/components/toolbar/tools/routing/routing.ts @@ -6,35 +6,141 @@ 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: 'brouter', profile: 'gravel' }, + 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', +}; +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-a.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 + ? {} + : { + priority: [ + { + if: 'road_access == PRIVATE', + multiply_by: '0.0', + }, + ], + }, + }), + }); + + if (!response.ok) { + throw new Error(`${await response.text()}`); + } + + 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]; 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 { + route[j].setExtension(key, 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()}`); } @@ -52,14 +158,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), }) ); From 9ca46b9d3521d8af36ae5a8990f0943ecade5b9d Mon Sep 17 00:00:00 2001 From: vcoppe Date: Wed, 24 Dec 2025 17:21:26 +0100 Subject: [PATCH 2/8] small fix --- website/src/lib/components/toolbar/tools/routing/routing.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/src/lib/components/toolbar/tools/routing/routing.ts b/website/src/lib/components/toolbar/tools/routing/routing.ts index c5ad8f400..438ddab95 100644 --- a/website/src/lib/components/toolbar/tools/routing/routing.ts +++ b/website/src/lib/components/toolbar/tools/routing/routing.ts @@ -108,7 +108,7 @@ async function getGraphHopperRoute( 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]; j++) { + 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]); @@ -122,8 +122,8 @@ async function getGraphHopperRoute( if (mtbScale) { route[j].setExtension('mtb_scale', mtbScale); } - } else { - route[j].setExtension(key, detail[i][2]); + } else if (key === 'surface' && detail[i][2] !== 'other') { + route[j].setExtension('surface', detail[i][2]); } } } From c91baf7c838c223e96c4a2cb4d50352b69de4c39 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sat, 17 Jan 2026 11:58:47 +0100 Subject: [PATCH 3/8] switch gravel to graphhopper --- website/src/lib/components/toolbar/tools/routing/routing.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/lib/components/toolbar/tools/routing/routing.ts b/website/src/lib/components/toolbar/tools/routing/routing.ts index 438ddab95..9462e3f13 100644 --- a/website/src/lib/components/toolbar/tools/routing/routing.ts +++ b/website/src/lib/components/toolbar/tools/routing/routing.ts @@ -14,7 +14,7 @@ export type RoutingProfile = { export const routingProfiles: { [key: string]: RoutingProfile } = { bike: { engine: 'graphhopper', profile: 'bike' }, racing_bike: { engine: 'graphhopper', profile: 'racingbike' }, - gravel_bike: { engine: 'brouter', profile: 'gravel' }, + gravel_bike: { engine: 'graphhopper', profile: 'gravelbike' }, mountain_bike: { engine: 'graphhopper', profile: 'mtb' }, foot: { engine: 'graphhopper', profile: 'foot' }, motorcycle: { engine: 'graphhopper', profile: 'motorcycle' }, From a01ca79a82bf7ab7c73702e69eadcb9146be9dd7 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sun, 18 Jan 2026 15:23:39 +0100 Subject: [PATCH 4/8] finer-grained road access --- .../toolbar/tools/routing/routing.ts | 60 ++++++++++++++++--- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/website/src/lib/components/toolbar/tools/routing/routing.ts b/website/src/lib/components/toolbar/tools/routing/routing.ts index 9462e3f13..b43210e0a 100644 --- a/website/src/lib/components/toolbar/tools/routing/routing.ts +++ b/website/src/lib/components/toolbar/tools/routing/routing.ts @@ -53,6 +53,57 @@ const mtbRatingToScale: { [key: string]: string } = { '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[], graphHopperProfile: string, @@ -71,14 +122,7 @@ async function getGraphHopperRoute( details: graphhopperDetails, custom_model: privateRoads ? {} - : { - priority: [ - { - if: 'road_access == PRIVATE', - multiply_by: '0.0', - }, - ], - }, + : graphhopperBlockPrivateCustomModels[graphHopperProfile] || {}, }), }); From 089b88c62da000552dd33042b69e8a9945e985f9 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sat, 7 Mar 2026 15:30:22 +0100 Subject: [PATCH 5/8] update graphhopper url --- website/src/lib/components/toolbar/tools/routing/routing.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/lib/components/toolbar/tools/routing/routing.ts b/website/src/lib/components/toolbar/tools/routing/routing.ts index b43210e0a..cf7fe968f 100644 --- a/website/src/lib/components/toolbar/tools/routing/routing.ts +++ b/website/src/lib/components/toolbar/tools/routing/routing.ts @@ -109,7 +109,7 @@ async function getGraphHopperRoute( graphHopperProfile: string, privateRoads: boolean ): Promise { - let response = await fetch('https://graphhopper-a.gpx.studio/route', { + let response = await fetch('https://graphhopper.gpx.studio/route', { method: 'POST', headers: { 'Content-Type': 'application/json', From dd94a7d613f4363ac30a9a99087c56decca46e7a Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sat, 7 Mar 2026 15:57:58 +0100 Subject: [PATCH 6/8] catch graphhopper exceptions --- .../toolbar/tools/routing/routing-controls.ts | 12 +------ .../toolbar/tools/routing/routing.ts | 31 +++++++++++++++++-- website/src/locales/en.json | 2 ++ 3 files changed, 32 insertions(+), 13 deletions(-) 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 cf7fe968f..924330283 100644 --- a/website/src/lib/components/toolbar/tools/routing/routing.ts +++ b/website/src/lib/components/toolbar/tools/routing/routing.ts @@ -127,7 +127,23 @@ async function getGraphHopperRoute( }); if (!response.ok) { - throw new Error(`${await response.text()}`); + const error = await response.json(); + console.log(error); + 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(); @@ -186,7 +202,18 @@ async function getBRouterRoute( let response = await fetch(url); 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(); 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" } }, From 01a7ec916ec6717580fb1618462b4df3cf7ce9e4 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sat, 7 Mar 2026 15:59:08 +0100 Subject: [PATCH 7/8] remove console log --- website/src/lib/components/toolbar/tools/routing/routing.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/lib/components/toolbar/tools/routing/routing.ts b/website/src/lib/components/toolbar/tools/routing/routing.ts index 924330283..df9a4398a 100644 --- a/website/src/lib/components/toolbar/tools/routing/routing.ts +++ b/website/src/lib/components/toolbar/tools/routing/routing.ts @@ -128,7 +128,6 @@ async function getGraphHopperRoute( if (!response.ok) { const error = await response.json(); - console.log(error); if (error.message.includes('Cannot find point 0')) { throw new Error('toolbar.routing.error.from'); } else if (error.message.includes('Cannot find point 1')) { From 5ff11a32c91c70fd75614ef978f2773e2ea3bdc4 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sun, 15 Mar 2026 17:00:03 +0100 Subject: [PATCH 8/8] update readme --- README.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) 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