@12-apps/routing 1.0.0 → 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 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
- - No WebGL, or a style that does not load, shows `copy.mapError` with a retry.
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.0.0",
3
+ "version": "1.1.0",
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.",
@@ -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
- const tail = span("", { width: "0", height: "0", borderLeft: "7px solid transparent", borderRight: "7px solid transparent", borderTop: `9px solid ${marker.color}` });
88
- element.append(bubble, tail);
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
- const tail = span("", { width: "0", height: "0", borderLeft: "7px solid transparent", borderRight: "7px solid transparent", borderTop: `9px solid ${theme.ink}` });
110
- element.append(bubble, tail);
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
- export function placeElement(spot: RouteMapPlace, theme: RouteMapTheme): HTMLElement {
138
- const element = 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" });
139
- if (spot.icon) element.appendChild(glyph(spot.icon));
140
- element.appendChild(document.createTextNode(spot.label));
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 {
@@ -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
- zoom={props.controls?.zoom ?? true}
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
- <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>
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, right: 12, display: "flex", flexDirection: "column", gap: 8, zIndex: 2 }}>
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
+ }
@@ -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
- controls?: { zoom?: boolean; fit?: boolean };
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
 
@@ -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
- out.push({ key: `p:${spot.id}`, signature: JSON.stringify([spot.label, spot.icon]), position: spot.position, anchor: "bottom", build: () => placeElement(spot, theme) });
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: { top: 40, bottom: 48, left: 40, right }, maxZoom: 16, duration: 0 });
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
  }