Migration to new Rust-based engine (#401)

* wasm first steps

* parsing start

* parsing continued

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* fixes

* reorganize

* change type

* fix

* progress

* refactoring

* progress

* progress

* progress

* keep style info

* progress

* progress

* remove (copy) in name

* progress

* merge methods into 1

* splice

* progress

* progress

* progress

* cleaning

* progress

* progress

* progress

* visibility

* progress

* progress

* progress

* progress

* progress

* fixes

* progress

* fix markers

* fix

* progress

* progress

* progress

* progress

* progress

* progress

* small fix

* use engine info

* fix popups

* small fix

* progress

* progress

* progress

* clean tool

* handle self closing elements

* reduce data exchanges across boundary

* copy on wasm side to avoid any data corruption

* speed fix

* routing

* refactoring

* reverse

* reverse

* crop

* split

* merge

* progress

* time tool

* fix bounds for waypoints

* reduce

* fix reactivity

* handle corrupt time data

* export

* progress

* remove gpx js package

* remove todo

* update readme

* cleaning

* progress

* fix link fields

* remove unused fields

* refactoring and tests

* import and export of routes, ref #192

* per-chunk categories storage

* cross-tab sync

* outdated state

* refactoring

* reorder methods

* refactoring

* fix

* improve flow

* renaming

* progress

* progress

* fix

* progress

* per file history

* keep original flag

* use history variant

* fix

* progress

* file explorer

* progress

* fix

* sticky folders

* improved sticky folders

* fix sortable

* fix sortable

* fix

* fix tab size

* no file delete on desktop

* fix routing on other file

* fix sorting

* remember language

* open links in browser

* commit missing changes

* avoid selecting on resize

* clarify filename

* explorer actions

* fix folder styling

* drop anywhere in folder

* cleaning

* fixes

* remove focus-ring

* full screen

* small fix

* consistent highlight color

* improved preview

* performance improvements

* sparser data transfers

* sparser data transfers

* show popup once data is loaded

* fix routing control size

* fix

* sparse desktop build

* remove custom ign styles

* use new endpoint for openhikingmap

* strip native menu

* reorder context menu items

* route full support

* route - track conversion

* create route file action

* route export, filter out anchors at high zoom level

* clarify name handling

* move hide & center to view

* routes continued

* update docs

* fix power format, closes #378

* lint fixes

* fix display of additional data with gaps, closes #361

* fix linting issues

* fix import modules

* fix multidrag

* change base to resolve

* fix type errors

* fix layer typings

* fix last type error

* each keys

* improve typings

* deploy to cloudflare

---------

Co-authored-by: vcoppe <vcoppe@hexaly.com>
This commit is contained in:
vcoppeandvcoppe authored and GitHub committed 2026-10-31 18:08:22 +01:00
1 parent 6a0d434371
commit 8fe7d8d64d
484 files changed
+53949 -41152

No files matched your search

@@ -1,4 +1,5 @@
<script lang="ts">
import type { MapMouseEvent, Popup } from 'maplibre-gl';
import * as Select from '$lib/components/ui/select';
import { Switch } from '$lib/components/ui/switch';
import { Label } from '$lib/components/ui/label/index.js';
@@ -22,26 +23,18 @@
SquareArrowOutDownRight,
} from '@lucide/svelte';
import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { slide } from 'svelte/transition';
import {
ListFileItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem,
type ListItem,
} from '$lib/components/file-list/file-list';
import { get } from 'svelte/store';
import { getURLForLanguage } from '$lib/utils';
import { onDestroy, onMount } from 'svelte';
import { TrackPoint } from 'gpx';
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/state/settings';
import { map } from '$lib/components/map/map';
import { fileStateCollection, GPXFileStateCollectionObserver } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection';
import { fileActions, getFileIds, newGPXFile } from '$lib/logic/file-actions';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { RoutingControls, routingControls } from './routing-controls';
import type { Popup } from 'maplibre-gl';
import { engine } from '$lib/engine';
import { coversSegments } from '$lib/engine/selection-helpers';
import { newFileName } from '$lib/actions/file-actions';
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
import { RoutingControls } from './routing-controls';
let {
minimized = $bindable(false),
@@ -59,52 +52,22 @@
const { privateRoads, routing, routingProfile } = settings;
let fileStateCollectionObserver: GPXFileStateCollectionObserver;
const selection = engine.selection;
let routingControls: RoutingControls | undefined = undefined;
let validSelection = $derived(
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints'])
);
// the tool works on files, routes, tracks and segments, not on waypoints
let validSelection = $derived(coversSegments($selection));
function createFileWithPoint(e: any) {
if ($selection.size === 0) {
let file = newGPXFile();
file.replaceTrackPoints(0, 0, 0, 0, [
new TrackPoint({
attributes: {
lat: e.lngLat.lat,
lon: e.lngLat.lng,
},
}),
]);
file._data.id = getFileIds(1)[0];
fileActions.add(file);
selection.selectFileWhenLoaded(file._data.id);
function createFileWithPoint(e: MapMouseEvent) {
if ($selection.type === 'empty') {
// the engine selects the new file, which starts with the trackpoint: one undo step
engine.newFile(newFileName(), { lng: e.lngLat.lng, lat: e.lngLat.lat });
}
}
onMount(() => {
if ($map && popup && popupElement) {
fileStateCollectionObserver = new GPXFileStateCollectionObserver(
(newFiles) => {
newFiles.forEach((fileState, fileId) => {
routingControls.set(
fileId,
new RoutingControls(fileId, fileState, popup, popupElement)
);
});
},
(fileId) => {
const controls = routingControls.get(fileId);
if (controls) {
controls.destroy();
routingControls.delete(fileId);
}
},
() => {
routingControls.forEach((controls) => controls.destroy());
routingControls.clear();
}
);
routingControls = new RoutingControls(popup, popupElement);
mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, true);
$map.on('click', createFileWithPoint);
@@ -113,9 +76,8 @@
onDestroy(() => {
if ($map) {
if (fileStateCollectionObserver) {
fileStateCollectionObserver.destroy();
}
routingControls?.destroy();
routingControls = undefined;
mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, false);
$map.off('click', createFileWithPoint);
@@ -168,7 +130,7 @@
{i18n._(`toolbar.routing.activities.${$routingProfile}`)}
</Select.Trigger>
<Select.Content>
{#each Object.keys(routingProfiles) as profile}
{#each Object.keys(routingProfiles) as profile (profile)}
<Select.Item value={profile}
>{i18n._(
`toolbar.routing.activities.${profile}`
@@ -194,7 +156,7 @@
variant="outline"
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
disabled={!validSelection}
onclick={fileActions.reverseSelection}
onclick={() => engine.reverse()}
>
<ArrowRightLeft class="size-3" />{i18n._('toolbar.routing.reverse.button')}
</ButtonWithTooltip>
@@ -204,31 +166,10 @@
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
disabled={!validSelection}
onclick={() => {
const selected = selection.getOrderedSelection();
if (selected.length > 0) {
const firstFileId = selected[0].getFileId();
const firstFile = fileStateCollection.getFile(firstFileId);
if (firstFile) {
let start = (() => {
if (selected[0] instanceof ListFileItem) {
return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
} else if (selected[0] instanceof ListTrackItem) {
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]
?.trkpt[0];
} else if (selected[0] instanceof ListTrackSegmentItem) {
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[
selected[0].getSegmentIndex()
]?.trkpt[0];
}
})();
if (start !== undefined) {
const lastFileId = selected[selected.length - 1].getFileId();
routingControls
.get(lastFileId)
?.appendAnchorWithCoordinates(start.getCoordinates());
}
}
// the first trackpoint of the selection
const { length, lng, lat } = get(engine.statistics);
if (length > 0) {
routingControls?.appendAnchorWithCoordinates({ lng: lng[0], lat: lat[0] });
}
}}
>
@@ -239,7 +180,7 @@
variant="outline"
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
disabled={!validSelection}
onclick={fileActions.createRoundTripForSelection}
onclick={() => engine.roundTrip()}
>
<Repeat class="size-3" />{i18n._('toolbar.routing.round_trip.button')}
</ButtonWithTooltip>
@@ -5,7 +5,7 @@
import { canChangeStart } from './routing-controls';
import { CirclePlay, Trash2 } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
let {
element = $bindable(),
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,282 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { distance } from '$lib/geo/coordinates';
import {
brouterError,
getTags,
graphHopperError,
graphhopperBlockPrivateCustomModels,
interpolatePoints,
parseBRouterRoute,
parseGraphHopperRoute,
routingProfiles,
} from './routing';
describe('routing profiles', () => {
it('block the private roads of every profile of GraphHopper that has a model', () => {
const profiles = Object.values(routingProfiles)
.filter((profile) => profile.engine === 'graphhopper')
.map((profile) => profile.profile);
assert.ok(profiles.length > 0);
for (const profile of profiles) {
assert.ok(graphhopperBlockPrivateCustomModels[profile], profile);
}
// and the models are about profiles that exist
for (const profile of Object.keys(graphhopperBlockPrivateCustomModels)) {
assert.ok(profiles.includes(profile), profile);
}
});
});
describe('GraphHopper', () => {
const response = (details: Record<string, unknown[]>, coordinates: number[][]) => ({
paths: [{ points: { coordinates }, details }],
});
const coordinates = [
[4.0, 50.0, 100],
[4.1, 50.1, 110],
[4.2, 50.2, 120],
[4.3, 50.3, 130],
[4.4, 50.4],
];
it('gives the points of the route, with the elevation of the previous one when missing', () => {
const route = parseGraphHopperRoute(response({}, coordinates));
assert.deepEqual(route.lng, [4.0, 4.1, 4.2, 4.3, 4.4]);
assert.deepEqual(route.lat, [50.0, 50.1, 50.2, 50.3, 50.4]);
assert.deepEqual(route.ele, [100, 110, 120, 130, 130]);
assert.deepEqual(route.surface, new Array(5).fill(undefined));
});
it('gives the details to the points of their ranges, the last range including the last point', () => {
const route = parseGraphHopperRoute(
response(
{
road_class: [
[0, 2, 'cycleway'],
[2, 4, 'path'],
],
surface: [
[0, 3, 'asphalt'],
[3, 4, 'gravel'],
],
hike_rating: [[0, 4, '2']],
mtb_rating: [
[0, 1, '1'],
[1, 4, '5'],
],
},
coordinates
)
);
assert.deepEqual(route.highway, ['cycleway', 'cycleway', 'path', 'path', 'path']);
assert.deepEqual(route.surface, ['asphalt', 'asphalt', 'asphalt', 'gravel', 'gravel']);
// the ratings are converted to the scales
assert.deepEqual(route.sacScale, new Array(5).fill('mountain_hiking'));
assert.deepEqual(route.mtbScale, ['0', '4', '4', '4', '4']);
});
it('ignores the details that are missing or unknown', () => {
const route = parseGraphHopperRoute(
response(
{
road_class: [
[0, 2, 'missing'],
[2, 4, 'primary'],
],
surface: [[0, 4, 'other']],
hike_rating: [[0, 4, '9']],
},
coordinates
)
);
assert.deepEqual(route.highway, [undefined, undefined, 'primary', 'primary', 'primary']);
assert.deepEqual(route.surface, new Array(5).fill(undefined));
assert.deepEqual(route.sacScale, new Array(5).fill(undefined));
});
it('turns its errors into the messages of the interface', () => {
const error = (message: string, details = '', count = 2) =>
graphHopperError({ message, hints: [{ details }] }, count).message;
assert.equal(error('Cannot find point 0: 1,2'), 'toolbar.routing.error.from');
assert.equal(error('Cannot find point 1: 1,2', '', 2), 'toolbar.routing.error.to');
assert.equal(error('Cannot find point 1: 1,2', '', 3), 'toolbar.routing.error.via');
assert.equal(
error('x', 'com.graphhopper.PointDistanceExceededException'),
'toolbar.routing.error.distance'
);
assert.equal(
error('x', 'ConnectionNotFoundException: no way'),
'toolbar.routing.error.connection'
);
assert.equal(error('something else'), 'something else');
// an error without hints is still an error
assert.equal(graphHopperError({ message: 'no hints' }, 2).message, 'no hints');
assert.equal(graphHopperError({}, 2).message, '');
});
});
describe('BRouter', () => {
const messages = [
[
'Longitude',
'Latitude',
'Elevation',
'Distance',
'CostPerKm',
'ElevCost',
'TurnCost',
'NodeCost',
'InitialCost',
'WayTags',
'NodeTags',
],
[
'4100000',
'50100000',
'100',
'10',
'0',
'0',
'0',
'0',
'0',
'highway=path surface=gravel sac_scale=hiking',
'',
],
[
'4300000',
'50300000',
'120',
'10',
'0',
'0',
'0',
'0',
'0',
'highway=residential surface=asphalt mtb:scale=1',
'',
],
];
const geojson = (coordinates: number[][], messages_: string[][] = messages) => ({
features: [{ geometry: { coordinates }, properties: { messages: messages_ } }],
});
it('reads the tags of a way', () => {
assert.deepEqual(getTags('highway=path surface=gravel'), {
highway: 'path',
surface: 'gravel',
});
assert.deepEqual(getTags('sac_scale=hiking mtb:scale=2'), {
sac_scale: 'hiking',
mtb_scale: '2',
});
assert.deepEqual(getTags('a:b:c=d'), { a_b_c: 'd' });
assert.deepEqual(getTags(''), { '': undefined });
});
it('gives each point the tags of its way', () => {
const route = parseBRouterRoute(
geojson([
[4.0, 50.0, 90],
[4.1, 50.1, 100],
[4.2, 50.2, 110],
[4.3, 50.3, 120],
[4.4, 50.4, 130],
])
);
assert.deepEqual(route.ele, [90, 100, 110, 120, 130]);
// the first way is the one of the first message, whose last point is the start of the next
assert.deepEqual(route.highway, [
'path',
'residential',
'residential',
undefined,
undefined,
]);
assert.deepEqual(route.surface, ['gravel', 'asphalt', 'asphalt', undefined, undefined]);
assert.deepEqual(route.sacScale, ['hiking', undefined, undefined, undefined, undefined]);
assert.deepEqual(route.mtbScale, [undefined, '1', '1', undefined, undefined]);
});
it('has no tags for a route without messages', () => {
const route = parseBRouterRoute(
geojson(
[
[4, 50, 1],
[4.1, 50.1, 2],
],
[messages[0]]
)
);
assert.deepEqual(route.lng, [4, 4.1]);
assert.deepEqual(route.highway, [undefined, undefined]);
});
it('turns its errors into the messages of the interface', () => {
assert.equal(
brouterError('from-position not mapped in existing datafile').message,
'toolbar.routing.error.from'
);
assert.equal(
brouterError('via1-position not mapped in existing datafile').message,
'toolbar.routing.error.via'
);
assert.equal(
brouterError('to-position not mapped in existing datafile').message,
'toolbar.routing.error.to'
);
assert.equal(
brouterError('operation killed by Time-out').message,
'toolbar.routing.error.timeout'
);
assert.equal(brouterError('other').message, 'other');
});
});
describe('straight routes', () => {
it('have a point every 50 m and the last point', () => {
const a = { lng: 4, lat: 50 };
const b = { lng: 4.001, lat: 50 };
const route = interpolatePoints([a, b]);
const length = distance(a, b);
assert.equal(route.lng.length, Math.ceil(length / 50) + 1);
assert.deepEqual([route.lng[0], route.lat[0]], [4, 50]);
assert.deepEqual([route.lng.at(-1), route.lat.at(-1)], [4.001, 50]);
// the points are in order, and evenly spaced
for (let i = 1; i < route.lng.length - 1; i++) {
const step = distance(
{ lng: route.lng[i - 1], lat: route.lat[i - 1] },
{ lng: route.lng[i], lat: route.lat[i] }
);
assert.ok(Math.abs(step - 50) < 1, `${step}`);
}
assert.ok(route.ele.every((ele) => ele === 0));
assert.equal(route.surface.length, route.lng.length);
});
it('go through the intermediate points', () => {
const route = interpolatePoints([
{ lng: 4, lat: 50 },
{ lng: 4.001, lat: 50 },
{ lng: 4.001, lat: 50.001 },
]);
assert.ok(route.lng.includes(4.001) && route.lat.includes(50.001));
const corner = route.lng.findIndex((lng, i) => lng === 4.001 && route.lat[i] === 50);
assert.ok(corner > 0);
});
it('are a point for one point, nothing for none', () => {
const route = interpolatePoints([{ lng: 4, lat: 50 }]);
assert.deepEqual(route.lng, [4]);
assert.deepEqual(interpolatePoints([]).lng, []);
// two points at the same place
assert.deepEqual(
interpolatePoints([
{ lng: 4, lat: 50 },
{ lng: 4, lat: 50 },
]).lng,
[4]
);
});
});
@@ -1,6 +1,5 @@
import type { Coordinates } from 'gpx';
import { TrackPoint, distance } from 'gpx';
import { settings } from '$lib/logic/settings';
import { distance, type Coordinates } from '$lib/geo/coordinates';
import { settings } from '$lib/state/settings';
import { getElevation } from '$lib/utils';
import { get } from 'svelte/store';
@@ -22,7 +21,36 @@ export const routingProfiles: { [key: string]: RoutingProfile } = {
railway: { engine: 'brouter', profile: 'rail' },
};
export function route(points: Coordinates[]): Promise<TrackPoint[]> {
/**
* The trackpoints of a route, as arrays with an entry per trackpoint. The OSM attributes are
* `undefined` where the router does not know them.
*/
export type RoutedPoints = {
lng: number[];
lat: number[];
ele: number[];
surface: (string | undefined)[];
highway: (string | undefined)[];
sacScale: (string | undefined)[];
mtbScale: (string | undefined)[];
};
export function emptyRoute(): RoutedPoints {
return { lng: [], lat: [], ele: [], surface: [], highway: [], sacScale: [], mtbScale: [] };
}
/** Adds a trackpoint, which has the elevation of the previous one if it has none. */
export function addPoint(route: RoutedPoints, lng: number, lat: number, ele: number | undefined) {
route.lng.push(lng);
route.lat.push(lat);
route.ele.push(ele ?? route.ele[route.ele.length - 1] ?? 0);
route.surface.push(undefined);
route.highway.push(undefined);
route.sacScale.push(undefined);
route.mtbScale.push(undefined);
}
export function route(points: Coordinates[]): Promise<RoutedPoints> {
if (get(routing)) {
const profile = routingProfiles[get(routingProfile)];
if (profile.engine === 'graphhopper') {
@@ -54,7 +82,7 @@ const mtbRatingToScale: { [key: string]: string } = {
'7': '6',
};
const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
export const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
bike: {
priority: [
{
@@ -95,7 +123,7 @@ const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
},
],
},
motorcycle: {
motorbike: {
priority: [
{
if: 'road_access == PRIVATE',
@@ -108,14 +136,14 @@ async function getGraphHopperRoute(
points: Coordinates[],
graphHopperProfile: string,
privateRoads: boolean
): Promise<TrackPoint[]> {
let response = await fetch('https://graphhopper.gpx.studio/route', {
): Promise<RoutedPoints> {
const 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]),
points: points.map((point) => [point.lng, point.lat]),
profile: graphHopperProfile,
elevation: true,
points_encoded: false,
@@ -127,62 +155,60 @@ async function getGraphHopperRoute(
});
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);
}
throw graphHopperError(await response.json(), points.length);
}
let json = await response.json();
return parseGraphHopperRoute(await response.json());
}
let route: TrackPoint[] = [];
let coordinates = json.paths[0].points.coordinates;
let details = json.paths[0].details;
/** The error to throw for the error response of GraphHopper. Its message is a translation key. */
export function graphHopperError(
error: { message?: string; hints?: { details?: string }[] },
pointCount: number
): Error {
const message = error.message ?? '';
const details = error.hints?.[0]?.details ?? '';
if (message.includes('Cannot find point 0')) {
return new Error('toolbar.routing.error.from');
} else if (message.includes('Cannot find point 1')) {
return new Error(
pointCount == 3 ? 'toolbar.routing.error.via' : 'toolbar.routing.error.to'
);
} else if (details.includes('PointDistanceExceededException')) {
return new Error('toolbar.routing.error.distance');
} else if (details.includes('ConnectionNotFoundException')) {
return new Error('toolbar.routing.error.connection');
}
return new Error(message);
}
/**
* The route of a response of GraphHopper. The details of the path (road class, surface...) are
* given as ranges of points: `[from, to, value]`, `to` excluded except for the last range, which
* includes the last point.
*/
export function parseGraphHopperRoute(json: any): RoutedPoints {
const route = emptyRoute();
const coordinates = json.paths[0].points.coordinates;
const 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: {},
})
);
addPoint(route, coordinates[i][0], coordinates[i][1], coordinates[i][2]);
}
for (let key of graphhopperDetails) {
let detail = details[key];
for (const key of graphhopperDetails) {
const 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++) {
for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1 ? 1 : 0); j++) {
if (detail[i][2] !== undefined && detail[i][2] !== 'missing') {
if (key === 'road_class') {
route[j].setExtension('highway', detail[i][2]);
route.highway[j] = detail[i][2];
} else if (key === 'hike_rating') {
const sacScale = hikeRatingToSACScale[detail[i][2]];
if (sacScale) {
route[j].setExtension('sac_scale', sacScale);
}
route.sacScale[j] = hikeRatingToSACScale[detail[i][2]];
} else if (key === 'mtb_rating') {
const mtbScale = mtbRatingToScale[detail[i][2]];
if (mtbScale) {
route[j].setExtension('mtb_scale', mtbScale);
}
route.mtbScale[j] = mtbRatingToScale[detail[i][2]];
} else if (key === 'surface' && detail[i][2] !== 'other') {
route[j].setExtension('surface', detail[i][2]);
route.surface[j] = detail[i][2];
}
}
}
@@ -195,31 +221,41 @@ async function getGraphHopperRoute(
async function getBRouterRoute(
points: Coordinates[],
brouterProfile: string
): Promise<TrackPoint[]> {
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`;
): Promise<RoutedPoints> {
const url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lng.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
let response = await fetch(url);
const response = await fetch(url);
if (!response.ok) {
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);
}
throw brouterError(await response.text());
}
let geojson = await response.json();
return parseBRouterRoute(await response.json());
}
let route: TrackPoint[] = [];
let coordinates = geojson.features[0].geometry.coordinates;
let messages = geojson.features[0].properties.messages;
/** The error to throw for the error response of BRouter. Its message is a translation key. */
export function brouterError(error: string): Error {
if (error.includes('from-position not mapped in existing datafile')) {
return new Error('toolbar.routing.error.from');
} else if (error.includes('via1-position not mapped in existing datafile')) {
return new Error('toolbar.routing.error.via');
} else if (error.includes('to-position not mapped in existing datafile')) {
return new Error('toolbar.routing.error.to');
} else if (error.includes('Time-out')) {
return new Error('toolbar.routing.error.timeout');
}
return new Error(error);
}
/**
* The route of a response of BRouter (GeoJSON). The tags of the ways are in the `messages`, one
* per way: a way starts at the point whose position the message gives, and applies up to the
* next message.
*/
export function parseBRouterRoute(geojson: any): RoutedPoints {
const route = emptyRoute();
const coordinates = geojson.features[0].geometry.coordinates;
const messages = geojson.features[0].properties.messages;
const lngIdx = messages[0].indexOf('Longitude');
const latIdx = messages[0].indexOf('Latitude');
@@ -228,15 +264,7 @@ async function getBRouterRoute(
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
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),
})
);
addPoint(route, coordinates[i][0], coordinates[i][1], coordinates[i][2]);
if (
messageIdx < messages.length &&
@@ -249,57 +277,57 @@ async function getBRouterRoute(
else tags = getTags(messages[messageIdx][tagIdx]);
}
route[route.length - 1].setExtensions(tags);
route.surface[i] = tags.surface;
route.highway[i] = tags.highway;
route.sacScale[i] = tags.sac_scale;
route.mtbScale[i] = tags.mtb_scale;
}
return route;
}
function getTags(message: string): { [key: string]: string } {
/** The tags of a way, `key=value` separated by spaces, with the `:` of the keys as `_`. */
export function getTags(message: string): { [key: string]: string } {
const fields = message.split(' ');
let tags: { [key: string]: string } = {};
const tags: { [key: string]: string } = {};
for (let i = 0; i < fields.length; i++) {
let [key, value] = fields[i].split('=');
key = key.replace(/:/g, '_');
tags[key] = value;
const [key, value] = fields[i].split('=');
tags[key.replace(/:/g, '_')] = value;
}
return tags;
}
function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
let route: TrackPoint[] = [];
let step = 0.05;
/**
* The points of a straight route through `points`: one every 50 m, and the last one. The
* elevations are 0.
*/
export function interpolatePoints(points: Coordinates[]): RoutedPoints {
const route = emptyRoute();
const step = 0.05;
for (let i = 0; i < points.length - 1; i++) {
// Add intermediate points between each pair of points
let dist = distance(points[i], points[i + 1]) / 1000;
const dist = distance(points[i], points[i + 1]) / 1000;
for (let d = 0; d < dist; d += step) {
let lat = points[i].lat + (d / dist) * (points[i + 1].lat - points[i].lat);
let lon = points[i].lon + (d / dist) * (points[i + 1].lon - points[i].lon);
route.push(
new TrackPoint({
attributes: {
lat: lat,
lon: lon,
},
})
);
const lat = points[i].lat + (d / dist) * (points[i + 1].lat - points[i].lat);
const lng = points[i].lng + (d / dist) * (points[i + 1].lng - points[i].lng);
addPoint(route, lng, lat, 0);
}
}
route.push(
new TrackPoint({
attributes: {
lat: points[points.length - 1].lat,
lon: points[points.length - 1].lon,
},
})
const last = points[points.length - 1];
if (last) {
addPoint(route, last.lng, last.lat, 0);
}
return route;
}
function getIntermediatePoints(points: Coordinates[]): Promise<RoutedPoints> {
const route = interpolatePoints(points);
return getElevation(route.lng.map((lng, i) => ({ lng, lat: route.lat[i] }))).then(
(elevations) => {
route.ele = elevations;
return route;
}
);
return getElevation(route).then((elevations) => {
route.forEach((point, i) => {
point.ele = elevations[i];
});
return route;
});
}
@@ -1,51 +0,0 @@
import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
const earthRadius = 6371008.8;
export const MIN_ANCHOR_ZOOM = 0;
export const MAX_ANCHOR_ZOOM = 22;
export function getZoomLevelForDistance(latitude: number, distance?: number): number {
if (distance === undefined) {
return MIN_ANCHOR_ZOOM;
}
const rad = Math.PI / 180;
const lat = latitude * rad;
return Math.min(
MAX_ANCHOR_ZOOM,
Math.max(MIN_ANCHOR_ZOOM, Math.round(Math.log2((earthRadius * Math.cos(lat)) / distance)))
);
}
export function updateAnchorPoints(file: GPXFile) {
let segments = file.getSegments();
for (let segment of segments) {
if (!segment._data.anchors) {
// New segment, compute anchor points for it
computeAnchorPoints(segment);
continue;
}
if (segment.trkpt.length > 0) {
// Ensure first and last points are anchors and always visible
segment.trkpt[0]._data.anchor = true;
segment.trkpt[0]._data.zoom = 0;
segment.trkpt[segment.trkpt.length - 1]._data.anchor = true;
segment.trkpt[segment.trkpt.length - 1]._data.zoom = 0;
}
}
}
function computeAnchorPoints(segment: TrackSegment) {
let points = segment.trkpt;
let anchors = ramerDouglasPeucker(points, 1);
anchors.forEach((anchor) => {
let point = anchor.point;
point._data.anchor = true;
point._data.zoom = getZoomLevelForDistance(point.getLatitude(), anchor.distance);
});
segment._data.anchors = true;
}