This commit is contained in:
vcoppe committed 2026-10-07 17:58:17 +02:00
1 parent 29850c1c8f
commit 594dd8979a
35 files changed
+3135 -1171

No files matched your search

+22 -1
View File
@@ -1,6 +1,6 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { closestPointIndex } from './closest-point';
import { closestPointIndex, closestPointIndexIn } from './closest-point';
describe('closestPointIndex', () => {
const line = [0, 0, 1, 0, 2, 0, 2, 1];
@@ -27,3 +27,24 @@ describe('closestPointIndex', () => {
assert.equal(closestPointIndex(coordinates, { lng: 3, lat: 0.01 }), 0);
});
});
describe('closestPointIndexIn', () => {
const lng = [0, 1, 2, 2, 5, 6];
const lat = [0, 0, 0, 1, 5, 5];
it('gives the index in the arrays, looking at the given range only', () => {
assert.equal(closestPointIndexIn(lng, lat, 0, 4, { lng: 0.8, lat: 0.1 }), 1);
// the range starts after the closest points
assert.equal(closestPointIndexIn(lng, lat, 4, 6, { lng: 0.8, lat: 0.1 }), 4);
assert.equal(closestPointIndexIn(lng, lat, 4, 6, { lng: 5.9, lat: 5.1 }), 5);
});
it('has no point in an empty range', () => {
assert.equal(closestPointIndexIn(lng, lat, 3, 3, { lng: 0, lat: 0 }), undefined);
assert.equal(closestPointIndexIn(lng, lat, 4, 2, { lng: 0, lat: 0 }), undefined);
});
it('is the point itself for a range of one point', () => {
assert.equal(closestPointIndexIn(lng, lat, 3, 4, { lng: 9, lat: 9 }), 3);
});
});
+36 -3
View File
@@ -9,7 +9,40 @@ export function closestPointIndex(
coordinates: ArrayLike<number>,
point: { lng: number; lat: number }
): number | undefined {
const count = Math.floor(coordinates.length / 2);
return closestIndex(
Math.floor(coordinates.length / 2),
(i) => coordinates[2 * i],
(i) => coordinates[2 * i + 1],
point
);
}
/**
* Like `closestPointIndex`, for the points `from` (included) to `to` (excluded) of a polyline
* given as the arrays of its longitudes and latitudes. The index is the one in the arrays.
*/
export function closestPointIndexIn(
lng: ArrayLike<number>,
lat: ArrayLike<number>,
from: number,
to: number,
point: { lng: number; lat: number }
): number | undefined {
const closest = closestIndex(
Math.max(0, to - from),
(i) => lng[from + i],
(i) => lat[from + i],
point
);
return closest === undefined ? undefined : from + closest;
}
function closestIndex(
count: number,
lngAt: (index: number) => number,
latAt: (index: number) => number,
point: { lng: number; lat: number }
): number | undefined {
if (count === 0) {
return undefined;
}
@@ -17,8 +50,8 @@ export function closestPointIndex(
const ky = metersPerDegree;
const px = point.lng * kx;
const py = point.lat * ky;
const x = (i: number) => coordinates[2 * i] * kx - px;
const y = (i: number) => coordinates[2 * i + 1] * ky - py;
const x = (i: number) => lngAt(i) * kx - px;
const y = (i: number) => latAt(i) * ky - py;
let best = 0;
let bestDistance = Number.MAX_VALUE;
@@ -24,23 +24,15 @@
import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing';
import { i18n } from '$lib/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 { 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 { engine } from '$lib/engine';
import { fileActions, newFileName } from '$lib/logic/file-actions';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { RoutingControls, routingControls } from './routing-controls';
import { RoutingControls } from './routing-controls';
let {
minimized = $bindable(false),
@@ -58,52 +50,24 @@
const { privateRoads, routing, routingProfile } = settings;
let fileStateCollectionObserver: GPXFileStateCollectionObserver;
const selection = engine.selection;
let routingControls: RoutingControls | undefined = undefined;
// the tool works on files, tracks and segments, not on waypoints
let validSelection = $derived(
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints'])
$selection.type === 'file' || $selection.type === 'track' || $selection.type === 'segment'
);
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);
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);
@@ -112,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);
@@ -203,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] });
}
}}
>
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,4 @@
import type { Coordinates } from 'gpx';
import { TrackPoint, distance } from 'gpx';
import { distance, type Coordinates } from 'gpx';
import { settings } from '$lib/logic/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)[];
};
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. */
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') {
@@ -108,7 +136,7 @@ async function getGraphHopperRoute(
points: Coordinates[],
graphHopperProfile: string,
privateRoads: boolean
): Promise<TrackPoint[]> {
): Promise<RoutedPoints> {
let response = await fetch('https://graphhopper.gpx.studio/route', {
method: 'POST',
headers: {
@@ -147,42 +175,27 @@ async function getGraphHopperRoute(
let json = await response.json();
let route: TrackPoint[] = [];
let coordinates = json.paths[0].points.coordinates;
let details = json.paths[0].details;
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,7 +208,7 @@ async function getGraphHopperRoute(
async function getBRouterRoute(
points: Coordinates[],
brouterProfile: string
): Promise<TrackPoint[]> {
): Promise<RoutedPoints> {
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);
@@ -217,9 +230,9 @@ async function getBRouterRoute(
let geojson = await response.json();
let route: TrackPoint[] = [];
let coordinates = geojson.features[0].geometry.coordinates;
let messages = geojson.features[0].properties.messages;
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 +241,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,7 +254,10 @@ 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;
@@ -266,40 +274,27 @@ function getTags(message: string): { [key: string]: string } {
return tags;
}
function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
let route: TrackPoint[] = [];
let step = 0.05;
function getIntermediatePoints(points: Coordinates[]): Promise<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 lon = points[i].lon + (d / dist) * (points[i + 1].lon - points[i].lon);
addPoint(route, lon, 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];
addPoint(route, last.lon, last.lat, 0);
return getElevation(route).then((elevations) => {
route.forEach((point, i) => {
point.ele = elevations[i];
});
return route;
});
return getElevation(route.lng.map((lng, i) => ({ lon: lng, lat: route.lat[i] }))).then(
(elevations) => {
route.ele = elevations;
return route;
}
);
}
+119 -4
View File
@@ -2,7 +2,7 @@ import { browser } from '$app/environment';
import { get, writable, type Readable, type Writable } from 'svelte/store';
import { FileColorAllocator, normalizeColor } from '$lib/file-colors';
import { setHidden, type Visibility } from '$lib/file-visibility';
import type { CategoryIntervals } from '$lib/trackpoint-categories';
import { categoryIntervals, type CategoryIntervals } from '$lib/trackpoint-categories';
import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers';
import type { Feature, FeatureCollection, LineString, Point } from 'geojson';
import type {
@@ -13,6 +13,7 @@ import type {
Selection,
WaypointDetails,
TrackpointDetails,
RouteAttributes,
} from 'gpx-rs';
export type {
@@ -101,6 +102,20 @@ export type FileState = {
const EMPTY_STATISTICS: GlobalStatistics = { totalDistance: 0, elevationGain: 0, elevationLoss: 0 };
/**
* New trackpoints for `Engine.route`, as arrays with an entry per trackpoint. The OSM attributes
* are the names of the values (`undefined` when unknown), they are left unknown if missing.
*/
export type RoutePoints = {
lng: ArrayLike<number>;
lat: ArrayLike<number>;
ele: ArrayLike<number>;
surface?: readonly (string | undefined)[];
highway?: readonly (string | undefined)[];
sacScale?: readonly (string | undefined)[];
mtbScale?: readonly (string | undefined)[];
};
/**
* Timestamp of the trackpoints that have none, in `SelectionStatistics.timestamps` (the smallest
* 64-bit integer, like `NO_TIME` in the engine).
@@ -121,7 +136,34 @@ export type StatisticsMetric =
| 'slopeSegment'
| 'surface'
/** `highway`, `sacScale` and `mtbScale`. */
| 'highway';
| 'highway'
/** `anchors`. */
| 'anchors';
/**
* The anchors of the routing tool among the trackpoints of the selection: the points that can be
* dragged to reroute a segment, which are few of them. The arrays are as long as the number of
* anchors, in the order of the trackpoints.
*/
export type SelectionAnchors = {
/** Index of the anchor in the trackpoints of the selection. */
indices: Uint32Array;
/** Lowest map zoom level at which the anchor is shown (0 for the ends of the segments). */
zooms: Uint8Array;
/**
* Index of the first trackpoint of each segment of the selection that has some: anchors that
* are between two consecutive starts belong to the same segment.
*/
segmentStarts: Uint32Array;
/** Id of each of these segments. */
segmentIds: string[];
/**
* Changes when the selected segments, or their trackpoints, change. The indices in the
* selection (of trackpoints, of anchors) only mean something for one revision, which commands
* that use them have to give.
*/
revision: number;
};
/**
* The statistics of the selection: its global statistics, and the values at each of its
@@ -162,6 +204,7 @@ export type SelectionStatistics = {
sacScale?: CategoryIntervals;
/** Mountain biking scale. */
mtbScale?: CategoryIntervals;
anchors?: SelectionAnchors;
/**
* Global statistics of the trackpoints from `start` to `end` (both included), for example
@@ -275,8 +318,12 @@ class Engine {
// Actions. Each one resolves to whether the engine changed something.
newFile(name: string) {
return this.run((w) => w.new_file(name));
/**
* Creates a file, which gets selected. With a `trackpoint`, the file starts with a track and a
* segment that hold it, in the same undo step.
*/
newFile(name: string, trackpoint?: { lng: number; lat: number; ele?: number }) {
return this.run((w) => w.new_file(name, trackpoint?.lng, trackpoint?.lat, trackpoint?.ele));
}
/**
@@ -515,6 +562,65 @@ class Engine {
);
}
/**
* Replaces the trackpoints `start` to `end` (excluded) of the selection by `points` (a pure
* insertion if `start === end`, a pure removal without points), in a single segment: the
* indices are the ones of `SelectionAnchors`, and `revision` the one they were read with. The
* indices in `anchors`, among the new points, become anchors of the routing tool. Resolves to
* `false` if the selection changed since the revision, or if the range is not valid.
*/
route(
revision: number,
start: number,
end: number,
points: RoutePoints,
anchors: readonly number[] = []
) {
const attributes = (values?: readonly (string | undefined)[]) =>
values && { ...categoryIntervals(values) };
const routeAttributes: RouteAttributes = {};
for (const [key, values] of [
['surface', points.surface],
['highway', points.highway],
['sacScale', points.sacScale],
['mtbScale', points.mtbScale],
] as const) {
const intervals = attributes(values);
if (intervals && intervals.names.length > 0) {
routeAttributes[key] = intervals;
}
}
return this.run((w) =>
w.route(
revision,
start,
end,
Float64Array.from(points.lng),
Float64Array.from(points.lat),
Float64Array.from(points.ele),
routeAttributes,
Uint32Array.from(anchors)
)
);
}
/**
* Makes an anchor of the point of the selected segments that is the closest to the
* coordinates, inserting a trackpoint on the path if there is none there. `revision` is the
* one of `SelectionAnchors`.
*/
insertAnchor(revision: number, lng: number, lat: number) {
return this.run((w) => w.insert_anchor(revision, lng, lat));
}
/**
* Makes the trackpoint `index` of the selection the start of its segment, which is a loop:
* the trackpoints before it go to the end, and the loop closes on it. `revision` is the one of `SelectionAnchors`.
*/
changeLoopStart(revision: number, index: number) {
return this.run((w) => w.change_loop_start(revision, index));
}
/** A trackpoint of a segment, `undefined` if it does not exist. */
trackpoint(fileId: string, segmentId: string, index: number): TrackpointDetails | undefined {
return this.wasm?.trackpoint(fileId, segmentId, index);
@@ -717,6 +823,15 @@ class Engine {
wasm.surfaces
);
break;
case 'anchors':
statistics.anchors = {
indices: wasm.anchor_indices(),
zooms: wasm.anchor_zooms(),
segmentStarts: wasm.segment_starts(),
segmentIds: wasm.segment_ids(),
revision: wasm.routing_revision(),
};
break;
case 'highway':
statistics.highway = categories(
wasm.highway_starts(),
+22 -1
View File
@@ -1,6 +1,6 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { categoryValues, type CategoryIntervals } from './trackpoint-categories';
import { categoryIntervals, categoryValues, type CategoryIntervals } from './trackpoint-categories';
describe('categoryValues', () => {
// asphalt, asphalt, unknown, gravel, gravel, gravel, unknown
@@ -43,3 +43,24 @@ describe('categoryValues', () => {
assert.deepEqual(categoryValues(unnamed, 2), [undefined, undefined]);
});
});
describe('categoryIntervals', () => {
it('groups the trackpoints that share a value', () => {
const values = ['asphalt', 'asphalt', undefined, 'gravel', 'gravel', 'asphalt', undefined];
const intervals = categoryIntervals(values);
assert.deepEqual([...intervals.starts], [0, 2, 3, 5, 6]);
assert.deepEqual([...intervals.values], [1, 0, 2, 1, 0]);
assert.deepEqual(intervals.names, ['asphalt', 'gravel']);
// and it goes back to the values
assert.deepEqual(categoryValues(intervals, values.length), values);
});
it('has no intervals when the value is unknown everywhere', () => {
for (const values of [[], [undefined, undefined]]) {
const intervals = categoryIntervals(values);
assert.equal(intervals.starts.length, 0);
assert.equal(intervals.values.length, 0);
assert.deepEqual(intervals.names, []);
}
});
});
+32
View File
@@ -35,3 +35,35 @@ export function categoryValues(
}
return result;
}
/**
* The intervals of the trackpoints that share a value, for a value per trackpoint (`undefined`
* when unknown): the inverse of `categoryValues`. The names are in the order of their first
* appearance. A value that is unknown everywhere gives no intervals.
*/
export function categoryIntervals(values: readonly (string | undefined)[]): CategoryIntervals {
const starts: number[] = [];
const codes: number[] = [];
const names: string[] = [];
const known = new Map<string, number>();
let last: number | undefined = undefined;
values.forEach((value, index) => {
let code = 0;
if (value !== undefined) {
if (!known.has(value)) {
names.push(value);
known.set(value, names.length);
}
code = known.get(value)!;
}
if (code !== last) {
starts.push(index);
codes.push(code);
last = code;
}
});
if (names.length === 0) {
return { starts: new Uint32Array(), values: new Uint8Array(), names };
}
return { starts: Uint32Array.from(starts), values: Uint8Array.from(codes), names };
}