@12-apps/routing 0.0.0-stage → 1.1.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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The MapLibre instance behind one `RouteMap`: load the library lazily, build
3
+ * the map with both line layers, report `loading | ready | error`, and tear it
4
+ * down on unmount or before a retry. A failure before the first `load` (no
5
+ * WebGL, a library or style that fails or does not arrive within
6
+ * `readyTimeoutMs`, counted from mount) is `error`; a tile failing later is not.
7
+ * Readiness is the style's, not the tiles' (`style.load`).
8
+ */
9
+
10
+ import { useEffect, useRef, useState, type MutableRefObject } from "react";
11
+
12
+ import { MAP_CSS, MAP_CSS_ID } from "./map-css";
13
+ import { addLines, centreOf } from "./map-geometry";
14
+ import type { MapLibreLike, MapLike } from "./maplibre-types";
15
+ import type { RouteMapProps, RouteMapTheme } from "./types";
16
+
17
+ export type MapStatus = "loading" | "ready" | "error";
18
+
19
+ export interface MapHandle {
20
+ mapRef: MutableRefObject<MapLike | null>;
21
+ libRef: MutableRefObject<MapLibreLike | null>;
22
+ status: MapStatus;
23
+ retry: () => void;
24
+ }
25
+
26
+ interface MapSetup {
27
+ load: () => Promise<MapLibreLike>;
28
+ styleUrl: string;
29
+ /** Not ready by then is `error`: a hung style request fires no error event. */
30
+ readyTimeoutMs: number;
31
+ theme: RouteMapTheme;
32
+ container: MutableRefObject<HTMLDivElement | null>;
33
+ propsRef: MutableRefObject<RouteMapProps>;
34
+ /** Called after each pan or zoom, to regroup markers. */
35
+ onMoveEnd: () => void;
36
+ /** Called after each zoom, to re-place the markers' tags. */
37
+ onZoomEnd: () => void;
38
+ /** Called before the map goes away, to drop markers. */
39
+ onDispose: () => void;
40
+ }
41
+
42
+ function injectCss(): void {
43
+ if (typeof document === "undefined" || document.getElementById(MAP_CSS_ID)) return;
44
+ const style = document.createElement("style");
45
+ style.id = MAP_CSS_ID;
46
+ style.textContent = MAP_CSS;
47
+ document.head.appendChild(style);
48
+ }
49
+
50
+ export function useMapInstance(setup: MapSetup): MapHandle {
51
+ const mapRef = useRef<MapLike | null>(null);
52
+ const libRef = useRef<MapLibreLike | null>(null);
53
+ const setupRef = useRef(setup);
54
+ setupRef.current = setup;
55
+ const [status, setStatus] = useState<MapStatus>("loading");
56
+ const [attempt, setAttempt] = useState(0);
57
+
58
+ useEffect(() => {
59
+ injectCss();
60
+ let disposed = false;
61
+ let ready = false;
62
+ setStatus("loading");
63
+ const current = setupRef.current;
64
+ // From the START, not from when the library arrives: a library chunk that
65
+ // never finishes downloading must reach the error state too.
66
+ const readyTimer = setTimeout(() => {
67
+ if (!ready && !disposed) setStatus("error");
68
+ }, current.readyTimeoutMs);
69
+ void current
70
+ .load()
71
+ .then((lib) => {
72
+ if (disposed || !current.container.current) return;
73
+ libRef.current = lib;
74
+ const map = new lib.Map({ container: current.container.current, style: current.styleUrl, attributionControl: false, center: centreOf(current.propsRef.current), zoom: 13 });
75
+ mapRef.current = map;
76
+ map.on("error", () => {
77
+ if (!ready && !disposed) setStatus("error");
78
+ });
79
+ // Ready as soon as the STYLE is in: sources, layers and markers need
80
+ // nothing more. `load` waits for every first tile too, which over a
81
+ // slow link can be many seconds of an empty map; it stays as a backstop.
82
+ const onReady = (): void => {
83
+ if (disposed || ready) return;
84
+ ready = true;
85
+ clearTimeout(readyTimer);
86
+ addLines(map, current.theme);
87
+ setStatus("ready");
88
+ };
89
+ map.on("style.load", onReady);
90
+ map.on("load", onReady);
91
+ map.on("moveend", () => setupRef.current.onMoveEnd());
92
+ map.on("zoomend", () => setupRef.current.onZoomEnd());
93
+ })
94
+ .catch(() => {
95
+ if (!disposed) setStatus("error");
96
+ });
97
+ return () => {
98
+ disposed = true;
99
+ clearTimeout(readyTimer);
100
+ setupRef.current.onDispose();
101
+ mapRef.current?.remove();
102
+ mapRef.current = null;
103
+ };
104
+ }, [attempt]);
105
+
106
+ return { mapRef, libRef, status, retry: () => setAttempt((n) => n + 1) };
107
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Everything `RouteMap` draws on top of the basemap, kept in step with the
3
+ * props: the two lines, the markers (regrouped after every pan or zoom), the
4
+ * stops and places, and the viewport fit — once when ready, then only when
5
+ * `fitKey` changes or the viewer asks.
6
+ *
7
+ * ## Reconciled by key, never rebuilt
8
+ *
9
+ * Every drawn element has a key (`m:<id>`, `g:<ids>`, `s:<id>`, `p:<id>`)
10
+ * and a signature of what it shows. A redraw moves elements whose signature is
11
+ * unchanged, replaces the ones that changed and removes the ones that left —
12
+ * so a refresh every few seconds, a pan, or the host re-rendering with a new
13
+ * array never drops the keyboard focus a viewer put on a marker. Click
14
+ * handlers read the host's CURRENT callback by key, so a kept element never
15
+ * calls a stale one.
16
+ *
17
+ * ## Tags step aside, stops and places never move
18
+ *
19
+ * After every draw, fit and zoom (unless `tagPlacement="fixed"`), each marker's tag (pill and tail) takes the
20
+ * first side of its pin — above, below, right, left — clear of every stop
21
+ * badge and place label (`tag-placement.ts`). The element is restyled and
22
+ * re-offset in place, never rebuilt, so the focus rule above still holds.
23
+ */
24
+
25
+ import { useEffect, useRef, type MutableRefObject, type RefObject } from "react";
26
+
27
+ import { isValidPoint } from "../core/geo";
28
+ import type { LngLat } from "../core/types";
29
+
30
+ import type { RouteMapCopy } from "./copy";
31
+ import { groupElement, markerElement, PLACE_LIFT, placeElement, stopElement } from "./map-elements";
32
+ import { boundsOf, groupMarkers, PLANNED_LAYER, pointsOf, setLine, TRAVELLED_LAYER } from "./map-geometry";
33
+ import type { MarkerLike } from "./maplibre-types";
34
+ import { placeTags, type Placed } from "./tag-placement";
35
+ import type { MapHandle } from "./use-map";
36
+ import type { RouteMapMarker, RouteMapProps, RouteMapTheme } from "./types";
37
+
38
+ interface Overlays {
39
+ draw: () => void;
40
+ /** Re-place the markers' tags around the stops and places, as drawn now. */
41
+ place: () => void;
42
+ clear: () => void;
43
+ fitAll: () => void;
44
+ fitTo: (points: readonly LngLat[]) => void;
45
+ }
46
+
47
+ interface Drawn extends Placed {
48
+ marker: MarkerLike;
49
+ element: HTMLElement;
50
+ signature: string;
51
+ }
52
+
53
+ interface Wanted {
54
+ key: string;
55
+ signature: string;
56
+ position: LngLat;
57
+ anchor: "bottom" | "center";
58
+ role: Placed["role"];
59
+ build: () => HTMLElement;
60
+ }
61
+
62
+ export function useOverlays(
63
+ handle: Pick<MapHandle, "mapRef" | "libRef"> & { container: RefObject<HTMLElement | null> },
64
+ propsRef: MutableRefObject<RouteMapProps>,
65
+ copy: RouteMapCopy,
66
+ theme: RouteMapTheme,
67
+ ): Overlays {
68
+ const drawn = useRef(new Map<string, Drawn>());
69
+ /** The current action per key, read at click time. */
70
+ const actions = useRef(new Map<string, () => void>());
71
+
72
+ const clear = (): void => {
73
+ for (const item of drawn.current.values()) item.marker.remove();
74
+ drawn.current.clear();
75
+ };
76
+
77
+ const onGroup = (group: readonly RouteMapMarker[]): void => {
78
+ const handler = propsRef.current.onGroupSelect;
79
+ if (handler) return handler(group.map((marker) => marker.id));
80
+ const bounds = boundsOf(group.map((marker) => marker.position));
81
+ if (bounds) handle.mapRef.current?.fitBounds(bounds, { padding: 64, maxZoom: 19, duration: 300 });
82
+ };
83
+
84
+ const wanted = (props: RouteMapProps): Wanted[] => {
85
+ const map = handle.mapRef.current;
86
+ if (!map) return [];
87
+ actions.current.clear();
88
+ const out: Wanted[] = [];
89
+ for (const spot of (props.places ?? []).filter((item) => isValidPoint(item.position))) {
90
+ const lifted = props.placeLabels !== "at-point";
91
+ out.push({ key: `p:${spot.id}`, signature: JSON.stringify([spot.label, spot.icon, lifted]), position: spot.position, anchor: lifted ? "bottom" : "center", role: "place", build: () => placeElement(spot, theme, lifted) });
92
+ }
93
+ for (const stop of (props.stops ?? []).filter((item) => isValidPoint(item.position))) {
94
+ out.push({ key: `s:${stop.id}`, signature: JSON.stringify([stop.mark, stop.title, stop.variant, !!stop.emphasized]), position: stop.position, anchor: "center", role: "stop", build: () => stopElement(stop, theme) });
95
+ }
96
+ const valid = (props.markers ?? []).filter((marker) => isValidPoint(marker.position));
97
+ for (const group of groupMarkers(valid, map)) out.push(group.length === 1 ? markerWanted(group[0]!) : groupWanted(group));
98
+ return out;
99
+ };
100
+
101
+ const markerWanted = (marker: RouteMapMarker): Wanted => {
102
+ const key = `m:${marker.id}`;
103
+ if (marker.onSelect) actions.current.set(key, marker.onSelect);
104
+ const act = marker.onSelect ? () => actions.current.get(key)?.() : null;
105
+ const signature = JSON.stringify([marker.text, marker.ariaLabel, marker.color, !!marker.emphasized, !!marker.faded, marker.icon, !!marker.onSelect]);
106
+ return { key, signature, position: marker.position, anchor: "bottom", role: "tag", build: () => markerElement(marker, theme, act) };
107
+ };
108
+
109
+ const groupWanted = (group: RouteMapMarker[]): Wanted => {
110
+ const key = `g:${group.map((marker) => marker.id).join(",")}`;
111
+ actions.current.set(key, () => onGroup(group));
112
+ return { key, signature: String(group.length), position: group[0]!.position, anchor: "bottom", role: "tag", build: () => groupElement(group.length, copy, theme, () => actions.current.get(key)?.()) };
113
+ };
114
+
115
+ const draw = (): void => {
116
+ const lib = handle.libRef.current;
117
+ const map = handle.mapRef.current;
118
+ if (lib && map) reconcile(drawn.current, wanted(propsRef.current), (element, anchor, at) => new lib.Marker({ element, anchor }).setLngLat(at).addTo(map));
119
+ place();
120
+ };
121
+
122
+ const place = (): void => placeAll(handle, drawn.current, propsRef.current.tagPlacement);
123
+
124
+ const fitAll = (): void => {
125
+ // The control column sits on the right edge: keep fitted content clear of
126
+ // it, or the farthest stop lands under the fit button.
127
+ const bounds = boundsOf(pointsOf(propsRef.current));
128
+ if (bounds) handle.mapRef.current?.fitBounds(bounds, { padding: fitPadding(propsRef.current), maxZoom: 16, duration: 0 });
129
+ place();
130
+ };
131
+
132
+ const fitTo = (points: readonly LngLat[]): void => {
133
+ const bounds = boundsOf(points.filter((point) => isValidPoint(point)));
134
+ // Animated: its zoomend/moveend re-place the tags where it lands.
135
+ if (bounds) handle.mapRef.current?.fitBounds(bounds, { padding: fitPadding(propsRef.current), maxZoom: 16, duration: 300 });
136
+ };
137
+
138
+ return { draw, place, clear, fitAll, fitTo };
139
+ }
140
+
141
+ /** Re-place every drawn marker's tag, inside the map container as laid out now. */
142
+ function placeAll(handle: Pick<MapHandle, "mapRef"> & { container: RefObject<HTMLElement | null> }, drawn: Map<string, Drawn>, mode: RouteMapProps["tagPlacement"]): void {
143
+ const map = handle.mapRef.current;
144
+ const box = handle.container.current;
145
+ if (map) placeTags(map, [...drawn.values()], { width: box?.clientWidth ?? 0, height: box?.clientHeight ?? 0 }, mode ?? "avoid");
146
+ }
147
+
148
+ type Edge = "top" | "right" | "bottom" | "left";
149
+
150
+ /**
151
+ * The fit's padding: a margin on every edge, the control column on the right
152
+ * when it shows, plus whatever the host's own overlays cover (`insets`).
153
+ */
154
+ function fitPadding(props: RouteMapProps): Record<Edge, number> {
155
+ const base = basePadding(props);
156
+ const insets = props.insets;
157
+ const edges: Edge[] = ["top", "right", "bottom", "left"];
158
+ return Object.fromEntries(edges.map((edge) => [edge, base[edge] + insetOf(insets?.[edge])])) as Record<Edge, number>;
159
+ }
160
+
161
+ /** The package's own margins, before the host's insets. */
162
+ function basePadding({ controls, places, placeLabels }: RouteMapProps): Record<Edge, number> {
163
+ // A place's label rides PLACE_LIFT px above its point (map-elements.ts), so
164
+ // a place fitted at the top edge needs that much more room or it is clipped.
165
+ const base: Record<Edge, number> = { top: 40 + (places?.length && placeLabels !== "at-point" ? PLACE_LIFT : 0), right: 40, bottom: 48, left: 40 };
166
+ if (showsControls(controls)) base[controls?.placement === "top-left" ? "left" : "right"] = 76;
167
+ return base;
168
+ }
169
+
170
+ function showsControls(controls: RouteMapProps["controls"]): boolean {
171
+ return (controls?.zoom ?? true) || (controls?.fit ?? true);
172
+ }
173
+
174
+ /** A host inset as a usable number of pixels: finite and never negative. */
175
+ function insetOf(value: number | undefined): number {
176
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
177
+ }
178
+
179
+ type Place = (element: HTMLElement, anchor: Wanted["anchor"], at: [number, number]) => MarkerLike;
180
+
181
+ /** Remove what left, move what is unchanged, replace what changed — keeping focus. */
182
+ function reconcile(drawn: Map<string, Drawn>, next: readonly Wanted[], place: Place): void {
183
+ const keep = new Set(next.map((item) => item.key));
184
+ for (const [key, item] of drawn) {
185
+ if (keep.has(key)) continue;
186
+ item.marker.remove();
187
+ drawn.delete(key);
188
+ }
189
+ for (const item of next) {
190
+ const at: [number, number] = [item.position.lng, item.position.lat];
191
+ const current = drawn.get(item.key);
192
+ if (current?.signature === item.signature) {
193
+ current.marker.setLngLat(at);
194
+ current.at = at;
195
+ continue;
196
+ }
197
+ const hadFocus = !!current && current.element.contains(document.activeElement);
198
+ current?.marker.remove();
199
+ const element = item.build();
200
+ drawn.set(item.key, { marker: place(element, item.anchor, at), element, signature: item.signature, at, anchor: item.anchor, role: item.role });
201
+ if (hadFocus) element.focus();
202
+ }
203
+ }
204
+
205
+ /** Keep lines, markers and the fit in step with the props once the map is ready. */
206
+ export function useOverlaySync(ready: boolean, handle: Pick<MapHandle, "mapRef">, props: RouteMapProps, overlays: Overlays, theme: RouteMapTheme): void {
207
+ useEffect(() => {
208
+ const map = handle.mapRef.current;
209
+ if (!ready || !map) return;
210
+ setLine(map, PLANNED_LAYER, props.planned);
211
+ setLine(map, TRAVELLED_LAYER, props.travelled);
212
+ map.setPaintProperty(TRAVELLED_LAYER, "line-color", props.travelledColor ?? theme.travelled);
213
+ }, [ready, props.planned, props.travelled, props.travelledColor]);
214
+
215
+ useEffect(() => {
216
+ if (ready) overlays.draw();
217
+ }, [ready, props.markers, props.stops, props.places, props.placeLabels, props.tagPlacement]);
218
+
219
+ useEffect(() => {
220
+ if (ready) overlays.fitAll();
221
+ }, [ready, props.fitKey]);
222
+
223
+ // After the fit above, so a selection made before the map was ready wins.
224
+ useEffect(() => {
225
+ if (ready && props.focus) overlays.fitTo(props.focus.points.map(([lng, lat]) => ({ lng, lat })));
226
+ }, [ready, props.focus?.key]);
227
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `@12-apps/routing/server` — the one thing this package exposes to a BACKEND
3
+ * host: `createApiRouting(config) → { routes, planRoute }`.
4
+ *
5
+ * - `planRoute` is the planner itself, for a host that plans in-process (a job
6
+ * that saves a route when a trip starts): no HTTP hop, same fallback.
7
+ * - `routes` is one framework-neutral descriptor, `POST /route`, for a host
8
+ * that lets a client ask for a route. Who may ask is the host's
9
+ * `authorize` — the package has no idea what a caller is.
10
+ *
11
+ * Which services are tried, and in what order, is the provider list the host
12
+ * passes: swapping openrouteservice for OSRM, or adding a new adapter, is a
13
+ * config change here and nowhere else.
14
+ */
15
+
16
+ import { isValidPoint } from "../core/geo";
17
+ import { createRoutePlanner, type RoutePlanner, type RoutePlannerConfig } from "../core/planner";
18
+ import type { LngLat, RouteRequest } from "../core/types";
19
+
20
+ export interface RoutingRequest<TActor> {
21
+ actor: TActor;
22
+ params: Record<string, string | undefined>;
23
+ query: Record<string, string | undefined>;
24
+ body?: unknown;
25
+ }
26
+
27
+ export interface RoutingResponse {
28
+ status: number;
29
+ body: unknown;
30
+ }
31
+
32
+ export interface RoutingRoute<TActor> {
33
+ method: "POST";
34
+ path: string;
35
+ handle(request: RoutingRequest<TActor>): Promise<RoutingResponse>;
36
+ }
37
+
38
+ export interface RoutingServerConfig<TActor = unknown> extends RoutePlannerConfig {
39
+ /** Answer whether this actor may plan a route. Required: there is no default. */
40
+ authorize(actor: TActor): boolean | Promise<boolean>;
41
+ /** Upper bound on stops per request (default 25) — a provider bills per waypoint. */
42
+ maxStops?: number;
43
+ }
44
+
45
+ export interface RoutingApi<TActor> {
46
+ routes: RoutingRoute<TActor>[];
47
+ planRoute: RoutePlanner;
48
+ }
49
+
50
+ const DEFAULT_MAX_STOPS = 25;
51
+
52
+ export function createApiRouting<TActor = unknown>(config: RoutingServerConfig<TActor>): RoutingApi<TActor> {
53
+ if (typeof config.authorize !== "function") throw new TypeError("createApiRouting: `authorize` is required");
54
+ const planRoute = createRoutePlanner(config);
55
+ const maxStops = config.maxStops ?? DEFAULT_MAX_STOPS;
56
+ const route: RoutingRoute<TActor> = {
57
+ method: "POST",
58
+ path: "/route",
59
+ async handle(request) {
60
+ if (!(await config.authorize(request.actor))) return { status: 403, body: { error: "forbidden" } };
61
+ const parsed = parseRouteRequest(request.body, maxStops);
62
+ if (!parsed.ok) return { status: 400, body: { error: parsed.error } };
63
+ const route = await planRoute(parsed.request);
64
+ // `detail` is for the host's logs (it can name an internal host); the
65
+ // caller gets which provider failed and how, never the raw text.
66
+ const failures = route.failures.map(({ provider, kind, status }) => ({ provider, kind, ...(status === undefined ? {} : { status }) }));
67
+ return { status: 200, body: { ...route, failures } };
68
+ },
69
+ };
70
+ return { routes: [route], planRoute };
71
+ }
72
+
73
+ type Parsed = { ok: true; request: RouteRequest } | { ok: false; error: string };
74
+
75
+ /** A finite, non-0,0 point from an untrusted value, or `null`. */
76
+ function pointOf(value: unknown): LngLat | null {
77
+ if (typeof value !== "object" || value === null) return null;
78
+ const { lng, lat } = value as Record<string, unknown>;
79
+ if (typeof lng !== "number" || typeof lat !== "number") return null;
80
+ const candidate = { lng, lat };
81
+ return isValidPoint(candidate) ? candidate : null;
82
+ }
83
+
84
+ function stopsOf(value: unknown, maxStops: number): LngLat[] | string {
85
+ if (!Array.isArray(value) || value.length === 0) return "stops must be a non-empty array";
86
+ if (value.length > maxStops) return `at most ${maxStops} stops`;
87
+ const stops = value.map(pointOf);
88
+ return stops.every((stop): stop is LngLat => stop !== null) ? stops : "every stop must be a valid point";
89
+ }
90
+
91
+ /** Validate an untrusted body into a request; never trust a client's points. */
92
+ export function parseRouteRequest(body: unknown, maxStops = DEFAULT_MAX_STOPS): Parsed {
93
+ if (typeof body !== "object" || body === null) return { ok: false, error: "body must be an object" };
94
+ const { origin, stops, returnTo } = body as Record<string, unknown>;
95
+ const from = pointOf(origin);
96
+ if (!from) return { ok: false, error: "origin must be a valid point" };
97
+ const parsedStops = stopsOf(stops, maxStops);
98
+ if (typeof parsedStops === "string") return { ok: false, error: parsedStops };
99
+ const back = returnTo === undefined || returnTo === null ? undefined : pointOf(returnTo);
100
+ if (back === null) return { ok: false, error: "returnTo must be a valid point" };
101
+ return { ok: true, request: { origin: from, stops: parsedStops, ...(back ? { returnTo: back } : {}) } };
102
+ }
103
+
104
+ export { openRouteServiceProvider, type OpenRouteServiceOptions } from "../providers/openrouteservice";
105
+ export { osrmProvider, type OsrmOptions } from "../providers/osrm";
106
+ export { googleRoutesProvider, type GoogleRoutesOptions } from "../providers/google";
107
+ export { createRoutePlanner, DEFAULT_PROVIDER_TIMEOUT_MS, RouteRequestError } from "../core/planner";
108
+ export type { RoutePlanner, RoutePlannerConfig } from "../core/planner";