@12-apps/routing 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -1
- package/package.json +1 -1
- package/src/providers/openrouteservice.ts +2 -1
- package/src/react/map-elements.ts +75 -8
- package/src/react/maplibre-types.ts +7 -1
- package/src/react/route-map.tsx +36 -8
- package/src/react/tag-placement.ts +221 -0
- package/src/react/types.ts +43 -1
- package/src/react/use-map.ts +3 -0
- package/src/react/use-overlays.ts +79 -14
package/README.md
CHANGED
|
@@ -90,7 +90,36 @@ const { RouteMap } = createWebRouting({
|
|
|
90
90
|
fit control is pressed. A data refresh never moves a map someone panned.
|
|
91
91
|
- Markers that overlap on screen fold into one group button. `onGroupSelect`
|
|
92
92
|
receives their ids; without that callback, the map zooms in on them.
|
|
93
|
-
-
|
|
93
|
+
- `focus={{ key, points }}` fits to just those points (a selected trip)
|
|
94
|
+
whenever `key` changes — a viewer's selection, never a refresh.
|
|
95
|
+
- `insets={{ bottom: legendHeight }}` tells the fit what the host's own
|
|
96
|
+
overlays cover, so fitted pins never land under a legend or a sheet.
|
|
97
|
+
- For a small map (a card): `placeLabels="at-point"` draws a place's label on
|
|
98
|
+
its point instead of lifted above a pin, and `attribution="compact"` folds
|
|
99
|
+
the basemap credit into an "i" that opens it.
|
|
100
|
+
- A stop with `emphasized: true` (the one the screen is about) is drawn above
|
|
101
|
+
the pins; other stops sit beneath them.
|
|
102
|
+
- A marker's (or group's) tag — its pill and tail — steps aside from stop
|
|
103
|
+
badges and place labels (`tagPlacement="avoid"`, the default). After every
|
|
104
|
+
draw, fit, pan and zoom it keeps its current side while that is clear;
|
|
105
|
+
otherwise it takes the first clear side of its pin in the order above,
|
|
106
|
+
below, right, left (a side other than the current one must be clear by a
|
|
107
|
+
few pixels, so a jittering GPS fix never flips it), and when every side is
|
|
108
|
+
blocked, the least-covering one. A stop or place under the pin's own point
|
|
109
|
+
(a courier arriving) does not count. Stops, places and the pin's point never
|
|
110
|
+
move; the tail follows the side, and the element is restyled in place, so
|
|
111
|
+
keyboard focus survives. `tagPlacement="fixed"` keeps every tag above, as
|
|
112
|
+
before this release.
|
|
113
|
+
- **DOM change:** every marker and group element now carries
|
|
114
|
+
`data-tag-side="above|below|right|left"` (in both modes), so a host's DOM
|
|
115
|
+
snapshot of the map gains that attribute. A host's own `loadMapLibre` fake
|
|
116
|
+
needs `Marker#setOffset` for a tag to move.
|
|
117
|
+
- `controls={{ placement: "top-left" }}` moves the zoom and fit column to the
|
|
118
|
+
other top corner when the host's floating chrome covers the top-right.
|
|
119
|
+
- No WebGL, a library or style that does not arrive within `readyTimeoutMs`
|
|
120
|
+
(default 15 s), shows `copy.mapError` with a retry. The region's
|
|
121
|
+
`data-state` reads `loading`, `ready` or `error`. The host's `overlay` is
|
|
122
|
+
hidden while the error shows — the error panel carries its own retry.
|
|
94
123
|
|
|
95
124
|
## Wiring
|
|
96
125
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/routing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
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.",
|
|
@@ -19,7 +19,8 @@ export interface OpenRouteServiceOptions {
|
|
|
19
19
|
baseUrl?: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/** `api.openrouteservice.org` is deprecated in favour of this host. */
|
|
23
|
+
const DEFAULT_BASE_URL = "https://api.heigit.org/openrouteservice";
|
|
23
24
|
|
|
24
25
|
interface OrsSegment {
|
|
25
26
|
distance?: unknown;
|
|
@@ -51,6 +51,58 @@ function span(text: string, style: Partial<CSSStyleDeclaration>): HTMLSpanElemen
|
|
|
51
51
|
return element;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** Which side of its pin a marker's tag (pill and tail) is drawn on. */
|
|
55
|
+
export type TagSide = "above" | "below" | "right" | "left";
|
|
56
|
+
|
|
57
|
+
/** How far the tail reaches from the pill to the pin. */
|
|
58
|
+
export const TAG_TAIL_PX = 9;
|
|
59
|
+
|
|
60
|
+
/** Each tail's colour, so a side change can redraw it pointing elsewhere. */
|
|
61
|
+
const tailColours = new WeakMap<HTMLElement, string>();
|
|
62
|
+
|
|
63
|
+
/** The tail under a pill, pointing down at the pin (the `above` side). */
|
|
64
|
+
function tailElement(colour: string): HTMLElement {
|
|
65
|
+
const tail = span("", { width: "0", height: "0", borderLeft: "7px solid transparent", borderRight: "7px solid transparent", borderTop: `${TAG_TAIL_PX}px solid ${colour}` });
|
|
66
|
+
tailColours.set(tail, colour);
|
|
67
|
+
return tail;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Per side: how the frame stacks pill and tail (the tail always at the edge
|
|
72
|
+
* that meets the pin), and which tail border is filled (the triangle points
|
|
73
|
+
* the opposite way) versus transparent (its two flanks).
|
|
74
|
+
*/
|
|
75
|
+
const SIDE_LAYOUT: Record<TagSide, { flexDirection: string; filled: Edge; flanks: readonly Edge[] }> = {
|
|
76
|
+
above: { flexDirection: "column", filled: "top", flanks: ["left", "right"] },
|
|
77
|
+
below: { flexDirection: "column-reverse", filled: "bottom", flanks: ["left", "right"] },
|
|
78
|
+
right: { flexDirection: "row-reverse", filled: "right", flanks: ["top", "bottom"] },
|
|
79
|
+
left: { flexDirection: "row", filled: "left", flanks: ["top", "bottom"] },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type Edge = "top" | "bottom" | "left" | "right";
|
|
83
|
+
|
|
84
|
+
function tailBorder(edge: Edge, side: TagSide, colour: string): string {
|
|
85
|
+
const layout = SIDE_LAYOUT[side];
|
|
86
|
+
if (edge === layout.filled) return `${TAG_TAIL_PX}px solid ${colour}`;
|
|
87
|
+
return layout.flanks.includes(edge) ? "7px solid transparent" : "0";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Lay a marker's (or group's) tag out on `side` of its pin, in place — the
|
|
92
|
+
* element is never rebuilt, so keyboard focus survives. The caller re-offsets
|
|
93
|
+
* the MapLibre marker so the tail's tip stays on the point. `false` when the
|
|
94
|
+
* element is not a tag this module built (nothing changed).
|
|
95
|
+
*/
|
|
96
|
+
export function setTagSide(element: HTMLElement, side: TagSide): boolean {
|
|
97
|
+
const tail = element.children[1] as HTMLElement | undefined;
|
|
98
|
+
const colour = tail ? tailColours.get(tail) : undefined;
|
|
99
|
+
if (!tail || colour === undefined) return false;
|
|
100
|
+
element.style.flexDirection = SIDE_LAYOUT[side].flexDirection;
|
|
101
|
+
for (const edge of ["top", "bottom", "left", "right"] as const) tail.style.setProperty(`border-${edge}`, tailBorder(edge, side, colour));
|
|
102
|
+
element.dataset.tagSide = side;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
54
106
|
/**
|
|
55
107
|
* A marker the viewer can act on is a `<button>` with `aria-pressed` (it
|
|
56
108
|
* selects); one they can only read is an `img` with a label — never an empty
|
|
@@ -84,8 +136,8 @@ export function markerElement(marker: RouteMapMarker, theme: RouteMapTheme, onSe
|
|
|
84
136
|
});
|
|
85
137
|
if (marker.icon) bubble.appendChild(glyph(marker.icon));
|
|
86
138
|
bubble.appendChild(document.createTextNode(marker.text));
|
|
87
|
-
|
|
88
|
-
element.
|
|
139
|
+
element.append(bubble, tailElement(marker.color));
|
|
140
|
+
element.dataset.tagSide = "above";
|
|
89
141
|
return element;
|
|
90
142
|
}
|
|
91
143
|
|
|
@@ -106,8 +158,8 @@ export function groupElement(count: number, copy: RouteMapCopy, theme: RouteMapT
|
|
|
106
158
|
border: `2px solid ${theme.paper}`,
|
|
107
159
|
boxShadow: SHADOW,
|
|
108
160
|
});
|
|
109
|
-
|
|
110
|
-
element.
|
|
161
|
+
element.append(bubble, tailElement(theme.ink));
|
|
162
|
+
element.dataset.tagSide = "above";
|
|
111
163
|
return element;
|
|
112
164
|
}
|
|
113
165
|
|
|
@@ -127,6 +179,8 @@ export function stopElement(stop: RouteMapStop, theme: RouteMapTheme): HTMLEleme
|
|
|
127
179
|
color: stop.variant === "pending" ? theme.ink : theme.paper,
|
|
128
180
|
border: `2px solid ${stop.variant === "done" ? theme.paper : theme.ink}`,
|
|
129
181
|
boxShadow: SHADOW,
|
|
182
|
+
// Pins are z 2–3; an emphasised stop sits above them, the rest beneath.
|
|
183
|
+
zIndex: stop.emphasized ? "4" : "1",
|
|
130
184
|
});
|
|
131
185
|
element.setAttribute("role", "img");
|
|
132
186
|
element.setAttribute("aria-label", stop.title);
|
|
@@ -134,9 +188,22 @@ export function stopElement(stop: RouteMapStop, theme: RouteMapTheme): HTMLEleme
|
|
|
134
188
|
return element;
|
|
135
189
|
}
|
|
136
190
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
191
|
+
/** How far above its point a place's label sits: label (~26) + stem (46) + dot (8). */
|
|
192
|
+
export const PLACE_LIFT = 80;
|
|
193
|
+
|
|
194
|
+
export function placeElement(spot: RouteMapPlace, theme: RouteMapTheme, lifted = true): HTMLElement {
|
|
195
|
+
// The label rides ABOVE a pin-high stem, so a courier standing at the place
|
|
196
|
+
// (a pin anchored on the same point) never covers it. It stays UNDER the
|
|
197
|
+
// pins (z 1 against their 2–3), so the stem never crosses a pin's name, and
|
|
198
|
+
// under any host overlay; clicks pass through to whatever is beneath.
|
|
199
|
+
const element = span("", { display: "flex", flexDirection: "column", alignItems: "center", pointerEvents: "none", zIndex: "1" });
|
|
200
|
+
const label = span("", { display: "inline-flex", alignItems: "center", gap: "6px", background: theme.place, color: theme.paper, padding: "4px 10px", borderRadius: "10px", fontSize: "13px", fontWeight: "600", whiteSpace: "nowrap" });
|
|
201
|
+
if (spot.icon) label.appendChild(glyph(spot.icon));
|
|
202
|
+
label.appendChild(document.createTextNode(spot.label));
|
|
203
|
+
if (!lifted) {
|
|
204
|
+
element.append(label);
|
|
205
|
+
return element;
|
|
206
|
+
}
|
|
207
|
+
element.append(label, span("", { width: "2px", height: "46px", background: theme.place }), span("", { width: "8px", height: "8px", borderRadius: "50%", background: theme.place }));
|
|
141
208
|
return element;
|
|
142
209
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export interface MapLike {
|
|
8
|
-
on(event: "load" | "style.load" | "error" | "moveend", listener: () => void): unknown;
|
|
8
|
+
on(event: "load" | "style.load" | "error" | "moveend" | "zoomend", listener: () => void): unknown;
|
|
9
9
|
remove(): void;
|
|
10
10
|
addSource(id: string, source: { type: "geojson"; data: unknown }): unknown;
|
|
11
11
|
getSource(id: string): { setData(data: unknown): unknown } | undefined;
|
|
@@ -21,6 +21,12 @@ export interface MarkerLike {
|
|
|
21
21
|
setLngLat(lngLat: [number, number]): MarkerLike;
|
|
22
22
|
addTo(map: MapLike): MarkerLike;
|
|
23
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;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
export interface MapLibreLike {
|
package/src/react/route-map.tsx
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* - **Failure is visible, not blank**: the host's error copy and a retry.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { useRef, type CSSProperties, type JSX } from "react";
|
|
16
|
+
import { useEffect, useRef, useState, type CSSProperties, type JSX } from "react";
|
|
17
17
|
|
|
18
18
|
import type { RouteMapCopy } from "./copy";
|
|
19
19
|
import type { MapLibreLike } from "./maplibre-types";
|
|
@@ -55,9 +55,10 @@ export function buildRouteMap(config: RouteMapConfig): (props: RouteMapProps) =>
|
|
|
55
55
|
container,
|
|
56
56
|
propsRef,
|
|
57
57
|
onMoveEnd: () => overlaysRef.current?.draw(),
|
|
58
|
+
onZoomEnd: () => overlaysRef.current?.place(),
|
|
58
59
|
onDispose: () => overlaysRef.current?.clear(),
|
|
59
60
|
});
|
|
60
|
-
const overlays = useOverlays(handle, propsRef, copy, theme);
|
|
61
|
+
const overlays = useOverlays({ ...handle, container }, propsRef, copy, theme);
|
|
61
62
|
overlaysRef.current = overlays;
|
|
62
63
|
useOverlaySync(handle.status === "ready", handle, props, overlays, theme);
|
|
63
64
|
|
|
@@ -71,8 +72,7 @@ export function buildRouteMap(config: RouteMapConfig): (props: RouteMapProps) =>
|
|
|
71
72
|
<MapControls
|
|
72
73
|
copy={copy}
|
|
73
74
|
theme={theme}
|
|
74
|
-
|
|
75
|
-
fit={props.controls?.fit ?? true}
|
|
75
|
+
{...controlsOf(props)}
|
|
76
76
|
onZoomIn={() => handle.mapRef.current?.zoomIn()}
|
|
77
77
|
onZoomOut={() => handle.mapRef.current?.zoomOut()}
|
|
78
78
|
onFit={() => {
|
|
@@ -81,27 +81,55 @@ export function buildRouteMap(config: RouteMapConfig): (props: RouteMapProps) =>
|
|
|
81
81
|
}}
|
|
82
82
|
/>
|
|
83
83
|
)}
|
|
84
|
-
{props.overlay ? <div style={{ position: "absolute", left: 12, right: 12, bottom: 28, zIndex: 2 }}>{props.overlay}</div> : null}
|
|
85
|
-
<
|
|
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
86
|
</div>
|
|
87
87
|
);
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
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
|
+
|
|
91
117
|
interface ControlsProps {
|
|
92
118
|
copy: RouteMapCopy;
|
|
93
119
|
theme: RouteMapTheme;
|
|
94
120
|
zoom: boolean;
|
|
95
121
|
fit: boolean;
|
|
122
|
+
placement: "top-right" | "top-left";
|
|
96
123
|
onZoomIn: () => void;
|
|
97
124
|
onZoomOut: () => void;
|
|
98
125
|
onFit: () => void;
|
|
99
126
|
}
|
|
100
127
|
|
|
101
|
-
function MapControls({ copy, theme, zoom, fit, onZoomIn, onZoomOut, onFit }: ControlsProps): JSX.Element | null {
|
|
128
|
+
function MapControls({ copy, theme, zoom, fit, placement, onZoomIn, onZoomOut, onFit }: ControlsProps): JSX.Element | null {
|
|
102
129
|
if (!zoom && !fit) return null;
|
|
130
|
+
const corner = placement === "top-left" ? { left: 12 } : { right: 12 };
|
|
103
131
|
return (
|
|
104
|
-
<div style={{ position: "absolute", top: 12,
|
|
132
|
+
<div style={{ position: "absolute", top: 12, ...corner, display: "flex", flexDirection: "column", gap: 8, zIndex: 2 }}>
|
|
105
133
|
{zoom ? (
|
|
106
134
|
<>
|
|
107
135
|
<button type="button" className="routing-zoom" aria-label={copy.zoomIn} onClick={onZoomIn} style={controlStyle(theme)}>
|
|
@@ -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
|
+
}
|
package/src/react/types.ts
CHANGED
|
@@ -29,6 +29,11 @@ export interface RouteMapStop {
|
|
|
29
29
|
/** Accessible title, e.g. "Parada 2 — Av. Vilarinho, 1731". */
|
|
30
30
|
title: string;
|
|
31
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;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export interface RouteMapPlace {
|
|
@@ -65,15 +70,52 @@ export interface RouteMapProps {
|
|
|
65
70
|
travelledColor?: string;
|
|
66
71
|
/** Change it to refit the viewport to everything drawn. */
|
|
67
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[] };
|
|
68
79
|
/** CSS height of the map area. */
|
|
69
80
|
height: number | string;
|
|
70
|
-
|
|
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" };
|
|
71
86
|
/** Called when the viewer presses the fit control (after it fits). */
|
|
72
87
|
onFitAll?: () => void;
|
|
73
88
|
/** Called with the ids of a pressed group of overlapping markers. */
|
|
74
89
|
onGroupSelect?: (markerIds: string[]) => void;
|
|
75
90
|
/** Rendered over the map (a legend), bottom edge. */
|
|
76
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";
|
|
77
119
|
testId?: string;
|
|
78
120
|
}
|
|
79
121
|
|
package/src/react/use-map.ts
CHANGED
|
@@ -33,6 +33,8 @@ interface MapSetup {
|
|
|
33
33
|
propsRef: MutableRefObject<RouteMapProps>;
|
|
34
34
|
/** Called after each pan or zoom, to regroup markers. */
|
|
35
35
|
onMoveEnd: () => void;
|
|
36
|
+
/** Called after each zoom, to re-place the markers' tags. */
|
|
37
|
+
onZoomEnd: () => void;
|
|
36
38
|
/** Called before the map goes away, to drop markers. */
|
|
37
39
|
onDispose: () => void;
|
|
38
40
|
}
|
|
@@ -87,6 +89,7 @@ export function useMapInstance(setup: MapSetup): MapHandle {
|
|
|
87
89
|
map.on("style.load", onReady);
|
|
88
90
|
map.on("load", onReady);
|
|
89
91
|
map.on("moveend", () => setupRef.current.onMoveEnd());
|
|
92
|
+
map.on("zoomend", () => setupRef.current.onZoomEnd());
|
|
90
93
|
})
|
|
91
94
|
.catch(() => {
|
|
92
95
|
if (!disposed) setStatus("error");
|
|
@@ -13,27 +13,38 @@
|
|
|
13
13
|
* array never drops the keyboard focus a viewer put on a marker. Click
|
|
14
14
|
* handlers read the host's CURRENT callback by key, so a kept element never
|
|
15
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.
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
|
-
import { useEffect, useRef, type MutableRefObject } from "react";
|
|
25
|
+
import { useEffect, useRef, type MutableRefObject, type RefObject } from "react";
|
|
19
26
|
|
|
20
27
|
import { isValidPoint } from "../core/geo";
|
|
21
28
|
import type { LngLat } from "../core/types";
|
|
22
29
|
|
|
23
30
|
import type { RouteMapCopy } from "./copy";
|
|
24
|
-
import { groupElement, markerElement, placeElement, stopElement } from "./map-elements";
|
|
31
|
+
import { groupElement, markerElement, PLACE_LIFT, placeElement, stopElement } from "./map-elements";
|
|
25
32
|
import { boundsOf, groupMarkers, PLANNED_LAYER, pointsOf, setLine, TRAVELLED_LAYER } from "./map-geometry";
|
|
26
33
|
import type { MarkerLike } from "./maplibre-types";
|
|
34
|
+
import { placeTags, type Placed } from "./tag-placement";
|
|
27
35
|
import type { MapHandle } from "./use-map";
|
|
28
36
|
import type { RouteMapMarker, RouteMapProps, RouteMapTheme } from "./types";
|
|
29
37
|
|
|
30
38
|
interface Overlays {
|
|
31
39
|
draw: () => void;
|
|
40
|
+
/** Re-place the markers' tags around the stops and places, as drawn now. */
|
|
41
|
+
place: () => void;
|
|
32
42
|
clear: () => void;
|
|
33
43
|
fitAll: () => void;
|
|
44
|
+
fitTo: (points: readonly LngLat[]) => void;
|
|
34
45
|
}
|
|
35
46
|
|
|
36
|
-
interface Drawn {
|
|
47
|
+
interface Drawn extends Placed {
|
|
37
48
|
marker: MarkerLike;
|
|
38
49
|
element: HTMLElement;
|
|
39
50
|
signature: string;
|
|
@@ -44,11 +55,12 @@ interface Wanted {
|
|
|
44
55
|
signature: string;
|
|
45
56
|
position: LngLat;
|
|
46
57
|
anchor: "bottom" | "center";
|
|
58
|
+
role: Placed["role"];
|
|
47
59
|
build: () => HTMLElement;
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
export function useOverlays(
|
|
51
|
-
handle: Pick<MapHandle, "mapRef" | "libRef"
|
|
63
|
+
handle: Pick<MapHandle, "mapRef" | "libRef"> & { container: RefObject<HTMLElement | null> },
|
|
52
64
|
propsRef: MutableRefObject<RouteMapProps>,
|
|
53
65
|
copy: RouteMapCopy,
|
|
54
66
|
theme: RouteMapTheme,
|
|
@@ -75,10 +87,11 @@ export function useOverlays(
|
|
|
75
87
|
actions.current.clear();
|
|
76
88
|
const out: Wanted[] = [];
|
|
77
89
|
for (const spot of (props.places ?? []).filter((item) => isValidPoint(item.position))) {
|
|
78
|
-
|
|
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) });
|
|
79
92
|
}
|
|
80
93
|
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) });
|
|
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) });
|
|
82
95
|
}
|
|
83
96
|
const valid = (props.markers ?? []).filter((marker) => isValidPoint(marker.position));
|
|
84
97
|
for (const group of groupMarkers(valid, map)) out.push(group.length === 1 ? markerWanted(group[0]!) : groupWanted(group));
|
|
@@ -90,31 +103,77 @@ export function useOverlays(
|
|
|
90
103
|
if (marker.onSelect) actions.current.set(key, marker.onSelect);
|
|
91
104
|
const act = marker.onSelect ? () => actions.current.get(key)?.() : null;
|
|
92
105
|
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) };
|
|
106
|
+
return { key, signature, position: marker.position, anchor: "bottom", role: "tag", build: () => markerElement(marker, theme, act) };
|
|
94
107
|
};
|
|
95
108
|
|
|
96
109
|
const groupWanted = (group: RouteMapMarker[]): Wanted => {
|
|
97
110
|
const key = `g:${group.map((marker) => marker.id).join(",")}`;
|
|
98
111
|
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)?.()) };
|
|
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)?.()) };
|
|
100
113
|
};
|
|
101
114
|
|
|
102
115
|
const draw = (): void => {
|
|
103
116
|
const lib = handle.libRef.current;
|
|
104
117
|
const map = handle.mapRef.current;
|
|
105
118
|
if (lib && map) reconcile(drawn.current, wanted(propsRef.current), (element, anchor, at) => new lib.Marker({ element, anchor }).setLngLat(at).addTo(map));
|
|
119
|
+
place();
|
|
106
120
|
};
|
|
107
121
|
|
|
122
|
+
const place = (): void => placeAll(handle, drawn.current, propsRef.current.tagPlacement);
|
|
123
|
+
|
|
108
124
|
const fitAll = (): void => {
|
|
109
125
|
// The control column sits on the right edge: keep fitted content clear of
|
|
110
126
|
// 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
127
|
const bounds = boundsOf(pointsOf(propsRef.current));
|
|
114
|
-
if (bounds) handle.mapRef.current?.fitBounds(bounds, { padding:
|
|
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 });
|
|
115
136
|
};
|
|
116
137
|
|
|
117
|
-
return { draw, clear, fitAll };
|
|
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;
|
|
118
177
|
}
|
|
119
178
|
|
|
120
179
|
type Place = (element: HTMLElement, anchor: Wanted["anchor"], at: [number, number]) => MarkerLike;
|
|
@@ -132,12 +191,13 @@ function reconcile(drawn: Map<string, Drawn>, next: readonly Wanted[], place: Pl
|
|
|
132
191
|
const current = drawn.get(item.key);
|
|
133
192
|
if (current?.signature === item.signature) {
|
|
134
193
|
current.marker.setLngLat(at);
|
|
194
|
+
current.at = at;
|
|
135
195
|
continue;
|
|
136
196
|
}
|
|
137
197
|
const hadFocus = !!current && current.element.contains(document.activeElement);
|
|
138
198
|
current?.marker.remove();
|
|
139
199
|
const element = item.build();
|
|
140
|
-
drawn.set(item.key, { marker: place(element, item.anchor, at), element, signature: item.signature });
|
|
200
|
+
drawn.set(item.key, { marker: place(element, item.anchor, at), element, signature: item.signature, at, anchor: item.anchor, role: item.role });
|
|
141
201
|
if (hadFocus) element.focus();
|
|
142
202
|
}
|
|
143
203
|
}
|
|
@@ -154,9 +214,14 @@ export function useOverlaySync(ready: boolean, handle: Pick<MapHandle, "mapRef">
|
|
|
154
214
|
|
|
155
215
|
useEffect(() => {
|
|
156
216
|
if (ready) overlays.draw();
|
|
157
|
-
}, [ready, props.markers, props.stops, props.places]);
|
|
217
|
+
}, [ready, props.markers, props.stops, props.places, props.placeLabels, props.tagPlacement]);
|
|
158
218
|
|
|
159
219
|
useEffect(() => {
|
|
160
220
|
if (ready) overlays.fitAll();
|
|
161
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]);
|
|
162
227
|
}
|