@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.
- package/README.md +126 -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 +209 -0
- package/src/react/map-geometry.ts +82 -0
- package/src/react/maplibre-types.ts +43 -0
- package/src/react/pt-BR.ts +12 -0
- package/src/react/route-map.tsx +177 -0
- package/src/react/tag-placement.ts +221 -0
- package/src/react/types.ts +142 -0
- package/src/react/use-map.ts +107 -0
- package/src/react/use-overlays.ts +227 -0
- package/src/server/index.ts +108 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers over what `RouteMap` draws: the box that holds it all, the
|
|
3
|
+
* screen-space grouping of overlapping markers, and the two line layers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { isValidPoint } from "../core/geo";
|
|
7
|
+
import type { LngLat, Position } from "../core/types";
|
|
8
|
+
|
|
9
|
+
import type { MapLike } from "./maplibre-types";
|
|
10
|
+
import type { RouteMapMarker, RouteMapProps, RouteMapTheme } from "./types";
|
|
11
|
+
|
|
12
|
+
/** Below this many pixels apart, two markers are one tap target. */
|
|
13
|
+
const GROUP_RADIUS_PX = 32;
|
|
14
|
+
|
|
15
|
+
export const PLANNED_LAYER = "routing-planned";
|
|
16
|
+
export const TRAVELLED_LAYER = "routing-travelled";
|
|
17
|
+
|
|
18
|
+
export function pointsOf(props: RouteMapProps): LngLat[] {
|
|
19
|
+
const points: LngLat[] = [];
|
|
20
|
+
for (const marker of props.markers ?? []) points.push(marker.position);
|
|
21
|
+
for (const stop of props.stops ?? []) points.push(stop.position);
|
|
22
|
+
for (const spot of props.places ?? []) points.push(spot.position);
|
|
23
|
+
for (const [lng, lat] of props.planned ?? []) points.push({ lng, lat });
|
|
24
|
+
return points.filter((point) => isValidPoint(point));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function centreOf(props: RouteMapProps): [number, number] {
|
|
28
|
+
const first = pointsOf(props)[0];
|
|
29
|
+
return first ? [first.lng, first.lat] : [0, 0];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The box around every valid point; a lone point still gets a street-sized box. */
|
|
33
|
+
export function boundsOf(points: readonly LngLat[]): [[number, number], [number, number]] | null {
|
|
34
|
+
const valid = points.filter((point) => isValidPoint(point));
|
|
35
|
+
if (valid.length === 0) return null;
|
|
36
|
+
const lngs = valid.map((point) => point.lng);
|
|
37
|
+
const lats = valid.map((point) => point.lat);
|
|
38
|
+
let [west, east] = [Math.min(...lngs), Math.max(...lngs)];
|
|
39
|
+
let [south, north] = [Math.min(...lats), Math.max(...lats)];
|
|
40
|
+
const pad = 0.0025;
|
|
41
|
+
if (east - west < pad) [west, east] = [west - pad, east + pad];
|
|
42
|
+
if (north - south < pad) [south, north] = [south - pad, north + pad];
|
|
43
|
+
return [
|
|
44
|
+
[west, south],
|
|
45
|
+
[east, north],
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Greedy screen-space grouping: each marker joins the first group whose anchor
|
|
51
|
+
* is within `GROUP_RADIUS_PX`. An emphasised marker always stands alone.
|
|
52
|
+
*/
|
|
53
|
+
export function groupMarkers(markers: readonly RouteMapMarker[], map: Pick<MapLike, "project">): RouteMapMarker[][] {
|
|
54
|
+
const groups: { anchor: { x: number; y: number }; members: RouteMapMarker[] }[] = [];
|
|
55
|
+
const alone: RouteMapMarker[][] = [];
|
|
56
|
+
for (const marker of markers) {
|
|
57
|
+
if (marker.emphasized) {
|
|
58
|
+
alone.push([marker]);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const at = map.project([marker.position.lng, marker.position.lat]);
|
|
62
|
+
const hit = groups.find((group) => Math.hypot(group.anchor.x - at.x, group.anchor.y - at.y) < GROUP_RADIUS_PX);
|
|
63
|
+
if (hit) hit.members.push(marker);
|
|
64
|
+
else groups.push({ anchor: at, members: [marker] });
|
|
65
|
+
}
|
|
66
|
+
return [...groups.map((group) => group.members), ...alone];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function lineFeature(positions: readonly Position[]) {
|
|
70
|
+
return { type: "Feature" as const, properties: {}, geometry: { type: "LineString" as const, coordinates: positions.map((p) => [p[0], p[1]]) } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function addLines(map: MapLike, theme: RouteMapTheme): void {
|
|
74
|
+
for (const id of [PLANNED_LAYER, TRAVELLED_LAYER]) map.addSource(id, { type: "geojson", data: lineFeature([]) });
|
|
75
|
+
const layout = { "line-cap": "round", "line-join": "round" };
|
|
76
|
+
map.addLayer({ id: PLANNED_LAYER, type: "line", source: PLANNED_LAYER, layout, paint: { "line-color": theme.planned, "line-width": 4, "line-dasharray": [2, 1.6] } });
|
|
77
|
+
map.addLayer({ id: TRAVELLED_LAYER, type: "line", source: TRAVELLED_LAYER, layout, paint: { "line-color": theme.travelled, "line-width": 5 } });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function setLine(map: MapLike, id: string, positions: readonly Position[] | undefined): void {
|
|
81
|
+
map.getSource(id)?.setData(lineFeature(positions ?? []));
|
|
82
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of MapLibre GL this package calls — declared here so the
|
|
3
|
+
* component compiles and tests run without the library's WebGL runtime, and
|
|
4
|
+
* so a test can hand in a fake. `maplibre-gl` satisfies it structurally.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface MapLike {
|
|
8
|
+
on(event: "load" | "style.load" | "error" | "moveend" | "zoomend", listener: () => void): unknown;
|
|
9
|
+
remove(): void;
|
|
10
|
+
addSource(id: string, source: { type: "geojson"; data: unknown }): unknown;
|
|
11
|
+
getSource(id: string): { setData(data: unknown): unknown } | undefined;
|
|
12
|
+
addLayer(layer: Record<string, unknown>): unknown;
|
|
13
|
+
setPaintProperty(layer: string, property: string, value: unknown): unknown;
|
|
14
|
+
fitBounds(bounds: [[number, number], [number, number]], options?: Record<string, unknown>): unknown;
|
|
15
|
+
zoomIn(): unknown;
|
|
16
|
+
zoomOut(): unknown;
|
|
17
|
+
project(lngLat: [number, number]): { x: number; y: number };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MarkerLike {
|
|
21
|
+
setLngLat(lngLat: [number, number]): MarkerLike;
|
|
22
|
+
addTo(map: MapLike): MarkerLike;
|
|
23
|
+
remove(): unknown;
|
|
24
|
+
/**
|
|
25
|
+
* Shift the element from its anchor, in pixels — how a marker's tag moves
|
|
26
|
+
* to another side of its pin. Optional so an older fake still drives the
|
|
27
|
+
* map; without it the tag keeps its default side.
|
|
28
|
+
*/
|
|
29
|
+
setOffset?(offset: [number, number]): MarkerLike;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MapLibreLike {
|
|
33
|
+
Map: new (options: {
|
|
34
|
+
container: HTMLElement;
|
|
35
|
+
style: string;
|
|
36
|
+
attributionControl: boolean;
|
|
37
|
+
center: [number, number];
|
|
38
|
+
zoom: number;
|
|
39
|
+
}) => MapLike;
|
|
40
|
+
Marker: new (options: { element: HTMLElement; anchor: "bottom" | "center" }) => MarkerLike;
|
|
41
|
+
/** MapLibre 6 loads its worker from a separate file; the host says where. */
|
|
42
|
+
setWorkerUrl?(url: string): void;
|
|
43
|
+
}
|
|
@@ -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,177 @@
|
|
|
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 { useEffect, useRef, useState, 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
|
+
onZoomEnd: () => overlaysRef.current?.place(),
|
|
59
|
+
onDispose: () => overlaysRef.current?.clear(),
|
|
60
|
+
});
|
|
61
|
+
const overlays = useOverlays({ ...handle, container }, propsRef, copy, theme);
|
|
62
|
+
overlaysRef.current = overlays;
|
|
63
|
+
useOverlaySync(handle.status === "ready", handle, props, overlays, theme);
|
|
64
|
+
|
|
65
|
+
const height = typeof props.height === "number" ? `${props.height}px` : props.height;
|
|
66
|
+
return (
|
|
67
|
+
<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" }}>
|
|
68
|
+
<div ref={container} style={{ position: "absolute", inset: 0 }} />
|
|
69
|
+
{handle.status === "error" ? (
|
|
70
|
+
<MapError copy={copy} theme={theme} onRetry={handle.retry} />
|
|
71
|
+
) : (
|
|
72
|
+
<MapControls
|
|
73
|
+
copy={copy}
|
|
74
|
+
theme={theme}
|
|
75
|
+
{...controlsOf(props)}
|
|
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 && handle.status !== "error" ? <div style={{ position: "absolute", left: 12, right: 12, bottom: 28, zIndex: 2 }}>{props.overlay}</div> : null}
|
|
85
|
+
<Attribution copy={copy} theme={theme} compact={props.attribution === "compact"} />
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The controls the host asked for, with their defaults: both, top-right. */
|
|
92
|
+
function controlsOf({ controls }: RouteMapProps): Pick<ControlsProps, "zoom" | "fit" | "placement"> {
|
|
93
|
+
return { zoom: controls?.zoom ?? true, fit: controls?.fit ?? true, placement: controls?.placement ?? "top-right" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The basemap's required credit: the full line, or an "i" that opens it. */
|
|
97
|
+
function Attribution({ copy, theme, compact }: { copy: RouteMapCopy; theme: RouteMapTheme; compact: boolean }): JSX.Element {
|
|
98
|
+
const [open, setOpen] = useState(false);
|
|
99
|
+
// Switching to compact folds the credit again; switching to full always shows it.
|
|
100
|
+
useEffect(() => setOpen(false), [compact]);
|
|
101
|
+
const line: CSSProperties = { position: "absolute", right: 6, bottom: 4, fontSize: 10, color: theme.ink, background: theme.paper, padding: "0 4px", borderRadius: 4, zIndex: 2 };
|
|
102
|
+
if (!compact) return <small style={line}>{copy.attribution}</small>;
|
|
103
|
+
if (open) {
|
|
104
|
+
return (
|
|
105
|
+
<button type="button" aria-expanded={true} onClick={() => setOpen(false)} style={{ ...line, border: 0, cursor: "pointer" }}>
|
|
106
|
+
{copy.attribution}
|
|
107
|
+
</button>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return (
|
|
111
|
+
<button type="button" aria-label={copy.attribution} aria-expanded={false} onClick={() => setOpen(true)} style={{ ...line, width: 18, height: 18, padding: 0, borderRadius: 9, border: `1px solid ${theme.controlBorder}`, fontWeight: 700, cursor: "pointer" }}>
|
|
112
|
+
i
|
|
113
|
+
</button>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface ControlsProps {
|
|
118
|
+
copy: RouteMapCopy;
|
|
119
|
+
theme: RouteMapTheme;
|
|
120
|
+
zoom: boolean;
|
|
121
|
+
fit: boolean;
|
|
122
|
+
placement: "top-right" | "top-left";
|
|
123
|
+
onZoomIn: () => void;
|
|
124
|
+
onZoomOut: () => void;
|
|
125
|
+
onFit: () => void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function MapControls({ copy, theme, zoom, fit, placement, onZoomIn, onZoomOut, onFit }: ControlsProps): JSX.Element | null {
|
|
129
|
+
if (!zoom && !fit) return null;
|
|
130
|
+
const corner = placement === "top-left" ? { left: 12 } : { right: 12 };
|
|
131
|
+
return (
|
|
132
|
+
<div style={{ position: "absolute", top: 12, ...corner, display: "flex", flexDirection: "column", gap: 8, zIndex: 2 }}>
|
|
133
|
+
{zoom ? (
|
|
134
|
+
<>
|
|
135
|
+
<button type="button" className="routing-zoom" aria-label={copy.zoomIn} onClick={onZoomIn} style={controlStyle(theme)}>
|
|
136
|
+
+
|
|
137
|
+
</button>
|
|
138
|
+
<button type="button" className="routing-zoom" aria-label={copy.zoomOut} onClick={onZoomOut} style={controlStyle(theme)}>
|
|
139
|
+
−
|
|
140
|
+
</button>
|
|
141
|
+
</>
|
|
142
|
+
) : null}
|
|
143
|
+
{fit ? (
|
|
144
|
+
<button type="button" aria-label={copy.fitAll} onClick={onFit} style={controlStyle(theme)}>
|
|
145
|
+
<FitIcon />
|
|
146
|
+
</button>
|
|
147
|
+
) : null}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function MapError({ copy, theme, onRetry }: { copy: RouteMapCopy; theme: RouteMapTheme; onRetry: () => void }): JSX.Element {
|
|
153
|
+
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 };
|
|
154
|
+
return (
|
|
155
|
+
<div role="alert" style={style}>
|
|
156
|
+
<span>{copy.mapError}</span>
|
|
157
|
+
<button type="button" onClick={onRetry} style={{ ...controlStyle(theme), width: "auto", padding: "0 14px", fontSize: 14 }}>
|
|
158
|
+
{copy.retry}
|
|
159
|
+
</button>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function FitIcon(): JSX.Element {
|
|
165
|
+
return (
|
|
166
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
167
|
+
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
|
168
|
+
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
|
169
|
+
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
|
170
|
+
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
|
171
|
+
</svg>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function controlStyle(theme: RouteMapTheme): CSSProperties {
|
|
176
|
+
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" };
|
|
177
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a marker's tag sits around its pin, so it never hides a stop badge or
|
|
3
|
+
* a place label. Stops and places are geographic truth and never move; only a
|
|
4
|
+
* marker's TAG (its pill and tail) changes side — the pin point stays put.
|
|
5
|
+
*
|
|
6
|
+
* Sides are tried in order — above (the default), below, right, left — and the
|
|
7
|
+
* first whose box touches no stop or place and stays inside the map wins. When
|
|
8
|
+
* none is clear, the side with the least overlap area does (ties keep the
|
|
9
|
+
* earlier side), so a crowded card still reads as well as it can.
|
|
10
|
+
*
|
|
11
|
+
* Geometry is `map.project()` plus each element's layout size (`offsetWidth`,
|
|
12
|
+
* which ignores MapLibre's transform), so it never waits on a repaint.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { setTagSide, TAG_TAIL_PX, type TagSide } from "./map-elements";
|
|
16
|
+
import type { MapLike, MarkerLike } from "./maplibre-types";
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
const TAG_SIDES: readonly TagSide[] = ["above", "below", "right", "left"];
|
|
20
|
+
|
|
21
|
+
interface Box {
|
|
22
|
+
left: number;
|
|
23
|
+
top: number;
|
|
24
|
+
right: number;
|
|
25
|
+
bottom: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Point {
|
|
29
|
+
x: number;
|
|
30
|
+
y: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Size {
|
|
34
|
+
width: number;
|
|
35
|
+
height: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The visible tag (pill plus tail) for a pill of `size`, on `side` of `pin`. */
|
|
39
|
+
function tagBox(pin: Point, size: Size, side: TagSide): Box {
|
|
40
|
+
const along = TAG_TAIL_PX;
|
|
41
|
+
if (side === "above") return { left: pin.x - size.width / 2, right: pin.x + size.width / 2, top: pin.y - size.height - along, bottom: pin.y };
|
|
42
|
+
if (side === "below") return { left: pin.x - size.width / 2, right: pin.x + size.width / 2, top: pin.y, bottom: pin.y + along + size.height };
|
|
43
|
+
if (side === "right") return { left: pin.x, right: pin.x + along + size.width, top: pin.y - size.height / 2, bottom: pin.y + size.height / 2 };
|
|
44
|
+
return { left: pin.x - along - size.width, right: pin.x, top: pin.y - size.height / 2, bottom: pin.y + size.height / 2 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function overlapArea(a: Box, b: Box): number {
|
|
48
|
+
const width = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
|
49
|
+
const height = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
|
50
|
+
return width > 0 && height > 0 ? width * height : 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Inside the map; a map with no laid-out size (not measured yet) bounds nothing. */
|
|
54
|
+
function fits(box: Box, bounds: Size): boolean {
|
|
55
|
+
if (bounds.width <= 0 || bounds.height <= 0) return true;
|
|
56
|
+
return box.left >= 0 && box.top >= 0 && box.right <= bounds.width && box.bottom <= bounds.height;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* How far clear a side other than the current one must be to win it: a pin
|
|
61
|
+
* jittering a pixel or two at an obstacle's edge must not flip its tag.
|
|
62
|
+
*/
|
|
63
|
+
const SWITCH_MARGIN_PX = 4;
|
|
64
|
+
|
|
65
|
+
/** In the least-overlap fallback, leave the current side only for this much less overlap. */
|
|
66
|
+
const SWITCH_OVERLAP_RATIO = 0.75;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How far past an obstacle's edge a pin that was standing ON it still counts
|
|
70
|
+
* as on it: a pin jittering across a badge's edge must not flip its tag. A
|
|
71
|
+
* pin arriving must be on the badge itself.
|
|
72
|
+
*/
|
|
73
|
+
const CONTAIN_MARGIN_PX = 6;
|
|
74
|
+
|
|
75
|
+
function grow(box: Box, by: number): Box {
|
|
76
|
+
return { left: box.left - by, top: box.top - by, right: box.right + by, bottom: box.bottom + by };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function overlapWith(box: Box, obstacles: readonly Box[]): number {
|
|
80
|
+
return obstacles.reduce((sum, obstacle) => sum + overlapArea(box, obstacle), 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clear(box: Box, obstacles: readonly Box[], bounds: Size): boolean {
|
|
84
|
+
return overlapWith(box, obstacles) === 0 && fits(box, bounds);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function contains(box: Box, point: Point): boolean {
|
|
88
|
+
return point.x >= box.left && point.x <= box.right && point.y >= box.top && point.y <= box.bottom;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface Scored {
|
|
92
|
+
side: TagSide;
|
|
93
|
+
overlap: number;
|
|
94
|
+
inside: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function better(a: Scored, b: Scored): boolean {
|
|
98
|
+
if (a.overlap !== b.overlap) return a.overlap < b.overlap;
|
|
99
|
+
return a.inside && !b.inside;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** When every side is blocked: the least overlap, but the current side unless that is a real gain. */
|
|
103
|
+
function leastOverlap(pin: Point, size: Size, obstacles: readonly Box[], bounds: Size, current: TagSide): TagSide {
|
|
104
|
+
const scored = TAG_SIDES.map((side): Scored => {
|
|
105
|
+
const box = tagBox(pin, size, side);
|
|
106
|
+
return { side, overlap: overlapWith(box, obstacles), inside: fits(box, bounds) };
|
|
107
|
+
});
|
|
108
|
+
const best = scored.reduce((winner, next) => (better(next, winner) ? next : winner));
|
|
109
|
+
const kept = scored.find((item) => item.side === current)!;
|
|
110
|
+
return best.overlap < kept.overlap * SWITCH_OVERLAP_RATIO ? best.side : current;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The side for a tag whose pill is `size` on a pin at `pin`, now on `current`:
|
|
115
|
+
*
|
|
116
|
+
* 1. in order, the first side that is the current one and clear, or another
|
|
117
|
+
* one clear by `SWITCH_MARGIN_PX` — so a tag returns above once an
|
|
118
|
+
* obstacle has really gone, and never flips on a pixel of jitter;
|
|
119
|
+
* 2. else the first clear side;
|
|
120
|
+
* 3. else the least overlap (keeping the current side unless it is a real gain).
|
|
121
|
+
*
|
|
122
|
+
* The caller leaves out any obstacle under the pin itself (`underPin`).
|
|
123
|
+
*/
|
|
124
|
+
function chooseSide(pin: Point, size: Size, obstacles: readonly Box[], bounds: Size, current: TagSide = "above"): TagSide {
|
|
125
|
+
const margined = TAG_SIDES.find((side) => {
|
|
126
|
+
const box = tagBox(pin, size, side);
|
|
127
|
+
return side === current ? clear(box, obstacles, bounds) : clear(grow(box, SWITCH_MARGIN_PX), obstacles, bounds);
|
|
128
|
+
});
|
|
129
|
+
if (margined) return margined;
|
|
130
|
+
const first = TAG_SIDES.find((side) => clear(tagBox(pin, size, side), obstacles, bounds));
|
|
131
|
+
return first ?? leastOverlap(pin, size, obstacles, bounds, current);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The obstacles under the pin's own point (a courier arriving at his stop):
|
|
136
|
+
* they do not block his tag, since every side would clip them about equally.
|
|
137
|
+
* A pin that was `standing` on one keeps it until `CONTAIN_MARGIN_PX` clear.
|
|
138
|
+
*/
|
|
139
|
+
function underPin(pin: Point, obstacles: readonly Box[], standing: boolean): Box[] {
|
|
140
|
+
const reach = standing ? CONTAIN_MARGIN_PX : 0;
|
|
141
|
+
return obstacles.filter((obstacle) => contains(grow(obstacle, reach), pin));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type Anchor = "bottom" | "center";
|
|
145
|
+
|
|
146
|
+
/** An element's layout box when MapLibre puts its `anchor` on `point`. */
|
|
147
|
+
function anchoredBox(point: Point, anchor: Anchor, size: Size): Box {
|
|
148
|
+
const left = point.x - size.width / 2;
|
|
149
|
+
const top = anchor === "bottom" ? point.y - size.height : point.y - size.height / 2;
|
|
150
|
+
return { left, top, right: left + size.width, bottom: top + size.height };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The marker offset that keeps the pin on its point for a frame of `size`
|
|
155
|
+
* laid out for `side` (anchor `bottom`: the frame's bottom-centre is the
|
|
156
|
+
* point before the offset).
|
|
157
|
+
*/
|
|
158
|
+
function offsetFor(side: TagSide, size: Size): [number, number] {
|
|
159
|
+
if (side === "below") return [0, size.height];
|
|
160
|
+
if (side === "right") return [size.width / 2, size.height / 2];
|
|
161
|
+
if (side === "left") return [-size.width / 2, size.height / 2];
|
|
162
|
+
return [0, 0];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function sizeOf(element: Element | null | undefined): Size {
|
|
166
|
+
const html = element as HTMLElement | null | undefined;
|
|
167
|
+
return { width: html?.offsetWidth ?? 0, height: html?.offsetHeight ?? 0 };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** What the placement needs from each drawn element. */
|
|
171
|
+
export interface Placed {
|
|
172
|
+
marker: MarkerLike;
|
|
173
|
+
element: HTMLElement;
|
|
174
|
+
at: [number, number];
|
|
175
|
+
anchor: Anchor;
|
|
176
|
+
role: "tag" | "stop" | "place";
|
|
177
|
+
/** Whether the pin stood on a stop or place at the last placement (hysteresis). */
|
|
178
|
+
standing?: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A stop's badge, or a place's LABEL (a lifted place's stem is not in the way). */
|
|
182
|
+
function obstacleOf(map: Pick<MapLike, "project">, item: Placed): Box {
|
|
183
|
+
const frame = anchoredBox(map.project(item.at), item.anchor, sizeOf(item.element));
|
|
184
|
+
const target = item.role === "place" ? (item.element.firstElementChild as HTMLElement | null) : null;
|
|
185
|
+
if (!target) return frame;
|
|
186
|
+
const left = frame.left + target.offsetLeft;
|
|
187
|
+
const top = frame.top + target.offsetTop;
|
|
188
|
+
return { left, top, right: left + target.offsetWidth, bottom: top + target.offsetHeight };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Move one tag to `side`, re-anchoring its frame so the tail still meets the pin. */
|
|
192
|
+
function applySide(item: Placed, side: TagSide): void {
|
|
193
|
+
if (sideOf(item.element) === side) return;
|
|
194
|
+
if (!setTagSide(item.element, side)) return;
|
|
195
|
+
item.marker.setOffset?.(offsetFor(side, sizeOf(item.element)));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function sideOf(element: HTMLElement): TagSide {
|
|
199
|
+
const side = element.dataset.tagSide;
|
|
200
|
+
return TAG_SIDES.find((known) => known === side) ?? "above";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Place every marker's tag clear of the stops and places drawn with it — or,
|
|
205
|
+
* with `fixed`, put back above any tag a previous `avoid` had moved.
|
|
206
|
+
*/
|
|
207
|
+
export function placeTags(map: Pick<MapLike, "project">, items: readonly Placed[], bounds: Size, mode: "avoid" | "fixed" = "avoid"): void {
|
|
208
|
+
const tags = items.filter((item) => item.role === "tag");
|
|
209
|
+
if (mode === "fixed") {
|
|
210
|
+
for (const item of tags) applySide(item, "above");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const obstacles = items.filter((item) => item.role !== "tag").map((item) => obstacleOf(map, item));
|
|
214
|
+
for (const item of tags) {
|
|
215
|
+
const pin = map.project(item.at);
|
|
216
|
+
const under = underPin(pin, obstacles, !!item.standing);
|
|
217
|
+
item.standing = under.length > 0;
|
|
218
|
+
const blocking = obstacles.filter((obstacle) => !under.includes(obstacle));
|
|
219
|
+
applySide(item, chooseSide(pin, sizeOf(item.element.firstElementChild), blocking, bounds, sideOf(item.element)));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
* Drawn above every pin (the stop a screen is about, e.g. "this order"), so
|
|
34
|
+
* a courier standing next to it never hides it. Default: under the pins.
|
|
35
|
+
*/
|
|
36
|
+
emphasized?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RouteMapPlace {
|
|
40
|
+
id: string;
|
|
41
|
+
position: LngLat;
|
|
42
|
+
label: string;
|
|
43
|
+
/** A glyph before the label: a shopfront for a store. */
|
|
44
|
+
icon?: "store";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Colours, from the host's theme — the component never picks one itself. */
|
|
48
|
+
export interface RouteMapTheme {
|
|
49
|
+
planned: string;
|
|
50
|
+
travelled: string;
|
|
51
|
+
done: string;
|
|
52
|
+
next: string;
|
|
53
|
+
pending: string;
|
|
54
|
+
ink: string;
|
|
55
|
+
paper: string;
|
|
56
|
+
place: string;
|
|
57
|
+
control: string;
|
|
58
|
+
controlBorder: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RouteMapProps {
|
|
62
|
+
markers?: readonly RouteMapMarker[];
|
|
63
|
+
stops?: readonly RouteMapStop[];
|
|
64
|
+
places?: readonly RouteMapPlace[];
|
|
65
|
+
/** The dashed line, `[lng, lat][]`. */
|
|
66
|
+
planned?: readonly Position[];
|
|
67
|
+
/** The solid line, `[lng, lat][]`. */
|
|
68
|
+
travelled?: readonly Position[];
|
|
69
|
+
/** Overrides `theme.travelled` (a status colour). */
|
|
70
|
+
travelledColor?: string;
|
|
71
|
+
/** Change it to refit the viewport to everything drawn. */
|
|
72
|
+
fitKey?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Fit to just these points (a selected trip) whenever `key` changes — a
|
|
75
|
+
* viewer's selection, never a data refresh. Wins over `fitKey` on the same
|
|
76
|
+
* render.
|
|
77
|
+
*/
|
|
78
|
+
focus?: { key: string; points: readonly Position[] };
|
|
79
|
+
/** CSS height of the map area. */
|
|
80
|
+
height: number | string;
|
|
81
|
+
/**
|
|
82
|
+
* Which controls show, and in which top corner (default `top-right`). A
|
|
83
|
+
* host whose own floating chrome covers one corner moves them to the other.
|
|
84
|
+
*/
|
|
85
|
+
controls?: { zoom?: boolean; fit?: boolean; placement?: "top-right" | "top-left" };
|
|
86
|
+
/** Called when the viewer presses the fit control (after it fits). */
|
|
87
|
+
onFitAll?: () => void;
|
|
88
|
+
/** Called with the ids of a pressed group of overlapping markers. */
|
|
89
|
+
onGroupSelect?: (markerIds: string[]) => void;
|
|
90
|
+
/** Rendered over the map (a legend), bottom edge. */
|
|
91
|
+
overlay?: ReactNode;
|
|
92
|
+
/**
|
|
93
|
+
* Pixels the host's own overlays cover on each edge (a legend along the
|
|
94
|
+
* bottom, a sheet). Added to the fit padding, so "fit all" never parks a
|
|
95
|
+
* pin or a stop under them.
|
|
96
|
+
*/
|
|
97
|
+
insets?: { top?: number; right?: number; bottom?: number; left?: number };
|
|
98
|
+
/**
|
|
99
|
+
* How a place's label is drawn. `lifted` (default) raises it above a pin
|
|
100
|
+
* standing on the same point; `at-point` draws it on the point, for a small
|
|
101
|
+
* map where the lift would cost a third of the height.
|
|
102
|
+
*/
|
|
103
|
+
placeLabels?: "lifted" | "at-point";
|
|
104
|
+
/**
|
|
105
|
+
* Where a marker's tag (pill and tail) sits around its pin. `avoid`
|
|
106
|
+
* (default) keeps it above unless a stop badge or place label is in the
|
|
107
|
+
* way, then takes the first clear side — below, right, left — with the tail
|
|
108
|
+
* still on the pin; stops, places and the pin's point never move. `fixed`
|
|
109
|
+
* always draws it above, as before. Either way the marker element carries
|
|
110
|
+
* `data-tag-side="above|below|right|left"`.
|
|
111
|
+
*/
|
|
112
|
+
tagPlacement?: "avoid" | "fixed";
|
|
113
|
+
/**
|
|
114
|
+
* `full` (default) prints the basemap's attribution line; `compact` shows an
|
|
115
|
+
* "i" control that expands to it, for a small map. Either satisfies the
|
|
116
|
+
* tile terms.
|
|
117
|
+
*/
|
|
118
|
+
attribution?: "full" | "compact";
|
|
119
|
+
testId?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface RouteMapConfig {
|
|
123
|
+
copy: RouteMapCopy;
|
|
124
|
+
theme: RouteMapTheme;
|
|
125
|
+
/** A MapLibre style URL. Defaults to OpenFreeMap "liberty". */
|
|
126
|
+
styleUrl?: string;
|
|
127
|
+
/**
|
|
128
|
+
* Where MapLibre's worker file is served from. MapLibre 6 ships the worker
|
|
129
|
+
* as its own module (`maplibre-gl/dist/maplibre-gl-worker.mjs`) and cannot
|
|
130
|
+
* guess its bundled URL; a Vite host passes
|
|
131
|
+
* `import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?url"`.
|
|
132
|
+
*/
|
|
133
|
+
workerUrl?: string;
|
|
134
|
+
/** How to load MapLibre — swapped in tests, or for a host that preloads it. */
|
|
135
|
+
loadMapLibre?: () => Promise<MapLibreLike>;
|
|
136
|
+
/**
|
|
137
|
+
* How long the map may take to become ready (its style loaded) before it
|
|
138
|
+
* shows the error state with Retry. Default 15 000 ms. A style request that
|
|
139
|
+
* hangs fires no error, so without this the map would stay blank forever.
|
|
140
|
+
*/
|
|
141
|
+
readyTimeoutMs?: number;
|
|
142
|
+
}
|