@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
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RouteMapCopy } from "./copy";
|
|
2
|
+
|
|
3
|
+
export const PT_BR_ROUTE_MAP_COPY: RouteMapCopy = {
|
|
4
|
+
zoomIn: "Aproximar",
|
|
5
|
+
zoomOut: "Afastar",
|
|
6
|
+
fitAll: "Mostrar todos",
|
|
7
|
+
mapLabel: "Mapa",
|
|
8
|
+
mapError: "Não foi possível carregar o mapa.",
|
|
9
|
+
retry: "Tentar de novo",
|
|
10
|
+
group: (count) => `${count} aqui — toque para escolher`,
|
|
11
|
+
attribution: "© OpenFreeMap © OpenMapTiles © colaboradores do OpenStreetMap",
|
|
12
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RouteMap` — a geographic map that draws what the host hands it: markers
|
|
3
|
+
* (people, vehicles), places (a shop), numbered stops, a planned line (dashed)
|
|
4
|
+
* and a travelled line (solid). It fetches nothing and decides nothing: every
|
|
5
|
+
* point, word and colour is a prop or config, so the same component serves a
|
|
6
|
+
* fleet overview and a single order's card.
|
|
7
|
+
*
|
|
8
|
+
* - **Real projection.** MapLibre GL over a vector style (OpenFreeMap by
|
|
9
|
+
* default), loaded lazily the first time a map mounts.
|
|
10
|
+
* - **The viewport is the viewer's.** It fits once, and again only when
|
|
11
|
+
* `fitKey` changes or the fit control is pressed.
|
|
12
|
+
* - **Overlapping markers stay reachable** as one group button.
|
|
13
|
+
* - **Failure is visible, not blank**: the host's error copy and a retry.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useRef, type CSSProperties, type JSX } from "react";
|
|
17
|
+
|
|
18
|
+
import type { RouteMapCopy } from "./copy";
|
|
19
|
+
import type { MapLibreLike } from "./maplibre-types";
|
|
20
|
+
import type { RouteMapConfig, RouteMapProps, RouteMapTheme } from "./types";
|
|
21
|
+
import { useMapInstance } from "./use-map";
|
|
22
|
+
import { useOverlays, useOverlaySync } from "./use-overlays";
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_STYLE_URL = "https://tiles.openfreemap.org/styles/liberty";
|
|
25
|
+
|
|
26
|
+
/** How long a map may take to load its style before it shows the error state. */
|
|
27
|
+
const DEFAULT_READY_TIMEOUT_MS = 15_000;
|
|
28
|
+
|
|
29
|
+
const defaultLoader = async (): Promise<MapLibreLike> => {
|
|
30
|
+
const lib = (await import("maplibre-gl")) as unknown as { default?: MapLibreLike } & MapLibreLike;
|
|
31
|
+
return lib.default ?? lib;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function buildRouteMap(config: RouteMapConfig): (props: RouteMapProps) => JSX.Element {
|
|
35
|
+
const { copy, theme } = config;
|
|
36
|
+
const baseLoad = config.loadMapLibre ?? defaultLoader;
|
|
37
|
+
const load = async (): Promise<MapLibreLike> => {
|
|
38
|
+
const lib = await baseLoad();
|
|
39
|
+
if (config.workerUrl) lib.setWorkerUrl?.(config.workerUrl);
|
|
40
|
+
return lib;
|
|
41
|
+
};
|
|
42
|
+
const styleUrl = config.styleUrl ?? DEFAULT_STYLE_URL;
|
|
43
|
+
const readyTimeoutMs = config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
|
44
|
+
|
|
45
|
+
return function RouteMap(props: RouteMapProps): JSX.Element {
|
|
46
|
+
const container = useRef<HTMLDivElement | null>(null);
|
|
47
|
+
const propsRef = useRef(props);
|
|
48
|
+
propsRef.current = props;
|
|
49
|
+
const overlaysRef = useRef<ReturnType<typeof useOverlays> | null>(null);
|
|
50
|
+
const handle = useMapInstance({
|
|
51
|
+
load,
|
|
52
|
+
styleUrl,
|
|
53
|
+
readyTimeoutMs,
|
|
54
|
+
theme,
|
|
55
|
+
container,
|
|
56
|
+
propsRef,
|
|
57
|
+
onMoveEnd: () => overlaysRef.current?.draw(),
|
|
58
|
+
onDispose: () => overlaysRef.current?.clear(),
|
|
59
|
+
});
|
|
60
|
+
const overlays = useOverlays(handle, propsRef, copy, theme);
|
|
61
|
+
overlaysRef.current = overlays;
|
|
62
|
+
useOverlaySync(handle.status === "ready", handle, props, overlays, theme);
|
|
63
|
+
|
|
64
|
+
const height = typeof props.height === "number" ? `${props.height}px` : props.height;
|
|
65
|
+
return (
|
|
66
|
+
<div className="routing-map" data-testid={props.testId} data-state={handle.status} role="region" aria-label={copy.mapLabel} style={{ position: "relative", height, width: "100%", overflow: "hidden", borderRadius: "inherit" }}>
|
|
67
|
+
<div ref={container} style={{ position: "absolute", inset: 0 }} />
|
|
68
|
+
{handle.status === "error" ? (
|
|
69
|
+
<MapError copy={copy} theme={theme} onRetry={handle.retry} />
|
|
70
|
+
) : (
|
|
71
|
+
<MapControls
|
|
72
|
+
copy={copy}
|
|
73
|
+
theme={theme}
|
|
74
|
+
zoom={props.controls?.zoom ?? true}
|
|
75
|
+
fit={props.controls?.fit ?? true}
|
|
76
|
+
onZoomIn={() => handle.mapRef.current?.zoomIn()}
|
|
77
|
+
onZoomOut={() => handle.mapRef.current?.zoomOut()}
|
|
78
|
+
onFit={() => {
|
|
79
|
+
overlays.fitAll();
|
|
80
|
+
propsRef.current.onFitAll?.();
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
84
|
+
{props.overlay ? <div style={{ position: "absolute", left: 12, right: 12, bottom: 28, zIndex: 2 }}>{props.overlay}</div> : null}
|
|
85
|
+
<small style={{ position: "absolute", right: 6, bottom: 4, fontSize: 10, color: theme.ink, background: theme.paper, padding: "0 4px", borderRadius: 4, zIndex: 2 }}>{copy.attribution}</small>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface ControlsProps {
|
|
92
|
+
copy: RouteMapCopy;
|
|
93
|
+
theme: RouteMapTheme;
|
|
94
|
+
zoom: boolean;
|
|
95
|
+
fit: boolean;
|
|
96
|
+
onZoomIn: () => void;
|
|
97
|
+
onZoomOut: () => void;
|
|
98
|
+
onFit: () => void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function MapControls({ copy, theme, zoom, fit, onZoomIn, onZoomOut, onFit }: ControlsProps): JSX.Element | null {
|
|
102
|
+
if (!zoom && !fit) return null;
|
|
103
|
+
return (
|
|
104
|
+
<div style={{ position: "absolute", top: 12, right: 12, display: "flex", flexDirection: "column", gap: 8, zIndex: 2 }}>
|
|
105
|
+
{zoom ? (
|
|
106
|
+
<>
|
|
107
|
+
<button type="button" className="routing-zoom" aria-label={copy.zoomIn} onClick={onZoomIn} style={controlStyle(theme)}>
|
|
108
|
+
+
|
|
109
|
+
</button>
|
|
110
|
+
<button type="button" className="routing-zoom" aria-label={copy.zoomOut} onClick={onZoomOut} style={controlStyle(theme)}>
|
|
111
|
+
−
|
|
112
|
+
</button>
|
|
113
|
+
</>
|
|
114
|
+
) : null}
|
|
115
|
+
{fit ? (
|
|
116
|
+
<button type="button" aria-label={copy.fitAll} onClick={onFit} style={controlStyle(theme)}>
|
|
117
|
+
<FitIcon />
|
|
118
|
+
</button>
|
|
119
|
+
) : null}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function MapError({ copy, theme, onRetry }: { copy: RouteMapCopy; theme: RouteMapTheme; onRetry: () => void }): JSX.Element {
|
|
125
|
+
const style: CSSProperties = { position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, background: theme.paper, color: theme.ink, textAlign: "center", padding: 16 };
|
|
126
|
+
return (
|
|
127
|
+
<div role="alert" style={style}>
|
|
128
|
+
<span>{copy.mapError}</span>
|
|
129
|
+
<button type="button" onClick={onRetry} style={{ ...controlStyle(theme), width: "auto", padding: "0 14px", fontSize: 14 }}>
|
|
130
|
+
{copy.retry}
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function FitIcon(): JSX.Element {
|
|
137
|
+
return (
|
|
138
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
139
|
+
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
|
140
|
+
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
|
141
|
+
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
|
142
|
+
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
|
143
|
+
</svg>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function controlStyle(theme: RouteMapTheme): CSSProperties {
|
|
148
|
+
return { minWidth: 44, width: 44, height: 44, padding: 0, borderRadius: 10, border: `1px solid ${theme.controlBorder}`, background: theme.control, color: theme.ink, fontSize: 20, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" };
|
|
149
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { LngLat, Position } from "../core/types";
|
|
4
|
+
|
|
5
|
+
import type { RouteMapCopy } from "./copy";
|
|
6
|
+
import type { MapLibreLike } from "./maplibre-types";
|
|
7
|
+
|
|
8
|
+
export interface RouteMapMarker {
|
|
9
|
+
id: string;
|
|
10
|
+
position: LngLat;
|
|
11
|
+
/** What the marker reads — initials, or a name when emphasised. */
|
|
12
|
+
text: string;
|
|
13
|
+
ariaLabel: string;
|
|
14
|
+
color: string;
|
|
15
|
+
/** The selected marker: larger, on top, never grouped. */
|
|
16
|
+
emphasized?: boolean;
|
|
17
|
+
/** An old position: dashed outline, translucent. */
|
|
18
|
+
faded?: boolean;
|
|
19
|
+
/** A glyph before the text: a motorbike for a courier. */
|
|
20
|
+
icon?: "motorbike";
|
|
21
|
+
onSelect?: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RouteMapStop {
|
|
25
|
+
id: string;
|
|
26
|
+
position: LngLat;
|
|
27
|
+
/** "✓", "2", … */
|
|
28
|
+
mark: string;
|
|
29
|
+
/** Accessible title, e.g. "Parada 2 — Av. Vilarinho, 1731". */
|
|
30
|
+
title: string;
|
|
31
|
+
variant: "done" | "next" | "pending";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RouteMapPlace {
|
|
35
|
+
id: string;
|
|
36
|
+
position: LngLat;
|
|
37
|
+
label: string;
|
|
38
|
+
/** A glyph before the label: a shopfront for a store. */
|
|
39
|
+
icon?: "store";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Colours, from the host's theme — the component never picks one itself. */
|
|
43
|
+
export interface RouteMapTheme {
|
|
44
|
+
planned: string;
|
|
45
|
+
travelled: string;
|
|
46
|
+
done: string;
|
|
47
|
+
next: string;
|
|
48
|
+
pending: string;
|
|
49
|
+
ink: string;
|
|
50
|
+
paper: string;
|
|
51
|
+
place: string;
|
|
52
|
+
control: string;
|
|
53
|
+
controlBorder: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RouteMapProps {
|
|
57
|
+
markers?: readonly RouteMapMarker[];
|
|
58
|
+
stops?: readonly RouteMapStop[];
|
|
59
|
+
places?: readonly RouteMapPlace[];
|
|
60
|
+
/** The dashed line, `[lng, lat][]`. */
|
|
61
|
+
planned?: readonly Position[];
|
|
62
|
+
/** The solid line, `[lng, lat][]`. */
|
|
63
|
+
travelled?: readonly Position[];
|
|
64
|
+
/** Overrides `theme.travelled` (a status colour). */
|
|
65
|
+
travelledColor?: string;
|
|
66
|
+
/** Change it to refit the viewport to everything drawn. */
|
|
67
|
+
fitKey?: string;
|
|
68
|
+
/** CSS height of the map area. */
|
|
69
|
+
height: number | string;
|
|
70
|
+
controls?: { zoom?: boolean; fit?: boolean };
|
|
71
|
+
/** Called when the viewer presses the fit control (after it fits). */
|
|
72
|
+
onFitAll?: () => void;
|
|
73
|
+
/** Called with the ids of a pressed group of overlapping markers. */
|
|
74
|
+
onGroupSelect?: (markerIds: string[]) => void;
|
|
75
|
+
/** Rendered over the map (a legend), bottom edge. */
|
|
76
|
+
overlay?: ReactNode;
|
|
77
|
+
testId?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RouteMapConfig {
|
|
81
|
+
copy: RouteMapCopy;
|
|
82
|
+
theme: RouteMapTheme;
|
|
83
|
+
/** A MapLibre style URL. Defaults to OpenFreeMap "liberty". */
|
|
84
|
+
styleUrl?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Where MapLibre's worker file is served from. MapLibre 6 ships the worker
|
|
87
|
+
* as its own module (`maplibre-gl/dist/maplibre-gl-worker.mjs`) and cannot
|
|
88
|
+
* guess its bundled URL; a Vite host passes
|
|
89
|
+
* `import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?url"`.
|
|
90
|
+
*/
|
|
91
|
+
workerUrl?: string;
|
|
92
|
+
/** How to load MapLibre — swapped in tests, or for a host that preloads it. */
|
|
93
|
+
loadMapLibre?: () => Promise<MapLibreLike>;
|
|
94
|
+
/**
|
|
95
|
+
* How long the map may take to become ready (its style loaded) before it
|
|
96
|
+
* shows the error state with Retry. Default 15 000 ms. A style request that
|
|
97
|
+
* hangs fires no error, so without this the map would stay blank forever.
|
|
98
|
+
*/
|
|
99
|
+
readyTimeoutMs?: number;
|
|
100
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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 before the map goes away, to drop markers. */
|
|
37
|
+
onDispose: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function injectCss(): void {
|
|
41
|
+
if (typeof document === "undefined" || document.getElementById(MAP_CSS_ID)) return;
|
|
42
|
+
const style = document.createElement("style");
|
|
43
|
+
style.id = MAP_CSS_ID;
|
|
44
|
+
style.textContent = MAP_CSS;
|
|
45
|
+
document.head.appendChild(style);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useMapInstance(setup: MapSetup): MapHandle {
|
|
49
|
+
const mapRef = useRef<MapLike | null>(null);
|
|
50
|
+
const libRef = useRef<MapLibreLike | null>(null);
|
|
51
|
+
const setupRef = useRef(setup);
|
|
52
|
+
setupRef.current = setup;
|
|
53
|
+
const [status, setStatus] = useState<MapStatus>("loading");
|
|
54
|
+
const [attempt, setAttempt] = useState(0);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
injectCss();
|
|
58
|
+
let disposed = false;
|
|
59
|
+
let ready = false;
|
|
60
|
+
setStatus("loading");
|
|
61
|
+
const current = setupRef.current;
|
|
62
|
+
// From the START, not from when the library arrives: a library chunk that
|
|
63
|
+
// never finishes downloading must reach the error state too.
|
|
64
|
+
const readyTimer = setTimeout(() => {
|
|
65
|
+
if (!ready && !disposed) setStatus("error");
|
|
66
|
+
}, current.readyTimeoutMs);
|
|
67
|
+
void current
|
|
68
|
+
.load()
|
|
69
|
+
.then((lib) => {
|
|
70
|
+
if (disposed || !current.container.current) return;
|
|
71
|
+
libRef.current = lib;
|
|
72
|
+
const map = new lib.Map({ container: current.container.current, style: current.styleUrl, attributionControl: false, center: centreOf(current.propsRef.current), zoom: 13 });
|
|
73
|
+
mapRef.current = map;
|
|
74
|
+
map.on("error", () => {
|
|
75
|
+
if (!ready && !disposed) setStatus("error");
|
|
76
|
+
});
|
|
77
|
+
// Ready as soon as the STYLE is in: sources, layers and markers need
|
|
78
|
+
// nothing more. `load` waits for every first tile too, which over a
|
|
79
|
+
// slow link can be many seconds of an empty map; it stays as a backstop.
|
|
80
|
+
const onReady = (): void => {
|
|
81
|
+
if (disposed || ready) return;
|
|
82
|
+
ready = true;
|
|
83
|
+
clearTimeout(readyTimer);
|
|
84
|
+
addLines(map, current.theme);
|
|
85
|
+
setStatus("ready");
|
|
86
|
+
};
|
|
87
|
+
map.on("style.load", onReady);
|
|
88
|
+
map.on("load", onReady);
|
|
89
|
+
map.on("moveend", () => setupRef.current.onMoveEnd());
|
|
90
|
+
})
|
|
91
|
+
.catch(() => {
|
|
92
|
+
if (!disposed) setStatus("error");
|
|
93
|
+
});
|
|
94
|
+
return () => {
|
|
95
|
+
disposed = true;
|
|
96
|
+
clearTimeout(readyTimer);
|
|
97
|
+
setupRef.current.onDispose();
|
|
98
|
+
mapRef.current?.remove();
|
|
99
|
+
mapRef.current = null;
|
|
100
|
+
};
|
|
101
|
+
}, [attempt]);
|
|
102
|
+
|
|
103
|
+
return { mapRef, libRef, status, retry: () => setAttempt((n) => n + 1) };
|
|
104
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
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
|
+
|
|
18
|
+
import { useEffect, useRef, type MutableRefObject } from "react";
|
|
19
|
+
|
|
20
|
+
import { isValidPoint } from "../core/geo";
|
|
21
|
+
import type { LngLat } from "../core/types";
|
|
22
|
+
|
|
23
|
+
import type { RouteMapCopy } from "./copy";
|
|
24
|
+
import { groupElement, markerElement, placeElement, stopElement } from "./map-elements";
|
|
25
|
+
import { boundsOf, groupMarkers, PLANNED_LAYER, pointsOf, setLine, TRAVELLED_LAYER } from "./map-geometry";
|
|
26
|
+
import type { MarkerLike } from "./maplibre-types";
|
|
27
|
+
import type { MapHandle } from "./use-map";
|
|
28
|
+
import type { RouteMapMarker, RouteMapProps, RouteMapTheme } from "./types";
|
|
29
|
+
|
|
30
|
+
interface Overlays {
|
|
31
|
+
draw: () => void;
|
|
32
|
+
clear: () => void;
|
|
33
|
+
fitAll: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Drawn {
|
|
37
|
+
marker: MarkerLike;
|
|
38
|
+
element: HTMLElement;
|
|
39
|
+
signature: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Wanted {
|
|
43
|
+
key: string;
|
|
44
|
+
signature: string;
|
|
45
|
+
position: LngLat;
|
|
46
|
+
anchor: "bottom" | "center";
|
|
47
|
+
build: () => HTMLElement;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useOverlays(
|
|
51
|
+
handle: Pick<MapHandle, "mapRef" | "libRef">,
|
|
52
|
+
propsRef: MutableRefObject<RouteMapProps>,
|
|
53
|
+
copy: RouteMapCopy,
|
|
54
|
+
theme: RouteMapTheme,
|
|
55
|
+
): Overlays {
|
|
56
|
+
const drawn = useRef(new Map<string, Drawn>());
|
|
57
|
+
/** The current action per key, read at click time. */
|
|
58
|
+
const actions = useRef(new Map<string, () => void>());
|
|
59
|
+
|
|
60
|
+
const clear = (): void => {
|
|
61
|
+
for (const item of drawn.current.values()) item.marker.remove();
|
|
62
|
+
drawn.current.clear();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const onGroup = (group: readonly RouteMapMarker[]): void => {
|
|
66
|
+
const handler = propsRef.current.onGroupSelect;
|
|
67
|
+
if (handler) return handler(group.map((marker) => marker.id));
|
|
68
|
+
const bounds = boundsOf(group.map((marker) => marker.position));
|
|
69
|
+
if (bounds) handle.mapRef.current?.fitBounds(bounds, { padding: 64, maxZoom: 19, duration: 300 });
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const wanted = (props: RouteMapProps): Wanted[] => {
|
|
73
|
+
const map = handle.mapRef.current;
|
|
74
|
+
if (!map) return [];
|
|
75
|
+
actions.current.clear();
|
|
76
|
+
const out: Wanted[] = [];
|
|
77
|
+
for (const spot of (props.places ?? []).filter((item) => isValidPoint(item.position))) {
|
|
78
|
+
out.push({ key: `p:${spot.id}`, signature: JSON.stringify([spot.label, spot.icon]), position: spot.position, anchor: "bottom", build: () => placeElement(spot, theme) });
|
|
79
|
+
}
|
|
80
|
+
for (const stop of (props.stops ?? []).filter((item) => isValidPoint(item.position))) {
|
|
81
|
+
out.push({ key: `s:${stop.id}`, signature: JSON.stringify([stop.mark, stop.title, stop.variant]), position: stop.position, anchor: "center", build: () => stopElement(stop, theme) });
|
|
82
|
+
}
|
|
83
|
+
const valid = (props.markers ?? []).filter((marker) => isValidPoint(marker.position));
|
|
84
|
+
for (const group of groupMarkers(valid, map)) out.push(group.length === 1 ? markerWanted(group[0]!) : groupWanted(group));
|
|
85
|
+
return out;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const markerWanted = (marker: RouteMapMarker): Wanted => {
|
|
89
|
+
const key = `m:${marker.id}`;
|
|
90
|
+
if (marker.onSelect) actions.current.set(key, marker.onSelect);
|
|
91
|
+
const act = marker.onSelect ? () => actions.current.get(key)?.() : null;
|
|
92
|
+
const signature = JSON.stringify([marker.text, marker.ariaLabel, marker.color, !!marker.emphasized, !!marker.faded, marker.icon, !!marker.onSelect]);
|
|
93
|
+
return { key, signature, position: marker.position, anchor: "bottom", build: () => markerElement(marker, theme, act) };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const groupWanted = (group: RouteMapMarker[]): Wanted => {
|
|
97
|
+
const key = `g:${group.map((marker) => marker.id).join(",")}`;
|
|
98
|
+
actions.current.set(key, () => onGroup(group));
|
|
99
|
+
return { key, signature: String(group.length), position: group[0]!.position, anchor: "bottom", build: () => groupElement(group.length, copy, theme, () => actions.current.get(key)?.()) };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const draw = (): void => {
|
|
103
|
+
const lib = handle.libRef.current;
|
|
104
|
+
const map = handle.mapRef.current;
|
|
105
|
+
if (lib && map) reconcile(drawn.current, wanted(propsRef.current), (element, anchor, at) => new lib.Marker({ element, anchor }).setLngLat(at).addTo(map));
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const fitAll = (): void => {
|
|
109
|
+
// The control column sits on the right edge: keep fitted content clear of
|
|
110
|
+
// it, or the farthest stop lands under the fit button.
|
|
111
|
+
const controls = propsRef.current.controls;
|
|
112
|
+
const right = (controls?.zoom ?? true) || (controls?.fit ?? true) ? 76 : 40;
|
|
113
|
+
const bounds = boundsOf(pointsOf(propsRef.current));
|
|
114
|
+
if (bounds) handle.mapRef.current?.fitBounds(bounds, { padding: { top: 40, bottom: 48, left: 40, right }, maxZoom: 16, duration: 0 });
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return { draw, clear, fitAll };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type Place = (element: HTMLElement, anchor: Wanted["anchor"], at: [number, number]) => MarkerLike;
|
|
121
|
+
|
|
122
|
+
/** Remove what left, move what is unchanged, replace what changed — keeping focus. */
|
|
123
|
+
function reconcile(drawn: Map<string, Drawn>, next: readonly Wanted[], place: Place): void {
|
|
124
|
+
const keep = new Set(next.map((item) => item.key));
|
|
125
|
+
for (const [key, item] of drawn) {
|
|
126
|
+
if (keep.has(key)) continue;
|
|
127
|
+
item.marker.remove();
|
|
128
|
+
drawn.delete(key);
|
|
129
|
+
}
|
|
130
|
+
for (const item of next) {
|
|
131
|
+
const at: [number, number] = [item.position.lng, item.position.lat];
|
|
132
|
+
const current = drawn.get(item.key);
|
|
133
|
+
if (current?.signature === item.signature) {
|
|
134
|
+
current.marker.setLngLat(at);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const hadFocus = !!current && current.element.contains(document.activeElement);
|
|
138
|
+
current?.marker.remove();
|
|
139
|
+
const element = item.build();
|
|
140
|
+
drawn.set(item.key, { marker: place(element, item.anchor, at), element, signature: item.signature });
|
|
141
|
+
if (hadFocus) element.focus();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Keep lines, markers and the fit in step with the props once the map is ready. */
|
|
146
|
+
export function useOverlaySync(ready: boolean, handle: Pick<MapHandle, "mapRef">, props: RouteMapProps, overlays: Overlays, theme: RouteMapTheme): void {
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const map = handle.mapRef.current;
|
|
149
|
+
if (!ready || !map) return;
|
|
150
|
+
setLine(map, PLANNED_LAYER, props.planned);
|
|
151
|
+
setLine(map, TRAVELLED_LAYER, props.travelled);
|
|
152
|
+
map.setPaintProperty(TRAVELLED_LAYER, "line-color", props.travelledColor ?? theme.travelled);
|
|
153
|
+
}, [ready, props.planned, props.travelled, props.travelledColor]);
|
|
154
|
+
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (ready) overlays.draw();
|
|
157
|
+
}, [ready, props.markers, props.stops, props.places]);
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (ready) overlays.fitAll();
|
|
161
|
+
}, [ready, props.fitKey]);
|
|
162
|
+
}
|
|
@@ -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";
|