@any-routing/valhalla-data-provider 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # valhalla-data-provider
2
+
3
+ `AnyRoutingDataProvider` implementation backed by the [Valhalla route API](https://valhalla.github.io/valhalla/api/route/api-reference/).
4
+
5
+ ```ts
6
+ import { ValhallaProvider } from '@any-routing/valhalla-data-provider';
7
+
8
+ const dataProvider = new ValhallaProvider({
9
+ baseUrl: 'https://valhalla1.openstreetmap.de/route',
10
+ costing: 'auto',
11
+ });
12
+ ```
13
+
14
+ The provider sends Valhalla's JSON route request with `POST`, decodes Valhalla's
15
+ polyline6 geometry, and supports alternative routes through `alternates`.
16
+ Requests run in a Web Worker by default; use `worker: false` when a worker is
17
+ not available.
@@ -0,0 +1,22 @@
1
+ import baseConfig from '../../eslint.config.mjs';
2
+
3
+ export default [
4
+ ...baseConfig,
5
+ {
6
+ files: ['**/*.json'],
7
+ rules: {
8
+ '@nx/dependency-checks': [
9
+ 'error',
10
+ {
11
+ ignoredFiles: [
12
+ '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
13
+ '{projectRoot}/vite.config.{js,ts,mjs,mts}',
14
+ ],
15
+ },
16
+ ],
17
+ },
18
+ languageOptions: {
19
+ parser: await import('jsonc-eslint-parser'),
20
+ },
21
+ },
22
+ ];
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@any-routing/valhalla-data-provider",
3
+ "version": "1.0.0-rc.1",
4
+ "type": "module",
5
+ "main": "./index.js",
6
+ "types": "./index.d.ts",
7
+ "dependencies": {
8
+ "@any-routing/core": "1.0.0-rc.1",
9
+ "@turf/bbox": "7.4.0",
10
+ "@turf/helpers": "7.4.0",
11
+ "@types/geojson": "7946.0.16",
12
+ "comlink": "4.4.2"
13
+ }
14
+ }
package/project.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "valhalla-data-provider",
3
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
4
+ "sourceRoot": "libs/valhalla-data-provider/src",
5
+ "projectType": "library",
6
+ "release": {
7
+ "version": {
8
+ "manifestRootsToUpdate": ["dist/{projectRoot}"],
9
+ "currentVersionResolver": "git-tag",
10
+ "fallbackCurrentVersionResolver": "disk"
11
+ }
12
+ },
13
+ "tags": [],
14
+ "targets": {
15
+ "nx-release-publish": {
16
+ "options": {
17
+ "packageRoot": "dist/{projectRoot}"
18
+ }
19
+ }
20
+ }
21
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './lib/valhalla-data-provider';
2
+ export * from './lib/valhalla-provider.types';
@@ -0,0 +1,7 @@
1
+ import { valhallaDataProvider } from './valhalla-data-provider';
2
+
3
+ describe('valhallaDataProvider', () => {
4
+ it('should work', () => {
5
+ expect(valhallaDataProvider()).toEqual('valhalla-data-provider');
6
+ });
7
+ });
@@ -0,0 +1,77 @@
1
+ import { Remote, wrap } from 'comlink';
2
+ import type { AnyRoutingDataProvider, RequestOptions, Waypoint } from '@any-routing/core';
3
+ import { ValhallaExecutor, type ExecutorRequestOptions } from './valhalla.executor';
4
+ import type { Options, ValhallaRoutingData } from './valhalla-provider.types';
5
+
6
+ const defaultOptions: Partial<Options> = {
7
+ baseUrl: 'https://valhalla1.openstreetmap.de/route',
8
+ costing: 'auto',
9
+ units: 'kilometers',
10
+ language: 'en-US',
11
+ worker: true,
12
+ alternatives: 0,
13
+ shapeFormat: 'polyline6',
14
+ };
15
+
16
+ export class ValhallaProvider implements AnyRoutingDataProvider {
17
+ private worker?: Worker;
18
+ private executorAPI: ValhallaExecutor | Remote<ValhallaExecutor>;
19
+ private _options: Options;
20
+
21
+ public get options(): Options {
22
+ return this._options;
23
+ }
24
+
25
+ constructor(options: Options = {}) {
26
+ this._options = { ...defaultOptions, ...options };
27
+ if (this.options.worker) {
28
+ this.worker = new Worker(new URL('./valhalla.worker', import.meta.url), { type: 'module' });
29
+ this.executorAPI = wrap<ValhallaExecutor>(this.worker);
30
+ } else {
31
+ this.executorAPI = new ValhallaExecutor();
32
+ }
33
+ }
34
+
35
+ public destroy(): void {
36
+ this.worker?.terminate();
37
+ }
38
+
39
+ public request(waypoints: Waypoint[], opts: RequestOptions): Promise<ValhallaRoutingData> {
40
+ const url = this.buildUrl(waypoints, { ...this.options, ...opts });
41
+ const requestOptions = {
42
+ ...this.options,
43
+ ...opts,
44
+ url,
45
+ requestLocations: waypoints.map(({ position }, index) => ({
46
+ lat: position.lat,
47
+ lon: position.lng,
48
+ type: index === 0 || index === waypoints.length - 1 ? 'break' : 'via',
49
+ })),
50
+ };
51
+ delete requestOptions.buildUrl;
52
+ return this.executorAPI.request(requestOptions as ExecutorRequestOptions);
53
+ }
54
+
55
+ public async abortAllRequests(): Promise<void> {
56
+ await this.executorAPI.abortAllRequests();
57
+ }
58
+
59
+ public setOption<T extends keyof Options>(key: T, value: Options[T]): void {
60
+ this.options[key] = value;
61
+ }
62
+
63
+ public async hasPendingRequests(): Promise<boolean> {
64
+ return await this.executorAPI.hasPendingRequests();
65
+ }
66
+
67
+ private buildUrl(waypoints: Waypoint[], opts: Options & RequestOptions): string {
68
+ if (waypoints.length < 2) {
69
+ throw new Error('At least two waypoints are required');
70
+ }
71
+ const query = new URLSearchParams(
72
+ Object.entries(opts.queryParams ?? {}).map(([key, value]) => [key, String(value)]),
73
+ ).toString();
74
+ const url = `${opts.baseUrl ?? defaultOptions.baseUrl}${query ? `?${query}` : ''}`;
75
+ return opts.buildUrl ? opts.buildUrl({ waypoints, options: opts }, url) : url;
76
+ }
77
+ }
@@ -0,0 +1,95 @@
1
+ import type {
2
+ AnyRoutingDataResponse,
3
+ RequestOptions,
4
+ RouteSummary,
5
+ Waypoint,
6
+ } from '@any-routing/core';
7
+ import type { ExecutorRequestOptions } from './valhalla.executor';
8
+
9
+ export type ValhallaLocationType = 'break' | 'through' | 'via' | 'break_through';
10
+ export type ValhallaUnits = 'kilometers' | 'miles';
11
+
12
+ export interface ValhallaRouteSummary extends RouteSummary {
13
+ rawRoute: ValhallaRawTrip;
14
+ }
15
+
16
+ export interface ValhallaRoutingData extends AnyRoutingDataResponse {
17
+ routes: ValhallaRouteSummary[];
18
+ requestOptions: ExecutorRequestOptions;
19
+ }
20
+
21
+ export interface ValhallaRawManeuver {
22
+ type?: number;
23
+ instruction?: string;
24
+ length?: number;
25
+ time?: number;
26
+ begin_shape_index?: number;
27
+ end_shape_index?: number;
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ export interface ValhallaRawLeg {
32
+ shape: string;
33
+ summary?: { length?: number; time?: number; cost?: number };
34
+ maneuvers?: ValhallaRawManeuver[];
35
+ }
36
+
37
+ export interface ValhallaRawLocation {
38
+ lat: number;
39
+ lon: number;
40
+ type?: ValhallaLocationType;
41
+ original_index?: number;
42
+ }
43
+
44
+ export interface ValhallaRawTrip {
45
+ locations: ValhallaRawLocation[];
46
+ legs: ValhallaRawLeg[];
47
+ summary?: { length?: number; time?: number; cost?: number };
48
+ status?: number;
49
+ status_message?: string;
50
+ units?: string;
51
+ [key: string]: unknown;
52
+ }
53
+
54
+ export interface ValhallaApiResponse {
55
+ trip?: ValhallaRawTrip;
56
+ alternates?: Array<{ trip: ValhallaRawTrip }>;
57
+ error_code?: number;
58
+ error?: string;
59
+ status?: number;
60
+ status_message?: string;
61
+ }
62
+
63
+ export type Options = {
64
+ baseUrl?: string;
65
+ worker?: boolean;
66
+ costing?: string;
67
+ costingOptions?: Record<string, unknown>;
68
+ units?: ValhallaUnits;
69
+ language?: string;
70
+ alternatives?: number;
71
+ shapeFormat?: 'polyline6' | 'polyline5';
72
+ directionsOptions?: Record<string, unknown>;
73
+ queryParams?: Record<string, string | number | boolean>;
74
+ requestParams?: RequestInit;
75
+ buildUrl?: (
76
+ ctx: { waypoints: Waypoint[]; options: Options & RequestOptions },
77
+ url: string,
78
+ ) => string;
79
+ };
80
+
81
+ export class ValhallaAPIError extends Error {
82
+ constructor(
83
+ message: string,
84
+ public readonly status?: number,
85
+ public readonly body?: {
86
+ error?: string;
87
+ error_code?: number;
88
+ status_code?: number;
89
+ status: string;
90
+ },
91
+ ) {
92
+ super(message);
93
+ this.name = 'ValhallaAPIError';
94
+ }
95
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ValhallaExecutor } from './valhalla.executor';
3
+
4
+ describe('ValhallaExecutor', () => {
5
+ it('posts a Valhalla request and normalizes polyline geometry', async () => {
6
+ const fetchMock = vi.fn().mockResolvedValue(
7
+ new Response(
8
+ JSON.stringify({
9
+ trip: {
10
+ locations: [
11
+ { lat: 38.5, lon: -120.2 },
12
+ { lat: 40.7, lon: -120.95 },
13
+ ],
14
+ legs: [
15
+ {
16
+ shape: '_p~iF~ps|U_ulLnnqC_mqNvxq`@',
17
+ summary: { length: 100, time: 12 },
18
+ },
19
+ ],
20
+ summary: { length: 100, time: 12 },
21
+ status: 0,
22
+ },
23
+ }),
24
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
25
+ ),
26
+ );
27
+ vi.stubGlobal('fetch', fetchMock);
28
+
29
+ const result = await new ValhallaExecutor().request({
30
+ url: 'https://example.test/route',
31
+ mode: 'default',
32
+ requestLocations: [
33
+ { lat: 38.5, lon: -120.2, type: 'break' },
34
+ { lat: 40.7, lon: -120.95, type: 'break' },
35
+ ],
36
+ costing: 'auto',
37
+ units: 'kilometers',
38
+ shapeFormat: 'polyline5',
39
+ });
40
+
41
+ expect(fetchMock).toHaveBeenCalledWith(
42
+ 'https://example.test/route',
43
+ expect.objectContaining({
44
+ method: 'POST',
45
+ body: expect.stringContaining('"locations"'),
46
+ }),
47
+ );
48
+ expect(result.routes[0]?.distance).toBe(100);
49
+ expect(result.routes[0]?.path).toEqual([
50
+ [38.5, -120.2],
51
+ [40.7, -120.95],
52
+ [43.252, -126.453],
53
+ ]);
54
+ expect(result.routesShapeGeojson.features[0]?.geometry.coordinates[0]).toEqual([-120.2, 38.5]);
55
+ });
56
+ });
@@ -0,0 +1,180 @@
1
+ import bbox from '@turf/bbox';
2
+ import { featureCollection, lineString } from '@turf/helpers';
3
+ import type { BBox, Feature, LineString } from 'geojson';
4
+ import { Requester, UnauthorizedError } from '@any-routing/core';
5
+ import {
6
+ ValhallaAPIError,
7
+ type Options,
8
+ type ValhallaApiResponse,
9
+ type ValhallaRawLeg,
10
+ type ValhallaRawTrip,
11
+ type ValhallaRouteSummary,
12
+ type ValhallaRoutingData,
13
+ } from './valhalla-provider.types';
14
+
15
+ export type ExecutorRequestOptions = {
16
+ url: string;
17
+ requestLocations: Array<{ lat: number; lon: number; type: string }>;
18
+ } & Options;
19
+
20
+ type ShapeProperties = { waypoint: number; routeId: number };
21
+
22
+ export class ValhallaExecutor {
23
+ private readonly requester = new Requester();
24
+
25
+ async request(opts: ExecutorRequestOptions): Promise<ValhallaRoutingData> {
26
+ const body = {
27
+ locations: opts.requestLocations,
28
+ costing: opts.costing,
29
+ costing_options: opts.costingOptions,
30
+ units: opts.units,
31
+ language: opts.language,
32
+ alternates: opts.alternatives ?? 0,
33
+ shape_format: opts.shapeFormat,
34
+ directions_options: opts.directionsOptions,
35
+ };
36
+
37
+ try {
38
+ const response = (await this.requester.request(opts.url, {
39
+ ...opts.requestParams,
40
+ method: 'POST',
41
+ headers: {
42
+ ...opts.requestParams?.headers,
43
+ Accept: 'application/json',
44
+ 'Content-Type': 'application/json',
45
+ },
46
+ body: JSON.stringify(body),
47
+ })) as ValhallaApiResponse;
48
+
49
+ if (!response.trip || (response.trip.status && response.trip.status !== 0)) {
50
+ throw new Error(
51
+ response.error ||
52
+ response.status_message ||
53
+ response.trip?.status_message ||
54
+ 'Valhalla returned no route',
55
+ );
56
+ }
57
+
58
+ const trips = [response.trip, ...(response.alternates ?? []).map(({ trip }) => trip)];
59
+ const routes = trips.map((trip, routeId) => summarizeRoute(trip, routeId, opts.shapeFormat));
60
+ const features = routes.flatMap((route, fid) =>
61
+ route.shape.features.map((feature) => ({
62
+ id: fid,
63
+ ...feature,
64
+ properties: { ...feature.properties, routeId: route.id, id: fid },
65
+ })),
66
+ );
67
+ const routesShapeGeojson = featureCollection(features);
68
+
69
+ return {
70
+ routesShapeBounds: bbox(routesShapeGeojson) as BBox,
71
+ rawResponse: response,
72
+ routes,
73
+ selectedRouteId: routes.length ? 0 : null,
74
+ routesShapeGeojson,
75
+ version: performance.now(),
76
+ latest: !this.requester.hasPendingRequests,
77
+ requestOptions: opts,
78
+ };
79
+ } catch (error: unknown) {
80
+ if (error instanceof Response) {
81
+ const body = await error.json();
82
+
83
+ if (error.status === 401) {
84
+ throw new UnauthorizedError(body);
85
+ }
86
+
87
+ throw new ValhallaAPIError(
88
+ body.error || body.status_message || 'Valhalla returned an error',
89
+ error.status,
90
+ body
91
+ );
92
+ }
93
+
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ hasPendingRequests(): boolean {
99
+ return this.requester.hasPendingRequests;
100
+ }
101
+
102
+ abortAllRequests(): void {
103
+ this.requester.abortAllRequests();
104
+ }
105
+ }
106
+
107
+ function summarizeRoute(
108
+ trip: ValhallaRawTrip,
109
+ routeId: number,
110
+ shapeFormat: Options['shapeFormat'],
111
+ ): ValhallaRouteSummary {
112
+ const now = new Date();
113
+ const precision = shapeFormat === 'polyline5' ? 5 : 6;
114
+ const legPaths = trip.legs.map((leg) => decodePolyline(leg.shape, precision));
115
+ const path = legPaths.flatMap((legPath, index) => (index ? legPath.slice(1) : legPath));
116
+ const shapeFeatures: Feature<LineString, ShapeProperties>[] = legPaths
117
+ .filter((legPath) => legPath.length > 1)
118
+ .map((legPath, waypoint) =>
119
+ lineString(
120
+ legPath.map(([lat, lon]) => [lon, lat]),
121
+ { waypoint, routeId },
122
+ ),
123
+ );
124
+ const summary = trip.summary ?? {};
125
+ const duration =
126
+ summary.time ?? trip.legs.reduce((total, leg) => total + (leg.summary?.time ?? 0), 0);
127
+ const distance =
128
+ summary.length ?? trip.legs.reduce((total, leg) => total + (leg.summary?.length ?? 0), 0);
129
+ const locations = trip.locations.map(({ lat, lon }) => ({ lat, lng: lon }));
130
+
131
+ return {
132
+ id: routeId,
133
+ durationTime: duration,
134
+ distance,
135
+ arriveTime: new Date(now.getTime() + duration * 1000),
136
+ departureTime: now,
137
+ path,
138
+ waypoints: locations,
139
+ shape: featureCollection(shapeFeatures),
140
+ rawRoute: trip,
141
+ };
142
+ }
143
+
144
+ function decodePolyline(encoded: string, precision: number): Array<[number, number]> {
145
+ const coordinates: Array<[number, number]> = [];
146
+ const factor = 10 ** precision;
147
+ let index = 0;
148
+ let latitude = 0;
149
+ let longitude = 0;
150
+
151
+ while (index < encoded.length) {
152
+ const latitudeResult = decodeValue(encoded, index);
153
+ index = latitudeResult.index;
154
+ latitude += latitudeResult.value;
155
+ const longitudeResult = decodeValue(encoded, index);
156
+ index = longitudeResult.index;
157
+ longitude += longitudeResult.value;
158
+ coordinates.push([latitude / factor, longitude / factor]);
159
+ }
160
+
161
+ return coordinates;
162
+ }
163
+
164
+ function decodeValue(encoded: string, start: number): { value: number; index: number } {
165
+ let result = 0;
166
+ let shift = 0;
167
+ let index = start;
168
+ let byte: number;
169
+
170
+ do {
171
+ if (index >= encoded.length) {
172
+ throw new Error('Invalid Valhalla polyline geometry');
173
+ }
174
+ byte = encoded.charCodeAt(index++) - 63;
175
+ result |= (byte & 0x1f) << shift;
176
+ shift += 5;
177
+ } while (byte >= 0x20);
178
+
179
+ return { value: (result & 1) !== 0 ? ~(result >> 1) : result >> 1, index };
180
+ }
@@ -0,0 +1,4 @@
1
+ import { expose } from 'comlink';
2
+ import { ValhallaExecutor } from './valhalla.executor';
3
+
4
+ expose(new ValhallaExecutor());
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "es2022",
5
+ "forceConsistentCasingInFileNames": true,
6
+ "strict": true,
7
+ "importHelpers": true,
8
+ "noImplicitOverride": true,
9
+ "noImplicitReturns": true,
10
+ "noFallthroughCasesInSwitch": true,
11
+ "noPropertyAccessFromIndexSignature": true
12
+ },
13
+ "files": [],
14
+ "include": [],
15
+ "references": [{ "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.spec.json" }]
16
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "declaration": true,
6
+ "types": ["node", "vite/client"]
7
+ },
8
+ "include": ["src/**/*.ts"],
9
+ "exclude": ["vite.config.mts", "src/**/*.spec.ts"]
10
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"]
6
+ },
7
+ "include": ["vite.config.mts", "src/**/*.spec.ts"]
8
+ }
@@ -0,0 +1,41 @@
1
+ /// <reference types="vitest" />
2
+ import { defineConfig } from 'vite';
3
+ import dts from 'vite-plugin-dts';
4
+ import * as path from 'path';
5
+ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
6
+ import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
7
+
8
+ export default defineConfig(() => ({
9
+ root: import.meta.dirname,
10
+ cacheDir: '../../node_modules/.vite/libs/valhalla-data-provider',
11
+ plugins: [
12
+ nxViteTsPaths(),
13
+ nxCopyAssetsPlugin(['*.md']),
14
+ dts({
15
+ entryRoot: 'src',
16
+ tsconfigPath: path.join(import.meta.dirname, 'tsconfig.lib.json'),
17
+ pathsToAliases: false,
18
+ }),
19
+ ],
20
+ build: {
21
+ outDir: '../../dist/libs/valhalla-data-provider',
22
+ emptyOutDir: true,
23
+ reportCompressedSize: true,
24
+ commonjsOptions: { transformMixedEsModules: true },
25
+ lib: {
26
+ entry: 'src/index.ts',
27
+ name: 'valhalla-data-provider',
28
+ fileName: 'index',
29
+ formats: ['es' as const],
30
+ },
31
+ rolldownOptions: { external: [] },
32
+ },
33
+ test: {
34
+ name: 'valhalla-data-provider',
35
+ watch: false,
36
+ globals: true,
37
+ environment: 'node',
38
+ include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
39
+ reporters: ['default'],
40
+ },
41
+ }));