@12-apps/routing 0.0.0-stage → 1.0.0
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 +97 -2
- package/package.json +76 -4
- package/src/core/fallback.ts +16 -0
- package/src/core/geo.ts +97 -0
- package/src/core/planner.ts +106 -0
- package/src/core/provider.ts +67 -0
- package/src/core/types.ts +77 -0
- package/src/index.ts +13 -0
- package/src/manifest/index.ts +18 -0
- package/src/manifest/server.ts +14 -0
- package/src/manifest/web.ts +14 -0
- package/src/providers/google.ts +85 -0
- package/src/providers/http.ts +90 -0
- package/src/providers/openrouteservice.ts +71 -0
- package/src/providers/osrm.ts +58 -0
- package/src/react/copy.ts +24 -0
- package/src/react/en-US.ts +12 -0
- package/src/react/index.ts +28 -0
- package/src/react/locales.ts +12 -0
- package/src/react/map-css.ts +20 -0
- package/src/react/map-elements.ts +142 -0
- package/src/react/map-geometry.ts +82 -0
- package/src/react/maplibre-types.ts +37 -0
- package/src/react/pt-BR.ts +12 -0
- package/src/react/route-map.tsx +149 -0
- package/src/react/types.ts +100 -0
- package/src/react/use-map.ts +104 -0
- package/src/react/use-overlays.ts +162 -0
- package/src/server/index.ts +108 -0
package/README.md
CHANGED
|
@@ -1,3 +1,98 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @12-apps/routing
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Road routes through an ordered list of stops, and a map that draws them.
|
|
4
|
+
|
|
5
|
+
- **The routing service is configuration.** A host lists the providers it
|
|
6
|
+
wants, in order: openrouteservice, OSRM, Google Routes, or its own adapter.
|
|
7
|
+
The planner keeps the first one that answers.
|
|
8
|
+
- **There is always a route.** When every provider fails, times out or is not
|
|
9
|
+
configured, the answer is straight segments between the points, flagged
|
|
10
|
+
`fallback: true`. It has distances and no durations, because a time computed
|
|
11
|
+
from a straight line would be an invention.
|
|
12
|
+
- **The map draws what it is given.** `RouteMap` (MapLibre GL, OpenFreeMap by
|
|
13
|
+
default) renders markers, places, numbered stops, a dashed planned line and
|
|
14
|
+
a solid travelled line. It fetches nothing, and every word and colour comes
|
|
15
|
+
from the host.
|
|
16
|
+
|
|
17
|
+
## Server
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { createApiRouting, openRouteServiceProvider, osrmProvider } from "@12-apps/routing/server";
|
|
21
|
+
|
|
22
|
+
const routing = createApiRouting({
|
|
23
|
+
providers: [
|
|
24
|
+
openRouteServiceProvider({ apiKey: secrets.ROUTING_ORS_API_KEY }),
|
|
25
|
+
osrmProvider({ baseUrl: secrets.ROUTING_OSRM_URL }),
|
|
26
|
+
],
|
|
27
|
+
timeoutMs: 6_000,
|
|
28
|
+
authorize: (actor) => actor.canPlanRoutes,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// In-process (a job that saves a trip's route):
|
|
32
|
+
const route = await routing.planRoute({ origin: shop, stops: [a, b], returnTo: shop });
|
|
33
|
+
// route.geometry, route.legs[i].durationS (null on fallback), route.provider, route.fallback
|
|
34
|
+
|
|
35
|
+
// Or mount `routing.routes` (one POST /route) behind your own router.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The planner keeps the stop order it is given; sequencing stops is the host's
|
|
39
|
+
job. It never throws for provider trouble. It throws `RouteRequestError` only
|
|
40
|
+
for a request that cannot be routed (fewer than two points, or an invalid
|
|
41
|
+
point or 0,0) and for a provider listed twice.
|
|
42
|
+
|
|
43
|
+
### Adding a provider
|
|
44
|
+
|
|
45
|
+
Implement `RoutingProvider`:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const myProvider: RoutingProvider = {
|
|
49
|
+
name: "my-service",
|
|
50
|
+
async route(request, { fetch, signal }) {
|
|
51
|
+
// …call the service with `fetch` and `signal`…
|
|
52
|
+
return { ok: true, geometry, legs }; // or { ok: false, kind: "http", status }
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Add it to the host's list. Nothing else changes.
|
|
58
|
+
|
|
59
|
+
### Google Routes
|
|
60
|
+
|
|
61
|
+
Google's Maps Platform terms do not allow its route content to be displayed on
|
|
62
|
+
a non-Google map. Do not list `googleRoutesProvider` if you draw with
|
|
63
|
+
`RouteMap` over OpenFreeMap or any other non-Google basemap.
|
|
64
|
+
|
|
65
|
+
## Web
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
import { createWebRouting, ROUTE_MAP_COPY } from "@12-apps/routing/react";
|
|
69
|
+
|
|
70
|
+
const { RouteMap } = createWebRouting({
|
|
71
|
+
copy: ROUTE_MAP_COPY["pt-BR"],
|
|
72
|
+
theme: { planned, travelled, done, next, pending, ink, paper, place, control, controlBorder },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
<RouteMap
|
|
76
|
+
height={480}
|
|
77
|
+
markers={couriers.map((c) => ({ id: c.id, position: c.point, text: c.initials, ariaLabel: c.label, color: c.color, onSelect: () => select(c.id) }))}
|
|
78
|
+
places={[{ id: "shop", position: shop, label: "Loja" }]}
|
|
79
|
+
stops={stops}
|
|
80
|
+
planned={route?.geometry}
|
|
81
|
+
travelled={fixes}
|
|
82
|
+
fitKey={tenantSlug}
|
|
83
|
+
/>;
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- `maplibre-gl` is an optional peer dependency: install it in the web host.
|
|
87
|
+
It is imported the first time a map mounts, so a page without a map never
|
|
88
|
+
downloads it.
|
|
89
|
+
- The map fits its content once, and again only when `fitKey` changes or the
|
|
90
|
+
fit control is pressed. A data refresh never moves a map someone panned.
|
|
91
|
+
- Markers that overlap on screen fold into one group button. `onGroupSelect`
|
|
92
|
+
receives their ids; without that callback, the map zooms in on them.
|
|
93
|
+
- No WebGL, or a style that does not load, shows `copy.mapError` with a retry.
|
|
94
|
+
|
|
95
|
+
## Wiring
|
|
96
|
+
|
|
97
|
+
`./manifest` declares `server: ["http"]` and `web: ["surface"]`. `http.create`
|
|
98
|
+
is `createApiRouting` and `surface.create` is `createWebRouting`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,78 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/routing",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"description": "Road routes through an ordered list of stops, with the routing service chosen by host configuration (openrouteservice, OSRM, Google Routes, or your own adapter) and a straight-line fallback when none answers, plus a MapLibre route map for web hosts that draws markers, stops, a planned and a travelled line.",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./server": "./src/server/index.ts",
|
|
10
|
+
"./react": "./src/react/index.ts",
|
|
11
|
+
"./manifest": "./src/manifest/index.ts",
|
|
12
|
+
"./manifest/server": "./src/manifest/server.ts",
|
|
13
|
+
"./manifest/web": "./src/manifest/web.ts",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"clean": "rm -rf node_modules coverage dist",
|
|
18
|
+
"test": "node ../../scripts/vitest-with-teardown.mjs run",
|
|
19
|
+
"test:watch": "vitest watch",
|
|
20
|
+
"lint": "eslint src --max-warnings 0",
|
|
21
|
+
"check-types": "tsc --noEmit",
|
|
22
|
+
"typecheck": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@12-apps/wiring": ">=1.17.0",
|
|
26
|
+
"maplibre-gl": ">=5.0.0",
|
|
27
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"@12-apps/wiring": {
|
|
31
|
+
"optional": true
|
|
32
|
+
},
|
|
33
|
+
"maplibre-gl": {
|
|
34
|
+
"optional": true
|
|
35
|
+
},
|
|
36
|
+
"react": {
|
|
37
|
+
"optional": true
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@12-apps/eslint-config": "^1.22.0",
|
|
42
|
+
"@12-apps/typescript-config": "^1.21.0",
|
|
43
|
+
"@12-apps/wiring": "^1.17.0",
|
|
44
|
+
"@testing-library/react": "^16.3.1",
|
|
45
|
+
"@types/node": "^22.10.6",
|
|
46
|
+
"@types/react": "19.2.2",
|
|
47
|
+
"@types/react-dom": "^19.2.0",
|
|
48
|
+
"@vitejs/plugin-react": "^4.5.2",
|
|
49
|
+
"eslint": "^9.39.1",
|
|
50
|
+
"eslint-plugin-test-flakiness": "^1.4.0",
|
|
51
|
+
"jsdom": "^26.1.0",
|
|
52
|
+
"maplibre-gl": "6.13.0",
|
|
53
|
+
"react": "19.2.0",
|
|
54
|
+
"react-dom": "19.2.0",
|
|
55
|
+
"typescript": "^5.9.2",
|
|
56
|
+
"vitest": "^3.2.4"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=22.0.0"
|
|
60
|
+
},
|
|
61
|
+
"license": "MIT",
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"registry": "https://registry.npmjs.org",
|
|
64
|
+
"access": "public"
|
|
65
|
+
},
|
|
66
|
+
"repository": {
|
|
67
|
+
"type": "git",
|
|
68
|
+
"url": "git+https://github.com/12-apps/shared-packages.git",
|
|
69
|
+
"directory": "packages/routing"
|
|
70
|
+
},
|
|
71
|
+
"files": [
|
|
72
|
+
"src",
|
|
73
|
+
"*.md",
|
|
74
|
+
"!eslint.config.js",
|
|
75
|
+
"!**/__tests__/**",
|
|
76
|
+
"!**/*.test.*"
|
|
77
|
+
]
|
|
78
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route every request gets when no service answered: straight segments
|
|
3
|
+
* between the points, in the order given, with great-circle distances and NO
|
|
4
|
+
* durations. A straight line is an honest picture of the order of stops; a
|
|
5
|
+
* time computed from it would be an invention, so there is none.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { haversineM, toPosition } from "./geo";
|
|
9
|
+
import { waypointsOf } from "./provider";
|
|
10
|
+
import type { PlannedRoute, ProviderFailure, RouteRequest } from "./types";
|
|
11
|
+
|
|
12
|
+
export function straightRoute(request: RouteRequest, failures: ProviderFailure[] = []): PlannedRoute {
|
|
13
|
+
const points = waypointsOf(request);
|
|
14
|
+
const legs = points.slice(1).map((point, i) => ({ distanceM: haversineM(points[i]!, point), durationS: null }));
|
|
15
|
+
return { geometry: points.map(toPosition), legs, provider: null, fallback: true, failures };
|
|
16
|
+
}
|
package/src/core/geo.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The little geometry the package needs, and nothing more: the great-circle
|
|
3
|
+
* distance the fallback measures with, the distance from a point to a drawn
|
|
4
|
+
* line (how far someone is from the route they were given), and the decoder
|
|
5
|
+
* for Google's encoded polyline.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LngLat, Position } from "./types";
|
|
9
|
+
|
|
10
|
+
const EARTH_RADIUS_M = 6_371_008.8;
|
|
11
|
+
|
|
12
|
+
const toRad = (deg: number): number => (deg * Math.PI) / 180;
|
|
13
|
+
|
|
14
|
+
/** Great-circle distance between two points, in metres. */
|
|
15
|
+
export function haversineM(a: LngLat, b: LngLat): number {
|
|
16
|
+
const dLat = toRad(b.lat - a.lat);
|
|
17
|
+
const dLng = toRad(b.lng - a.lng);
|
|
18
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
|
|
19
|
+
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `true` for a finite WGS84 point — and never the 0,0 a missing value turns into. */
|
|
23
|
+
export function isValidPoint(point: LngLat | null | undefined): point is LngLat {
|
|
24
|
+
if (!point) return false;
|
|
25
|
+
const { lng, lat } = point;
|
|
26
|
+
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return false;
|
|
27
|
+
if (lng < -180 || lng > 180 || lat < -90 || lat > 90) return false;
|
|
28
|
+
return !(lng === 0 && lat === 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const toPosition = (point: LngLat): Position => [point.lng, point.lat];
|
|
32
|
+
|
|
33
|
+
const fromPosition = ([lng, lat]: Position): LngLat => ({ lng, lat });
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Shortest distance, in metres, from `point` to the polyline `line`.
|
|
37
|
+
*
|
|
38
|
+
* Each segment is projected in a local equirectangular plane around the point
|
|
39
|
+
* — exact enough at street scale (a few metres over a city), and far cheaper
|
|
40
|
+
* than the spherical formula a per-fix check would otherwise pay. An empty line
|
|
41
|
+
* is infinitely far; a one-point line is that point.
|
|
42
|
+
*/
|
|
43
|
+
export function distanceToLineM(point: LngLat, line: readonly Position[]): number {
|
|
44
|
+
if (line.length === 0) return Number.POSITIVE_INFINITY;
|
|
45
|
+
if (line.length === 1) return haversineM(point, fromPosition(line[0]!));
|
|
46
|
+
const cosLat = Math.cos(toRad(point.lat));
|
|
47
|
+
const project = ([lng, lat]: Position): [number, number] => [
|
|
48
|
+
toRad(lng - point.lng) * cosLat * EARTH_RADIUS_M,
|
|
49
|
+
toRad(lat - point.lat) * EARTH_RADIUS_M,
|
|
50
|
+
];
|
|
51
|
+
let best = Number.POSITIVE_INFINITY;
|
|
52
|
+
for (let i = 1; i < line.length; i += 1) {
|
|
53
|
+
const [ax, ay] = project(line[i - 1]!);
|
|
54
|
+
const [bx, by] = project(line[i]!);
|
|
55
|
+
const dx = bx - ax;
|
|
56
|
+
const dy = by - ay;
|
|
57
|
+
const lengthSq = dx * dx + dy * dy;
|
|
58
|
+
const t = lengthSq === 0 ? 0 : Math.max(0, Math.min(1, -(ax * dx + ay * dy) / lengthSq));
|
|
59
|
+
best = Math.min(best, Math.hypot(ax + t * dx, ay + t * dy));
|
|
60
|
+
}
|
|
61
|
+
return best;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Decode an encoded polyline (precision 5) into `[lng, lat]` positions.
|
|
66
|
+
* Returns `null` on a malformed string rather than a half-decoded line.
|
|
67
|
+
*/
|
|
68
|
+
export function decodePolyline(encoded: string, precision = 5): Position[] | null {
|
|
69
|
+
const factor = 10 ** precision;
|
|
70
|
+
const positions: Position[] = [];
|
|
71
|
+
let index = 0;
|
|
72
|
+
let lat = 0;
|
|
73
|
+
let lng = 0;
|
|
74
|
+
const next = (): number | null => {
|
|
75
|
+
let result = 0;
|
|
76
|
+
let shift = 0;
|
|
77
|
+
let byte: number;
|
|
78
|
+
do {
|
|
79
|
+
if (index >= encoded.length) return null;
|
|
80
|
+
byte = encoded.charCodeAt(index) - 63;
|
|
81
|
+
index += 1;
|
|
82
|
+
if (byte < 0 || byte > 63) return null;
|
|
83
|
+
result |= (byte & 0x1f) << shift;
|
|
84
|
+
shift += 5;
|
|
85
|
+
} while (byte >= 0x20);
|
|
86
|
+
return result & 1 ? ~(result >> 1) : result >> 1;
|
|
87
|
+
};
|
|
88
|
+
while (index < encoded.length) {
|
|
89
|
+
const dLat = next();
|
|
90
|
+
const dLng = next();
|
|
91
|
+
if (dLat === null || dLng === null) return null;
|
|
92
|
+
lat += dLat;
|
|
93
|
+
lng += dLng;
|
|
94
|
+
positions.push([lng / factor, lat / factor]);
|
|
95
|
+
}
|
|
96
|
+
return positions;
|
|
97
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The planner: try each configured provider in the host's order, keep the
|
|
3
|
+
* first route that comes back, and fall back to straight segments when none
|
|
4
|
+
* does. It never throws for a provider's trouble — the only refusals are a
|
|
5
|
+
* request that cannot be routed at all (fewer than two valid points), which is
|
|
6
|
+
* the caller's bug, and a provider list with two adapters of the same name.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { straightRoute } from "./fallback";
|
|
10
|
+
import { isValidPoint } from "./geo";
|
|
11
|
+
import { failureOfThrown, legCountOf, waypointsOf, type ProviderOutcome, type RoutingProvider } from "./provider";
|
|
12
|
+
import type { PlannedRoute, ProviderFailure, RouteRequest } from "./types";
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_PROVIDER_TIMEOUT_MS = 6_000;
|
|
15
|
+
|
|
16
|
+
export interface RoutePlannerConfig {
|
|
17
|
+
/** Tried in this order; the first that answers wins. Empty = always the fallback. */
|
|
18
|
+
providers: readonly RoutingProvider[];
|
|
19
|
+
/** Per-provider budget. A provider that exceeds it is a `timeout` failure. */
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
/** The host's fetch. Defaults to the global one. */
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type RoutePlanner = (request: RouteRequest) => Promise<PlannedRoute>;
|
|
26
|
+
|
|
27
|
+
export class RouteRequestError extends Error {
|
|
28
|
+
constructor(message: string) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "RouteRequestError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createRoutePlanner(config: RoutePlannerConfig): RoutePlanner {
|
|
35
|
+
assertUniqueNames(config.providers);
|
|
36
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_PROVIDER_TIMEOUT_MS;
|
|
37
|
+
const doFetch = config.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
38
|
+
return async (request) => {
|
|
39
|
+
assertRoutable(request);
|
|
40
|
+
const failures: ProviderFailure[] = [];
|
|
41
|
+
for (const provider of config.providers) {
|
|
42
|
+
const outcome = await attempt(provider, request, doFetch, timeoutMs);
|
|
43
|
+
if (outcome.ok) return { geometry: outcome.geometry, legs: outcome.legs, provider: provider.name, fallback: false, failures };
|
|
44
|
+
failures.push({
|
|
45
|
+
provider: provider.name,
|
|
46
|
+
kind: outcome.kind,
|
|
47
|
+
...(outcome.status === undefined ? {} : { status: outcome.status }),
|
|
48
|
+
...(outcome.detail === undefined ? {} : { detail: outcome.detail }),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return straightRoute(request, failures);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function attempt(
|
|
56
|
+
provider: RoutingProvider,
|
|
57
|
+
request: RouteRequest,
|
|
58
|
+
doFetch: typeof fetch,
|
|
59
|
+
timeoutMs: number,
|
|
60
|
+
): Promise<ProviderOutcome> {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
63
|
+
// The timer RACES the adapter rather than only aborting it: an adapter (or
|
|
64
|
+
// a host fetch wrapper) that ignores the signal must not hang the planner,
|
|
65
|
+
// or "there is always a route" stops being true.
|
|
66
|
+
const timedOut = new Promise<ProviderOutcome>((resolve) => {
|
|
67
|
+
timer = setTimeout(() => {
|
|
68
|
+
controller.abort();
|
|
69
|
+
resolve({ ok: false, kind: "timeout" });
|
|
70
|
+
}, timeoutMs);
|
|
71
|
+
});
|
|
72
|
+
// `Promise.resolve().then` so an adapter that throws synchronously is
|
|
73
|
+
// caught like one that rejects.
|
|
74
|
+
const answered = Promise.resolve()
|
|
75
|
+
.then(() => provider.route(request, { fetch: doFetch, signal: controller.signal }))
|
|
76
|
+
.then((outcome) => (outcome.ok ? checkShape(outcome, request) : outcome))
|
|
77
|
+
.catch((error: unknown) => failureOfThrown(error, controller.signal));
|
|
78
|
+
try {
|
|
79
|
+
return await Promise.race([answered, timedOut]);
|
|
80
|
+
} finally {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A 2xx with the wrong number of legs, or no line, is an unreadable body. */
|
|
86
|
+
function checkShape(outcome: Extract<ProviderOutcome, { ok: true }>, request: RouteRequest): ProviderOutcome {
|
|
87
|
+
if (outcome.geometry.length < 2) return { ok: false, kind: "body", detail: "empty geometry" };
|
|
88
|
+
if (outcome.legs.length !== legCountOf(request)) {
|
|
89
|
+
return { ok: false, kind: "body", detail: `expected ${legCountOf(request)} legs, got ${outcome.legs.length}` };
|
|
90
|
+
}
|
|
91
|
+
return outcome;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertRoutable(request: RouteRequest): void {
|
|
95
|
+
const points = waypointsOf(request);
|
|
96
|
+
if (points.length < 2) throw new RouteRequestError("a route needs an origin and at least one stop");
|
|
97
|
+
if (!points.every((point) => isValidPoint(point))) throw new RouteRequestError("every waypoint must be a valid point");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function assertUniqueNames(providers: readonly RoutingProvider[]): void {
|
|
101
|
+
const seen = new Set<string>();
|
|
102
|
+
for (const provider of providers) {
|
|
103
|
+
if (seen.has(provider.name)) throw new RouteRequestError(`provider "${provider.name}" is listed twice`);
|
|
104
|
+
seen.add(provider.name);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The adapter port. A routing service plugs in by implementing this — one
|
|
3
|
+
* file under `providers/`, one entry in the host's provider list — and nothing
|
|
4
|
+
* else in the package, or the host, changes.
|
|
5
|
+
*
|
|
6
|
+
* ## An adapter never throws for trouble it can name
|
|
7
|
+
*
|
|
8
|
+
* A routing service being down, slow, out of quota or unable to connect two
|
|
9
|
+
* points is the EXPECTED case the fallback exists for, not an exception. So an
|
|
10
|
+
* adapter answers a discriminated outcome; the planner walks to the next
|
|
11
|
+
* provider on any `ok: false`. A thrown error is still caught (and recorded as
|
|
12
|
+
* a `transport` failure) — a bug in an adapter must not cost the host its
|
|
13
|
+
* route — but no shipped adapter throws on purpose.
|
|
14
|
+
*
|
|
15
|
+
* ## An adapter is stateless and reads no environment
|
|
16
|
+
*
|
|
17
|
+
* Credentials and base URLs arrive in the adapter's own factory options from
|
|
18
|
+
* the HOST, which owns where secrets live. Nothing here reads `process.env`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ProviderFailureKind, Position, RouteLeg, RouteRequest } from "./types";
|
|
22
|
+
|
|
23
|
+
/** What the planner hands an adapter for one attempt. */
|
|
24
|
+
export interface ProviderContext {
|
|
25
|
+
/** The host's fetch (egress guard, user agent, tracing). */
|
|
26
|
+
fetch: typeof fetch;
|
|
27
|
+
/** Aborts when the planner's per-provider timeout elapses. */
|
|
28
|
+
signal: AbortSignal;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ProviderOutcome =
|
|
32
|
+
| { ok: true; geometry: Position[]; legs: RouteLeg[] }
|
|
33
|
+
| { ok: false; kind: ProviderFailureKind; status?: number; detail?: string };
|
|
34
|
+
|
|
35
|
+
export interface RoutingProvider {
|
|
36
|
+
/** Stable identifier, recorded on every route it plans (`"openrouteservice"`). */
|
|
37
|
+
readonly name: string;
|
|
38
|
+
route(request: RouteRequest, context: ProviderContext): Promise<ProviderOutcome>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The points of a request in travel order: origin, every stop, the return. */
|
|
42
|
+
export function waypointsOf(request: RouteRequest): RouteRequest["origin"][] {
|
|
43
|
+
return [request.origin, ...request.stops, ...(request.returnTo ? [request.returnTo] : [])];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Turn a fetch into an outcome failure, or `null` when it answered 2xx.
|
|
48
|
+
* Shared by the adapters so every one names timeouts and transport errors
|
|
49
|
+
* the same way.
|
|
50
|
+
*/
|
|
51
|
+
export async function failureOfResponse(response: Response): Promise<ProviderOutcome | null> {
|
|
52
|
+
if (response.ok) return null;
|
|
53
|
+
const text = await response.text().catch(() => "");
|
|
54
|
+
return { ok: false, kind: "http", status: response.status, detail: text.slice(0, 200) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Classify an error a fetch threw: an abort is the planner's timeout. */
|
|
58
|
+
export function failureOfThrown(error: unknown, signal: AbortSignal): ProviderOutcome {
|
|
59
|
+
if (signal.aborted) return { ok: false, kind: "timeout" };
|
|
60
|
+
const detail = error instanceof Error ? error.message.slice(0, 200) : undefined;
|
|
61
|
+
return { ok: false, kind: "transport", ...(detail ? { detail } : {}) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The number of legs a request has — what a provider's answer must match. */
|
|
65
|
+
export function legCountOf(request: RouteRequest): number {
|
|
66
|
+
return waypointsOf(request).length - 1;
|
|
67
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary every half of `@12-apps/routing` speaks. No provider word
|
|
3
|
+
* escapes an adapter: whatever a routing service calls its pieces, the planner
|
|
4
|
+
* hands the host these shapes and nothing else.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A point on the map, WGS84 degrees. Longitude first in every array form. */
|
|
8
|
+
export interface LngLat {
|
|
9
|
+
lng: number;
|
|
10
|
+
lat: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** `[lng, lat]` — the GeoJSON order, which is also what MapLibre draws. */
|
|
14
|
+
export type Position = readonly [number, number];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What to route: from `origin`, through every stop IN THE ORDER GIVEN, and
|
|
18
|
+
* optionally back to `returnTo`. The planner never reorders stops — sequencing
|
|
19
|
+
* is the host's decision (a dispatcher may have moved one by hand).
|
|
20
|
+
*/
|
|
21
|
+
export interface RouteRequest {
|
|
22
|
+
origin: LngLat;
|
|
23
|
+
stops: readonly LngLat[];
|
|
24
|
+
returnTo?: LngLat;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One leg between consecutive points: origin → stop 1, stop 1 → stop 2, …,
|
|
29
|
+
* last stop → `returnTo`. `durationS` is `null` when nobody measured it — the
|
|
30
|
+
* straight-line fallback knows a distance, never a time.
|
|
31
|
+
*/
|
|
32
|
+
export interface RouteLeg {
|
|
33
|
+
distanceM: number;
|
|
34
|
+
durationS: number | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A failure the planner recorded on its way to an answer. */
|
|
38
|
+
export interface ProviderFailure {
|
|
39
|
+
provider: string;
|
|
40
|
+
kind: ProviderFailureKind;
|
|
41
|
+
/** HTTP status when the service answered with one. */
|
|
42
|
+
status?: number;
|
|
43
|
+
/** A short, non-secret explanation for logs — never shown to an end user. */
|
|
44
|
+
detail?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ProviderFailureKind =
|
|
48
|
+
/** No credential or base URL was configured for this provider. */
|
|
49
|
+
| "unconfigured"
|
|
50
|
+
/** The service answered with a non-2xx status. */
|
|
51
|
+
| "http"
|
|
52
|
+
/** The request did not finish inside the planner's timeout. */
|
|
53
|
+
| "timeout"
|
|
54
|
+
/** The request never got an answer (DNS, refused, reset, egress guard). */
|
|
55
|
+
| "transport"
|
|
56
|
+
/** The service answered 2xx with a body this adapter cannot read. */
|
|
57
|
+
| "body"
|
|
58
|
+
/** The service found no route between the points. */
|
|
59
|
+
| "no-route";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The planner's answer. Always present: when every provider failed (or none
|
|
63
|
+
* is configured) it is the straight-line fallback, flagged as such, with the
|
|
64
|
+
* failures that led there.
|
|
65
|
+
*/
|
|
66
|
+
export interface PlannedRoute {
|
|
67
|
+
/** The drawn line, `[lng, lat][]`, origin first. */
|
|
68
|
+
geometry: Position[];
|
|
69
|
+
/** One entry per leg, in request order. */
|
|
70
|
+
legs: RouteLeg[];
|
|
71
|
+
/** The provider that answered, or `null` for the fallback. */
|
|
72
|
+
provider: string | null;
|
|
73
|
+
/** `true` when the line is straight segments between the points. */
|
|
74
|
+
fallback: boolean;
|
|
75
|
+
/** Every provider that was tried and failed, in the order tried. */
|
|
76
|
+
failures: ProviderFailure[];
|
|
77
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/routing` — the runtime-neutral core: the vocabulary, the provider
|
|
3
|
+
* port, the planner with its straight-line fallback, and the small geometry
|
|
4
|
+
* both halves share. Backend hosts import `./server`, web hosts `./react`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type { LngLat, PlannedRoute, Position, ProviderFailure, ProviderFailureKind, RouteLeg, RouteRequest } from "./core/types";
|
|
8
|
+
export type { ProviderContext, ProviderOutcome, RoutingProvider } from "./core/provider";
|
|
9
|
+
export { waypointsOf } from "./core/provider";
|
|
10
|
+
export { straightRoute } from "./core/fallback";
|
|
11
|
+
export { createRoutePlanner, DEFAULT_PROVIDER_TIMEOUT_MS, RouteRequestError } from "./core/planner";
|
|
12
|
+
export type { RoutePlanner, RoutePlannerConfig } from "./core/planner";
|
|
13
|
+
export { decodePolyline, distanceToLineM, haversineM, isValidPoint } from "./core/geo";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/routing/manifest` — the SHARED wiring manifest.
|
|
3
|
+
*
|
|
4
|
+
* Identity and the runtime inventory: one HTTP route set (and the in-process
|
|
5
|
+
* planner beside it) for a server host, the map surface for a web host.
|
|
6
|
+
* `@12-apps/wiring` is a TYPE-ONLY devDependency; the producer assertions run
|
|
7
|
+
* in this package's own suite.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PackageManifest } from "@12-apps/wiring";
|
|
11
|
+
|
|
12
|
+
export const routingManifest = {
|
|
13
|
+
name: "@12-apps/routing",
|
|
14
|
+
contract: 1,
|
|
15
|
+
observability: { namespace: "routing" },
|
|
16
|
+
server: ["http"],
|
|
17
|
+
web: ["surface"],
|
|
18
|
+
} as const satisfies PackageManifest;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/routing/manifest/server` — `http.create` IS `createApiRouting`.
|
|
3
|
+
* A host that only plans in-process still binds it (the routes are inert until
|
|
4
|
+
* mounted) or declines the capability with its reason.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AnyServerManifest } from "@12-apps/wiring";
|
|
8
|
+
|
|
9
|
+
import { createApiRouting } from "../server/index";
|
|
10
|
+
|
|
11
|
+
export const routingServerManifest = {
|
|
12
|
+
name: "@12-apps/routing",
|
|
13
|
+
http: { create: createApiRouting },
|
|
14
|
+
} as const satisfies AnyServerManifest;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/routing/manifest/web` — `surface.create` IS `createWebRouting`.
|
|
3
|
+
* No `areas`: a route map is not a page of its own, it sits inside whatever
|
|
4
|
+
* screen shows the trip, which only the host knows.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AnyWebManifest } from "@12-apps/wiring";
|
|
8
|
+
|
|
9
|
+
import { createWebRouting } from "../react/index";
|
|
10
|
+
|
|
11
|
+
export const routingWebManifest = {
|
|
12
|
+
name: "@12-apps/routing",
|
|
13
|
+
surface: { create: createWebRouting },
|
|
14
|
+
} as const satisfies AnyWebManifest;
|