mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-10 07:36:29 +00:00
routing
This commit is contained in:
1 parent
29850c1c8f
commit
594dd8979a
35 files changed
+3135
-1171
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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, []);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user