@elabs-ai/components-maps 4.2.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +1 -1
  2. package/dist/chunk-2FPB53NG.js +36 -0
  3. package/dist/chunk-2FPB53NG.js.map +1 -0
  4. package/dist/chunk-2VVJRT66.js +37 -0
  5. package/dist/chunk-2VVJRT66.js.map +1 -0
  6. package/dist/chunk-3HXFONRP.js +2 -0
  7. package/dist/chunk-3HXFONRP.js.map +1 -0
  8. package/dist/chunk-3SJN3IYT.js +2 -0
  9. package/dist/chunk-3SJN3IYT.js.map +1 -0
  10. package/dist/chunk-4DANBD5B.js +55 -0
  11. package/dist/chunk-4DANBD5B.js.map +1 -0
  12. package/dist/chunk-5PDVG53B.js +2 -0
  13. package/dist/chunk-5PDVG53B.js.map +1 -0
  14. package/dist/chunk-643R7IJC.js +2 -0
  15. package/dist/chunk-643R7IJC.js.map +1 -0
  16. package/dist/chunk-B3ZDBQ6I.js +164 -0
  17. package/dist/chunk-B3ZDBQ6I.js.map +1 -0
  18. package/dist/chunk-BGCE3RR5.js +35 -0
  19. package/dist/chunk-BGCE3RR5.js.map +1 -0
  20. package/dist/chunk-C4VI37TD.js +485 -0
  21. package/dist/chunk-C4VI37TD.js.map +1 -0
  22. package/dist/chunk-CJJT6AEI.js +179 -0
  23. package/dist/chunk-CJJT6AEI.js.map +1 -0
  24. package/dist/chunk-D2RCPTRO.js +2 -0
  25. package/dist/chunk-D2RCPTRO.js.map +1 -0
  26. package/dist/chunk-DTV7JZVN.js +2 -0
  27. package/dist/chunk-DTV7JZVN.js.map +1 -0
  28. package/dist/chunk-E65Z3MSV.js +80 -0
  29. package/dist/chunk-E65Z3MSV.js.map +1 -0
  30. package/dist/chunk-E7VPNINZ.js +70 -0
  31. package/dist/chunk-E7VPNINZ.js.map +1 -0
  32. package/dist/chunk-FHV5IA6N.js +100 -0
  33. package/dist/chunk-FHV5IA6N.js.map +1 -0
  34. package/dist/chunk-FQ6V7BFZ.js +347 -0
  35. package/dist/chunk-FQ6V7BFZ.js.map +1 -0
  36. package/dist/chunk-G7JDXDJN.js +19 -0
  37. package/dist/chunk-G7JDXDJN.js.map +1 -0
  38. package/dist/chunk-GWULZOMW.js +21 -0
  39. package/dist/chunk-GWULZOMW.js.map +1 -0
  40. package/dist/chunk-GYDTTAVD.js +120 -0
  41. package/dist/chunk-GYDTTAVD.js.map +1 -0
  42. package/dist/chunk-HCOBWIM4.js +48 -0
  43. package/dist/chunk-HCOBWIM4.js.map +1 -0
  44. package/dist/chunk-IAHK5MVT.js +2 -0
  45. package/dist/chunk-IAHK5MVT.js.map +1 -0
  46. package/dist/chunk-JGJ6442I.js +235 -0
  47. package/dist/chunk-JGJ6442I.js.map +1 -0
  48. package/dist/chunk-JYFERXZT.js +18 -0
  49. package/dist/chunk-JYFERXZT.js.map +1 -0
  50. package/dist/chunk-LU2UEKFM.js +53 -0
  51. package/dist/chunk-LU2UEKFM.js.map +1 -0
  52. package/dist/chunk-LVYM6C5C.js +2 -0
  53. package/dist/chunk-LVYM6C5C.js.map +1 -0
  54. package/dist/chunk-MIMDP3EU.js +104 -0
  55. package/dist/chunk-MIMDP3EU.js.map +1 -0
  56. package/dist/chunk-NNBURJHY.js +2 -0
  57. package/dist/chunk-NNBURJHY.js.map +1 -0
  58. package/dist/chunk-NPUC3V37.js +2 -0
  59. package/dist/chunk-NPUC3V37.js.map +1 -0
  60. package/dist/chunk-OEVVNPN3.js +201 -0
  61. package/dist/chunk-OEVVNPN3.js.map +1 -0
  62. package/dist/chunk-PF3R2BXM.js +200 -0
  63. package/dist/chunk-PF3R2BXM.js.map +1 -0
  64. package/dist/chunk-Q2I5YODW.js +2 -0
  65. package/dist/chunk-Q2I5YODW.js.map +1 -0
  66. package/dist/chunk-QDWWP3N7.js +247 -0
  67. package/dist/chunk-QDWWP3N7.js.map +1 -0
  68. package/dist/chunk-QU33KPMP.js +2 -0
  69. package/dist/chunk-QU33KPMP.js.map +1 -0
  70. package/dist/chunk-RK4BUMHS.js +2 -0
  71. package/dist/chunk-RK4BUMHS.js.map +1 -0
  72. package/dist/chunk-RNSLG64F.js +26 -0
  73. package/dist/chunk-RNSLG64F.js.map +1 -0
  74. package/dist/chunk-RV673TB4.js +68 -0
  75. package/dist/chunk-RV673TB4.js.map +1 -0
  76. package/dist/chunk-S7N2WMVT.js +180 -0
  77. package/dist/chunk-S7N2WMVT.js.map +1 -0
  78. package/dist/chunk-T3Z6TCDD.js +2 -0
  79. package/dist/chunk-T3Z6TCDD.js.map +1 -0
  80. package/dist/chunk-T4TJTVVA.js +105 -0
  81. package/dist/chunk-T4TJTVVA.js.map +1 -0
  82. package/dist/chunk-UMOYRCDX.js +103 -0
  83. package/dist/chunk-UMOYRCDX.js.map +1 -0
  84. package/dist/chunk-V2REM6UT.js +185 -0
  85. package/dist/chunk-V2REM6UT.js.map +1 -0
  86. package/dist/chunk-VFBRFGID.js +89 -0
  87. package/dist/chunk-VFBRFGID.js.map +1 -0
  88. package/dist/chunk-W76NLFER.js +103 -0
  89. package/dist/chunk-W76NLFER.js.map +1 -0
  90. package/dist/chunk-WNU4IOGL.js +116 -0
  91. package/dist/chunk-WNU4IOGL.js.map +1 -0
  92. package/dist/chunk-WS4Z7ENS.js +494 -0
  93. package/dist/chunk-WS4Z7ENS.js.map +1 -0
  94. package/dist/chunk-XQT7YLJY.js +497 -0
  95. package/dist/chunk-XQT7YLJY.js.map +1 -0
  96. package/dist/chunk-Y44MEKJV.js +21 -0
  97. package/dist/chunk-Y44MEKJV.js.map +1 -0
  98. package/dist/chunk-Z4EPSOB4.js +53 -0
  99. package/dist/chunk-Z4EPSOB4.js.map +1 -0
  100. package/dist/chunk-Z5OPDBDC.js +2 -0
  101. package/dist/chunk-Z5OPDBDC.js.map +1 -0
  102. package/dist/index.d.ts +1241 -46
  103. package/dist/index.js +189 -1471
  104. package/dist/index.js.map +1 -1
  105. package/dist/lib/arc-math.js +8 -0
  106. package/dist/lib/arc-math.js.map +1 -0
  107. package/dist/lib/map-position.js +11 -0
  108. package/dist/lib/map-position.js.map +1 -0
  109. package/dist/lib/merge-hover-paint.js +10 -0
  110. package/dist/lib/merge-hover-paint.js.map +1 -0
  111. package/dist/lib/plan-crs.js +14 -0
  112. package/dist/lib/plan-crs.js.map +1 -0
  113. package/dist/lib/plan-patterns.js +14 -0
  114. package/dist/lib/plan-patterns.js.map +1 -0
  115. package/dist/lib/plan-regions.js +18 -0
  116. package/dist/lib/plan-regions.js.map +1 -0
  117. package/dist/lib/plan-status.js +14 -0
  118. package/dist/lib/plan-status.js.map +1 -0
  119. package/dist/lib/route-arrow.js +12 -0
  120. package/dist/lib/route-arrow.js.map +1 -0
  121. package/dist/lib/use-map-breakpoint.js +33 -0
  122. package/dist/lib/use-map-breakpoint.js.map +1 -0
  123. package/dist/lib/use-plan-projection.js +9 -0
  124. package/dist/lib/use-plan-projection.js.map +1 -0
  125. package/dist/lib/use-token-color.js +10 -0
  126. package/dist/lib/use-token-color.js.map +1 -0
  127. package/dist/lib/warn-once.js +10 -0
  128. package/dist/lib/warn-once.js.map +1 -0
  129. package/dist/map-annotation/anchor.js +14 -0
  130. package/dist/map-annotation/anchor.js.map +1 -0
  131. package/dist/map-annotation/index.js +21 -0
  132. package/dist/map-annotation/index.js.map +1 -0
  133. package/dist/map-annotation/map-annotation.js +18 -0
  134. package/dist/map-annotation/map-annotation.js.map +1 -0
  135. package/dist/map-arc/index.js +16 -0
  136. package/dist/map-arc/index.js.map +1 -0
  137. package/dist/map-arc/map-arc.js +13 -0
  138. package/dist/map-arc/map-arc.js.map +1 -0
  139. package/dist/map-canvas/index.css +11 -0
  140. package/dist/map-canvas/index.css.map +1 -0
  141. package/dist/map-canvas/index.js +20 -0
  142. package/dist/map-canvas/index.js.map +1 -0
  143. package/dist/map-canvas/map-canvas.css +11 -0
  144. package/dist/map-canvas/map-canvas.css.map +1 -0
  145. package/dist/map-canvas/map-canvas.js +17 -0
  146. package/dist/map-canvas/map-canvas.js.map +1 -0
  147. package/dist/map-canvas/map-context.js +11 -0
  148. package/dist/map-canvas/map-context.js.map +1 -0
  149. package/dist/map-canvas/use-resolved-basemap-theme.js +9 -0
  150. package/dist/map-canvas/use-resolved-basemap-theme.js.map +1 -0
  151. package/dist/map-cluster-layer/index.js +12 -0
  152. package/dist/map-cluster-layer/index.js.map +1 -0
  153. package/dist/map-cluster-layer/map-cluster-layer.js +12 -0
  154. package/dist/map-cluster-layer/map-cluster-layer.js.map +1 -0
  155. package/dist/map-controls/index.js +11 -0
  156. package/dist/map-controls/index.js.map +1 -0
  157. package/dist/map-controls/map-controls.js +11 -0
  158. package/dist/map-controls/map-controls.js.map +1 -0
  159. package/dist/map-geojson/index.js +13 -0
  160. package/dist/map-geojson/index.js.map +1 -0
  161. package/dist/map-geojson/map-geojson.js +13 -0
  162. package/dist/map-geojson/map-geojson.js.map +1 -0
  163. package/dist/map-inset/index.css +11 -0
  164. package/dist/map-inset/index.css.map +1 -0
  165. package/dist/map-inset/index.js +20 -0
  166. package/dist/map-inset/index.js.map +1 -0
  167. package/dist/map-inset/map-inset.css +11 -0
  168. package/dist/map-inset/map-inset.css.map +1 -0
  169. package/dist/map-inset/map-inset.js +20 -0
  170. package/dist/map-inset/map-inset.js.map +1 -0
  171. package/dist/map-legend/index.js +14 -0
  172. package/dist/map-legend/index.js.map +1 -0
  173. package/dist/map-legend/map-corner.js +10 -0
  174. package/dist/map-legend/map-corner.js.map +1 -0
  175. package/dist/map-legend/map-legend.js +11 -0
  176. package/dist/map-legend/map-legend.js.map +1 -0
  177. package/dist/map-marker/index.js +22 -0
  178. package/dist/map-marker/index.js.map +1 -0
  179. package/dist/map-marker/map-marker.js +22 -0
  180. package/dist/map-marker/map-marker.js.map +1 -0
  181. package/dist/map-north-arrow/index.js +11 -0
  182. package/dist/map-north-arrow/index.js.map +1 -0
  183. package/dist/map-north-arrow/map-north-arrow.js +11 -0
  184. package/dist/map-north-arrow/map-north-arrow.js.map +1 -0
  185. package/dist/map-plan/index.js +30 -0
  186. package/dist/map-plan/index.js.map +1 -0
  187. package/dist/map-plan/map-plan-legend.js +10 -0
  188. package/dist/map-plan/map-plan-legend.js.map +1 -0
  189. package/dist/map-plan/map-plan-overlay.js +13 -0
  190. package/dist/map-plan/map-plan-overlay.js.map +1 -0
  191. package/dist/map-plan/map-plan-status.js +9 -0
  192. package/dist/map-plan/map-plan-status.js.map +1 -0
  193. package/dist/map-plan/map-plan-table.js +10 -0
  194. package/dist/map-plan/map-plan-table.js.map +1 -0
  195. package/dist/map-plan/use-plan-selection.js +9 -0
  196. package/dist/map-plan/use-plan-selection.js.map +1 -0
  197. package/dist/map-plan-image/index.js +11 -0
  198. package/dist/map-plan-image/index.js.map +1 -0
  199. package/dist/map-plan-image/map-plan-image.js +11 -0
  200. package/dist/map-plan-image/map-plan-image.js.map +1 -0
  201. package/dist/map-popup/index.js +12 -0
  202. package/dist/map-popup/index.js.map +1 -0
  203. package/dist/map-popup/map-popup.js +12 -0
  204. package/dist/map-popup/map-popup.js.map +1 -0
  205. package/dist/map-route/index.js +12 -0
  206. package/dist/map-route/index.js.map +1 -0
  207. package/dist/map-route/map-route.js +12 -0
  208. package/dist/map-route/map-route.js.map +1 -0
  209. package/dist/map-scale-bar/index.js +18 -0
  210. package/dist/map-scale-bar/index.js.map +1 -0
  211. package/dist/map-scale-bar/map-scale-bar.js +18 -0
  212. package/dist/map-scale-bar/map-scale-bar.js.map +1 -0
  213. package/package.json +5 -5
  214. package/src/index.ts +83 -0
  215. package/src/lib/arc-math.test.ts +19 -0
  216. package/src/lib/arc-math.ts +6 -1
  217. package/src/lib/map-position.ts +77 -0
  218. package/src/lib/merge-hover-paint.ts +44 -13
  219. package/src/lib/plan-crs.parity.test.ts +39 -0
  220. package/src/lib/plan-crs.test.ts +248 -0
  221. package/src/lib/plan-crs.ts +252 -0
  222. package/src/lib/plan-patterns.test.ts +255 -0
  223. package/src/lib/plan-patterns.ts +160 -0
  224. package/src/lib/plan-regions.test.ts +186 -0
  225. package/src/lib/plan-regions.ts +209 -0
  226. package/src/lib/plan-status.test.ts +87 -0
  227. package/src/lib/plan-status.ts +147 -0
  228. package/src/lib/route-arrow.test.ts +88 -0
  229. package/src/lib/route-arrow.ts +59 -0
  230. package/src/lib/use-map-breakpoint.test.ts +83 -0
  231. package/src/lib/use-map-breakpoint.ts +238 -0
  232. package/src/lib/use-plan-projection.ts +157 -0
  233. package/src/lib/warn-once.ts +21 -0
  234. package/src/map-annotation/anchor.ts +92 -0
  235. package/src/map-annotation/index.ts +15 -0
  236. package/src/map-annotation/map-annotation.stories.tsx +113 -0
  237. package/src/map-annotation/map-annotation.test.tsx +106 -0
  238. package/src/map-annotation/map-annotation.tsx +356 -0
  239. package/src/map-arc/map-arc.tsx +26 -5
  240. package/src/map-canvas/map-canvas.stories.tsx +211 -0
  241. package/src/map-canvas/map-canvas.test.tsx +389 -0
  242. package/src/map-canvas/map-canvas.tsx +457 -17
  243. package/src/map-canvas/map-context.ts +11 -0
  244. package/src/map-cluster-layer/map-cluster-layer.tsx +14 -1
  245. package/src/map-controls/map-controls.stories.tsx +16 -0
  246. package/src/map-controls/map-controls.test.tsx +145 -1
  247. package/src/map-controls/map-controls.tsx +57 -12
  248. package/src/map-geojson/map-geojson.stories.tsx +153 -3
  249. package/src/map-geojson/map-geojson.test.tsx +453 -0
  250. package/src/map-geojson/map-geojson.tsx +591 -51
  251. package/src/map-inset/index.ts +1 -0
  252. package/src/map-inset/map-inset.stories.tsx +61 -0
  253. package/src/map-inset/map-inset.test.tsx +117 -0
  254. package/src/map-inset/map-inset.tsx +264 -0
  255. package/src/map-legend/index.ts +10 -0
  256. package/src/map-legend/map-corner.ts +23 -0
  257. package/src/map-legend/map-legend.stories.tsx +171 -0
  258. package/src/map-legend/map-legend.test.tsx +136 -0
  259. package/src/map-legend/map-legend.tsx +264 -0
  260. package/src/map-marker/map-marker.stories.tsx +136 -4
  261. package/src/map-marker/map-marker.test.tsx +328 -3
  262. package/src/map-marker/map-marker.tsx +370 -33
  263. package/src/map-north-arrow/index.ts +1 -0
  264. package/src/map-north-arrow/map-north-arrow.stories.tsx +65 -0
  265. package/src/map-north-arrow/map-north-arrow.test.tsx +39 -0
  266. package/src/map-north-arrow/map-north-arrow.tsx +82 -0
  267. package/src/map-plan/index.ts +13 -0
  268. package/src/map-plan/map-plan-legend.test.tsx +61 -0
  269. package/src/map-plan/map-plan-legend.tsx +83 -0
  270. package/src/map-plan/map-plan-overlay.stories.tsx +260 -0
  271. package/src/map-plan/map-plan-overlay.test.tsx +302 -0
  272. package/src/map-plan/map-plan-overlay.tsx +531 -0
  273. package/src/map-plan/map-plan-status.test.tsx +71 -0
  274. package/src/map-plan/map-plan-status.tsx +77 -0
  275. package/src/map-plan/map-plan-table.test.tsx +86 -0
  276. package/src/map-plan/map-plan-table.tsx +153 -0
  277. package/src/map-plan/use-plan-selection.ts +78 -0
  278. package/src/map-plan-image/index.ts +1 -0
  279. package/src/map-plan-image/map-plan-image.stories.tsx +301 -0
  280. package/src/map-plan-image/map-plan-image.test.tsx +176 -0
  281. package/src/map-plan-image/map-plan-image.tsx +279 -0
  282. package/src/map-popup/map-popup.tsx +15 -10
  283. package/src/map-route/index.ts +1 -1
  284. package/src/map-route/map-route.test.tsx +216 -0
  285. package/src/map-route/map-route.tsx +105 -6
  286. package/src/map-scale-bar/index.ts +9 -0
  287. package/src/map-scale-bar/map-scale-bar.stories.tsx +63 -0
  288. package/src/map-scale-bar/map-scale-bar.test.tsx +65 -0
  289. package/src/map-scale-bar/map-scale-bar.tsx +145 -0
  290. package/src/test-utils/locator-fixture.ts +75 -0
  291. package/src/test-utils/maplibre-mock.ts +256 -19
@@ -1,13 +1,35 @@
1
1
  "use client";
2
2
 
3
3
  import MapLibreGL, { type MarkerOptions, type PopupOptions } from "maplibre-gl";
4
- import { createContext, use, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
4
+ import {
5
+ createContext,
6
+ use,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ type ReactNode,
13
+ } from "react";
5
14
  import { createPortal } from "react-dom";
6
15
  import { X } from "lucide-react";
7
16
  import { cn } from "@elabs-ai/components-ui/lib/cn";
8
17
  import { useLocale } from "@elabs-ai/components-ui";
9
18
 
10
19
  import { useMap } from "../map-canvas/map-context";
20
+ import {
21
+ type MapResponsive,
22
+ useMapBreakpoint,
23
+ resolveMapResponsive,
24
+ } from "../lib/use-map-breakpoint";
25
+ import {
26
+ type MapLabelAnchor,
27
+ clampShift,
28
+ mapAnchorGeometry,
29
+ mapAnchorTransform,
30
+ } from "../map-annotation/anchor";
31
+ import { resolveMapPosition, toPlanPoint, type MapPosition } from "../lib/map-position";
32
+ import type { PlanPoint } from "../lib/plan-crs";
11
33
 
12
34
  type MarkerContextValue = {
13
35
  marker: MapLibreGL.Marker;
@@ -24,35 +46,68 @@ function useMarkerContext() {
24
46
  return context;
25
47
  }
26
48
 
27
- export type MapMarkerProps = {
28
- /** Longitude coordinate for the marker position. */
29
- longitude: number;
30
- /** Latitude coordinate for the marker position. */
31
- latitude: number;
32
- /** Marker sub-components (MapMarkerContent, MapMarkerPopup, MapMarkerTooltip, MapMarkerLabel). */
33
- children: ReactNode;
49
+ /**
50
+ * A marker's own text label — the locator-map label: one of eight positions,
51
+ * optionally on a box, optionally pushed out on a callout line.
52
+ */
53
+ export interface MapMarkerLabelSpec {
54
+ /** The label text. */
55
+ text: string;
56
+ /** Where the label sits relative to the point (default `"top"`). */
57
+ position?: MapLabelAnchor;
58
+ /** Set the label on a background box so it reads over a busy basemap. */
59
+ box?: boolean;
60
+ /** Push the label further out and draw a callout line back to the point. */
61
+ callout?: boolean;
62
+ }
63
+
64
+ export type MapMarkerProps = MapPosition & {
65
+ /**
66
+ * Marker sub-components (MapMarkerContent, MapMarkerPopup, MapMarkerTooltip,
67
+ * MapMarkerLabel). Optional when `label` alone marks the place — an inline
68
+ * area label, for example.
69
+ */
70
+ children?: ReactNode;
71
+ /**
72
+ * Whether the marker shows at a tier (default `true` everywhere):
73
+ * `{ base: true, narrow: false }` drops it from a phone-width map. The
74
+ * The editorial advice applies — duplicate a marker with a shorter label and
75
+ * the opposite `showAt` rather than cramming one label into both.
76
+ */
77
+ showAt?: MapResponsive<boolean>;
78
+ /** A text label drawn with the marker (see {@link MapMarkerLabelSpec}). */
79
+ label?: MapMarkerLabelSpec;
34
80
  /** Callback when the marker is clicked. */
35
81
  onClick?: (e: MouseEvent) => void;
36
82
  /** Callback when the mouse enters the marker. */
37
83
  onMouseEnter?: (e: MouseEvent) => void;
38
84
  /** Callback when the mouse leaves the marker. */
39
85
  onMouseLeave?: (e: MouseEvent) => void;
40
- /** Callback when a drag starts (requires `draggable`). */
41
- onDragStart?: (lngLat: { lng: number; lat: number }) => void;
42
- /** Callback during a drag (requires `draggable`). */
43
- onDrag?: (lngLat: { lng: number; lat: number }) => void;
44
- /** Callback when a drag ends (requires `draggable`). */
45
- onDragEnd?: (lngLat: { lng: number; lat: number }) => void;
86
+ /** Callback when a drag starts (requires `draggable`). The plan point is `null` off a plan. */
87
+ onDragStart?: (lngLat: { lng: number; lat: number }, plan: PlanPoint | null) => void;
88
+ /** Callback during a drag (requires `draggable`). The plan point is `null` off a plan. */
89
+ onDrag?: (lngLat: { lng: number; lat: number }, plan: PlanPoint | null) => void;
90
+ /** Callback when a drag ends (requires `draggable`). The plan point is `null` off a plan. */
91
+ onDragEnd?: (lngLat: { lng: number; lat: number }, plan: PlanPoint | null) => void;
92
+ /**
93
+ * The accessible name of a `draggable` marker — say what it moves ("Depot
94
+ * location"). Defaults to the locale seam's generic name. Ignored when the
95
+ * marker is not draggable.
96
+ */
97
+ dragLabel?: string;
46
98
  } & Omit<MarkerOptions, "element">;
47
99
 
48
100
  /**
49
- * A marker anchored at a lng/lat. Compose the pieces you need:
50
- * `MapMarkerContent` (the visual), `MapMarkerLabel`, `MapMarkerPopup` (opens
51
- * on click) and `MapMarkerTooltip` (shows on hover).
101
+ * A marker anchored at a lng/lat or, on a canvas with a `plan` extent, at a
102
+ * plan `x`/`y`. Compose the pieces you need: `MapMarkerContent` (the visual),
103
+ * `MapMarkerLabel`, `MapMarkerPopup` (opens on click) and `MapMarkerTooltip`
104
+ * (shows on hover).
52
105
  */
53
106
  export function MapMarker({
54
- longitude,
55
- latitude,
107
+ longitude: longitudeProp,
108
+ latitude: latitudeProp,
109
+ x,
110
+ y,
56
111
  children,
57
112
  onClick,
58
113
  onMouseEnter,
@@ -61,11 +116,26 @@ export function MapMarker({
61
116
  onDrag,
62
117
  onDragEnd,
63
118
  draggable = false,
119
+ dragLabel,
120
+ showAt = true,
121
+ label,
64
122
  ...markerOptions
65
123
  }: MapMarkerProps) {
66
- const { map } = useMap();
124
+ const { map, plan } = useMap();
125
+ const { t } = useLocale();
126
+ const visible = resolveMapResponsive(showAt, useMapBreakpoint());
67
127
  const [marker, setMarker] = useState<MapLibreGL.Marker | null>(null);
68
128
 
129
+ // One position prop, two coordinate systems, two scalars out — so every sync
130
+ // effect below keeps primitive dependencies.
131
+ const [longitude, latitude] = resolveMapPosition(
132
+ { longitude: longitudeProp, latitude: latitudeProp, x, y },
133
+ plan,
134
+ "MapMarker",
135
+ );
136
+ const planRef = useRef(plan);
137
+ planRef.current = plan;
138
+
69
139
  const callbacksRef = useRef({
70
140
  onClick,
71
141
  onMouseEnter,
@@ -111,6 +181,12 @@ export function MapMarker({
111
181
  draggable: initialRef.current.draggable,
112
182
  }).setLngLat([initialRef.current.longitude, initialRef.current.latitude]);
113
183
 
184
+ // c-10: MapLibre gives a marker a tab stop of its own (a popup, a
185
+ // keyboard drag), and a raw element shows the BROWSER's focus ring, which
186
+ // does not follow the theme. The house indicator is a class, so the
187
+ // element wears the same `focus-ring` every other control does.
188
+ markerInstance.getElement()?.classList.add("focus-ring");
189
+
114
190
  const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e);
115
191
  const handleMouseEnter = (e: MouseEvent) => callbacksRef.current.onMouseEnter?.(e);
116
192
  const handleMouseLeave = (e: MouseEvent) => callbacksRef.current.onMouseLeave?.(e);
@@ -121,15 +197,24 @@ export function MapMarker({
121
197
 
122
198
  const handleDragStart = () => {
123
199
  const lngLat = markerInstance.getLngLat();
124
- callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat });
200
+ callbacksRef.current.onDragStart?.(
201
+ { lng: lngLat.lng, lat: lngLat.lat },
202
+ toPlanPoint(planRef.current, lngLat),
203
+ );
125
204
  };
126
205
  const handleDrag = () => {
127
206
  const lngLat = markerInstance.getLngLat();
128
- callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat });
207
+ callbacksRef.current.onDrag?.(
208
+ { lng: lngLat.lng, lat: lngLat.lat },
209
+ toPlanPoint(planRef.current, lngLat),
210
+ );
129
211
  };
130
212
  const handleDragEnd = () => {
131
213
  const lngLat = markerInstance.getLngLat();
132
- callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat });
214
+ callbacksRef.current.onDragEnd?.(
215
+ { lng: lngLat.lng, lat: lngLat.lat },
216
+ toPlanPoint(planRef.current, lngLat),
217
+ );
133
218
  };
134
219
 
135
220
  markerInstance.on("dragstart", handleDragStart);
@@ -142,15 +227,16 @@ export function MapMarker({
142
227
  // Attaches/detaches the CURRENT marker instance to the map — its own effect
143
228
  // so both a `map` change and an `anchor`/`className`-driven rebuild above
144
229
  // are handled the same way, without a double `remove()`.
230
+ // A marker hidden at this tier (`showAt`) is simply not on the map.
145
231
  useEffect(() => {
146
- if (!map || !marker) return;
232
+ if (!map || !marker || !visible) return;
147
233
 
148
234
  marker.addTo(map);
149
235
 
150
236
  return () => {
151
237
  marker.remove();
152
238
  };
153
- }, [map, marker]);
239
+ }, [map, marker, visible]);
154
240
 
155
241
  useEffect(() => {
156
242
  if (!marker) return;
@@ -184,6 +270,88 @@ export function MapMarker({
184
270
  }
185
271
  }, [marker, longitude, latitude, draggable, offset, rotation, rotationAlignment, pitchAlignment]);
186
272
 
273
+ // c-4 (WCAG 2.1.1): `marker.setDraggable(true)` buys a MOUSE affordance and
274
+ // nothing else — MapLibre gives a marker element a tab stop and a key
275
+ // handler only from inside `setPopup`, so a popup-less draggable marker is
276
+ // not focusable and answers no key. The drag gets its keyboard equivalent
277
+ // here: a real tab stop, a name that says what moves, and arrow keys that
278
+ // step the marker in PIXELS (Shift = a coarser step) through the same
279
+ // project/unproject round trip the pointer drag uses, firing the identical
280
+ // dragstart/drag/dragend payloads.
281
+ useEffect(() => {
282
+ const element = marker?.getElement();
283
+ if (!(draggable && marker && map && element)) return;
284
+
285
+ const hadTabIndex = element.getAttribute("tabindex");
286
+ const hadRole = element.getAttribute("role");
287
+ const hadLabel = element.getAttribute("aria-label");
288
+ if (hadTabIndex === null) element.setAttribute("tabindex", "0");
289
+ if (hadRole === null) element.setAttribute("role", "button");
290
+ // MapLibre's own name is the generic "Map marker"; it says neither what
291
+ // this one moves nor that it can be moved at all. A draggable marker is
292
+ // named for its drag, and the original name goes back on cleanup.
293
+ element.setAttribute("aria-label", dragLabel ?? t("maps.marker.drag"));
294
+
295
+ let dragging = false;
296
+ const position = () => {
297
+ const lngLat = marker.getLngLat();
298
+ return { lng: lngLat.lng, lat: lngLat.lat };
299
+ };
300
+ // The keyboard path reports the plan point exactly as the pointer path
301
+ // does, so a plan consumer cannot tell the two apart.
302
+ const planPoint = () => toPlanPoint(planRef.current, marker.getLngLat());
303
+ const settle = () => {
304
+ if (!dragging) return;
305
+ dragging = false;
306
+ callbacksRef.current.onDragEnd?.(position(), planPoint());
307
+ };
308
+
309
+ const handleKeyDown = (event: KeyboardEvent) => {
310
+ const step = event.shiftKey ? KEYBOARD_DRAG_STEP_COARSE_PX : KEYBOARD_DRAG_STEP_PX;
311
+ const delta = KEYBOARD_DRAG_DELTAS[event.key];
312
+ if (!delta) return;
313
+ event.preventDefault();
314
+ // MapLibre appends the marker element straight into
315
+ // `map.getCanvasContainer()` — the SAME element its own
316
+ // `HandlerManager` listens on for `keydown` (bubble phase, no
317
+ // `defaultPrevented` check). Without this, every arrow key we handle
318
+ // here also reaches MapLibre's built-in keyboard pan handler, which
319
+ // fires an `easeTo` that moves the CAMERA by 100px the opposite way
320
+ // (its `offset` is negated) and — since that `easeTo` isn't marked
321
+ // `essential` — collapses to an instant jump under reduced motion but
322
+ // animates over 300ms otherwise, so repeated presses compose
323
+ // differently per motion setting. Stopping propagation keeps the key
324
+ // entirely inside this handler: only `marker.setLngLat` moves, the
325
+ // camera never does.
326
+ event.stopPropagation();
327
+ if (!dragging) {
328
+ dragging = true;
329
+ callbacksRef.current.onDragStart?.(position(), planPoint());
330
+ }
331
+ const point = map.project(marker.getLngLat());
332
+ const next = map.unproject([point.x + delta[0] * step, point.y + delta[1] * step]);
333
+ marker.setLngLat(next);
334
+ callbacksRef.current.onDrag?.(position(), planPoint());
335
+ };
336
+ const handleKeyUp = (event: KeyboardEvent) => {
337
+ if (KEYBOARD_DRAG_DELTAS[event.key]) settle();
338
+ };
339
+
340
+ element.addEventListener("keydown", handleKeyDown);
341
+ element.addEventListener("keyup", handleKeyUp);
342
+ element.addEventListener("blur", settle);
343
+
344
+ return () => {
345
+ element.removeEventListener("keydown", handleKeyDown);
346
+ element.removeEventListener("keyup", handleKeyUp);
347
+ element.removeEventListener("blur", settle);
348
+ if (hadTabIndex === null) element.removeAttribute("tabindex");
349
+ if (hadRole === null) element.removeAttribute("role");
350
+ if (hadLabel === null) element.removeAttribute("aria-label");
351
+ else element.setAttribute("aria-label", hadLabel);
352
+ };
353
+ }, [draggable, dragLabel, map, marker, t]);
354
+
187
355
  // No marker yet (first client tick after mount, or the moment an
188
356
  // `anchor`/`className` rebuild is in flight) — render nothing rather than
189
357
  // a sub-component (`MapMarkerContent` et al.) reaching into a null marker,
@@ -192,7 +360,148 @@ export function MapMarker({
192
360
  return null;
193
361
  }
194
362
 
195
- return <MarkerContext.Provider value={{ marker, map }}>{children}</MarkerContext.Provider>;
363
+ return (
364
+ <MarkerContext.Provider value={{ marker, map }}>
365
+ {children}
366
+ {label && <MarkerOwnLabel label={label} />}
367
+ </MarkerContext.Provider>
368
+ );
369
+ }
370
+
371
+ /** One arrow-key press of a keyboard drag, in screen px (c-4). */
372
+ const KEYBOARD_DRAG_STEP_PX = 8;
373
+ /** The same press with Shift held — a coarser step across the map. */
374
+ const KEYBOARD_DRAG_STEP_COARSE_PX = 40;
375
+ /** Arrow key → screen-space direction. `y` grows downward, as `map.project` does. */
376
+ const KEYBOARD_DRAG_DELTAS: Record<string, [number, number] | undefined> = {
377
+ ArrowLeft: [-1, 0],
378
+ ArrowRight: [1, 0],
379
+ ArrowUp: [0, -1],
380
+ ArrowDown: [0, 1],
381
+ };
382
+
383
+ const LABEL_GAP = 10;
384
+ const CALLOUT_DISTANCE = 28;
385
+ /** How close to the map's edge a clamped label may sit, in px. */
386
+ const LABEL_EDGE_PAD = 4;
387
+
388
+ /**
389
+ * Keep a marker's label inside the map box (c-7 / c-8).
390
+ *
391
+ * A label is a pure offset from its marker, so at the edge of a static map it
392
+ * was painted half outside the box and clipped — "bottom-righ", "…urg Gate" —
393
+ * with no way to pan it back into view. This is the pass `MapAnnotation`
394
+ * already makes: measure the painted box, clamp it into the map's rectangle
395
+ * with `clampShift`, and write the correction to the CSS `translate` property,
396
+ * which composes with the label's own `transform` instead of replacing it.
397
+ *
398
+ * A label whose own point has left the box is hidden rather than pinned to the
399
+ * edge: a name parked at the border beside no marker claims a place the reader
400
+ * cannot see. Skipped while the map box measures 0 (jsdom, first paint).
401
+ */
402
+ function useLabelInsideMap(
403
+ element: HTMLElement | null,
404
+ map: MapLibreGL.Map | null,
405
+ marker: MapLibreGL.Marker,
406
+ onShift?: (sx: number, sy: number) => void,
407
+ ) {
408
+ const onShiftRef = useRef(onShift);
409
+ onShiftRef.current = onShift;
410
+
411
+ useLayoutEffect(() => {
412
+ if (!element || !map) return undefined;
413
+ const container = map.getContainer();
414
+
415
+ const update = () => {
416
+ const frame = container.getBoundingClientRect();
417
+ if (frame.width === 0 || frame.height === 0) return;
418
+
419
+ const point = map.project(marker.getLngLat());
420
+ const inside =
421
+ point.x >= 0 && point.y >= 0 && point.x <= frame.width && point.y <= frame.height;
422
+ element.style.visibility = inside ? "" : "hidden";
423
+ if (!inside) return;
424
+
425
+ // Measure the label where it would sit unshifted, then re-apply.
426
+ element.style.translate = "";
427
+ const box = element.getBoundingClientRect();
428
+ const { sx, sy } = clampShift(
429
+ box.left - frame.left,
430
+ box.top - frame.top,
431
+ box.width,
432
+ box.height,
433
+ frame.width,
434
+ frame.height,
435
+ LABEL_EDGE_PAD,
436
+ );
437
+ element.style.translate = sx === 0 && sy === 0 ? "" : `${sx}px ${sy}px`;
438
+ onShiftRef.current?.(sx, sy);
439
+ };
440
+
441
+ update();
442
+ map.on("move", update);
443
+ map.on("resize", update);
444
+ const observer =
445
+ typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => update());
446
+ observer?.observe(element);
447
+ return () => {
448
+ map.off("move", update);
449
+ map.off("resize", update);
450
+ observer?.disconnect();
451
+ };
452
+ }, [element, map, marker]);
453
+ }
454
+
455
+ /** The `label` prop, portaled into the marker element and centred on the point. */
456
+ function MarkerOwnLabel({ label }: { label: MapMarkerLabelSpec }) {
457
+ const { marker, map } = useMarkerContext();
458
+ const { text, position = "top", box = false, callout = false } = label;
459
+ const distance = callout ? CALLOUT_DISTANCE : LABEL_GAP;
460
+ const { dx, dy } = mapAnchorGeometry(position, distance);
461
+ const [labelEl, setLabelEl] = useState<HTMLDivElement | null>(null);
462
+ const lineRef = useRef<SVGLineElement | null>(null);
463
+
464
+ // A clamped label pulls its callout line's end along with it, so the line
465
+ // still meets the box it points at.
466
+ useLabelInsideMap(labelEl, map, marker, (sx, sy) => {
467
+ const line = lineRef.current;
468
+ if (!line) return;
469
+ line.setAttribute("x2", String(dx + sx));
470
+ line.setAttribute("y2", String(dy + sy));
471
+ });
472
+
473
+ return createPortal(
474
+ <>
475
+ {callout && (
476
+ <svg
477
+ aria-hidden="true"
478
+ data-slot="map-marker-callout"
479
+ className="pointer-events-none absolute overflow-visible text-foreground"
480
+ // The marker element is centred on the point; draw from its centre.
481
+ style={{ left: "50%", top: "50%" }}
482
+ width={1}
483
+ height={1}
484
+ >
485
+ <line ref={lineRef} x1={0} y1={0} x2={dx} y2={dy} stroke="currentColor" strokeWidth={1} />
486
+ </svg>
487
+ )}
488
+ <div
489
+ ref={setLabelEl}
490
+ data-slot="map-marker-label"
491
+ data-position={position}
492
+ className={cn(
493
+ "pointer-events-none absolute whitespace-nowrap text-meta font-medium text-foreground",
494
+ box && "rounded-sm bg-background/90 px-1 py-px",
495
+ )}
496
+ // Geographic placement: physical offsets from the point in every
497
+ // writing direction (east stays east), like MapLibre's own anchors.
498
+ style={{ left: "50%", top: "50%", transform: mapAnchorTransform(position, distance) }}
499
+ >
500
+ {text}
501
+ </div>
502
+ </>,
503
+ marker.getElement(),
504
+ );
196
505
  }
197
506
 
198
507
  export interface MapMarkerContentProps {
@@ -330,17 +639,36 @@ export function MapMarkerTooltip({ children, className, ...popupOptions }: MapMa
330
639
 
331
640
  tooltip.setDOMContent(container);
332
641
 
333
- const handleMouseEnter = () => {
642
+ const show = () => {
334
643
  tooltip.setLngLat(marker.getLngLat()).addTo(map);
335
644
  };
336
- const handleMouseLeave = () => tooltip.remove();
645
+ const hide = () => tooltip.remove();
646
+ // WCAG 1.4.13: a tooltip that only answers a pointer is unreachable, and
647
+ // one that cannot be dismissed without moving the pointer traps the view.
648
+ const handleKeyDown = (event: KeyboardEvent) => {
649
+ if (event.key === "Escape") hide();
650
+ };
651
+
652
+ const element = marker.getElement();
653
+ // MapLibre only makes a marker focusable inside `setPopup`, so a
654
+ // tooltip-only marker has to ask for the tab stop itself. The original
655
+ // value is put back on cleanup, so a marker that had one keeps it.
656
+ const hadTabIndex = element?.getAttribute("tabindex") ?? null;
657
+ if (element && hadTabIndex === null) element.setAttribute("tabindex", "0");
337
658
 
338
- marker.getElement()?.addEventListener("mouseenter", handleMouseEnter);
339
- marker.getElement()?.addEventListener("mouseleave", handleMouseLeave);
659
+ element?.addEventListener("mouseenter", show);
660
+ element?.addEventListener("mouseleave", hide);
661
+ element?.addEventListener("focus", show);
662
+ element?.addEventListener("blur", hide);
663
+ element?.addEventListener("keydown", handleKeyDown);
340
664
 
341
665
  return () => {
342
- marker.getElement()?.removeEventListener("mouseenter", handleMouseEnter);
343
- marker.getElement()?.removeEventListener("mouseleave", handleMouseLeave);
666
+ element?.removeEventListener("mouseenter", show);
667
+ element?.removeEventListener("mouseleave", hide);
668
+ element?.removeEventListener("focus", show);
669
+ element?.removeEventListener("blur", hide);
670
+ element?.removeEventListener("keydown", handleKeyDown);
671
+ if (element && hadTabIndex === null) element.removeAttribute("tabindex");
344
672
  tooltip.remove();
345
673
  };
346
674
  // eslint-disable-next-line react-hooks/exhaustive-deps -- tooltip/marker/container are stable
@@ -384,14 +712,23 @@ export interface MapMarkerLabelProps {
384
712
  * sibling composition rendering the label against the map container instead).
385
713
  */
386
714
  export function MapMarkerLabel({ children, className, position = "top" }: MapMarkerLabelProps) {
387
- const { marker } = useMarkerContext();
715
+ const { marker, map } = useMarkerContext();
716
+ const [labelEl, setLabelEl] = useState<HTMLDivElement | null>(null);
388
717
  const positionClasses = {
389
718
  top: "bottom-full mb-1",
390
719
  bottom: "top-full mt-1",
391
720
  };
392
721
 
722
+ // Same edge clamp as the `label` prop's own label (c-7 / c-8): a composed
723
+ // label is a plain offset from the marker too, so at the map's edge it was
724
+ // painted outside the box and clipped.
725
+ useLabelInsideMap(labelEl, map, marker);
726
+
393
727
  return createPortal(
394
728
  <div
729
+ ref={setLabelEl}
730
+ data-slot="map-marker-label"
731
+ data-position={position}
395
732
  className={cn(
396
733
  "absolute left-1/2 -translate-x-1/2 whitespace-nowrap",
397
734
  "text-meta font-medium text-foreground",
@@ -0,0 +1 @@
1
+ export { MapNorthArrow, type MapNorthArrowProps } from "./map-north-arrow";
@@ -0,0 +1,65 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor } from "storybook/test";
3
+
4
+ import { MapCanvas } from "../map-canvas";
5
+ import { MapNorthArrow } from "./map-north-arrow";
6
+
7
+ const meta = {
8
+ title: "Maps/MapNorthArrow",
9
+ component: MapNorthArrow,
10
+ tags: ["autodocs"],
11
+ parameters: { layout: "fullscreen" },
12
+ } satisfies Meta<typeof MapNorthArrow>;
13
+ export default meta;
14
+ type Story = StoryObj<typeof meta>;
15
+
16
+ /**
17
+ * A rotated map (bearing 30°) shows the arrow, turned to point at true north.
18
+ * On a north-up map the arrow stays hidden — it would only state the default.
19
+ */
20
+ export const Default: Story = {
21
+ render: (args) => (
22
+ <div className="w-full p-4">
23
+ <MapCanvas interactive={false} center={[-79.38, 43.65]} zoom={9} bearing={30}>
24
+ <MapNorthArrow {...args} />
25
+ </MapCanvas>
26
+ </div>
27
+ ),
28
+ play: async ({ canvasElement }) => {
29
+ if (!canvasElement.querySelector(".maplibregl-canvas")) return;
30
+ await waitFor(() => {
31
+ const arrow = canvasElement.querySelector<HTMLElement>('[data-slot="map-north-arrow"]');
32
+ expect(arrow).not.toBeNull();
33
+ expect(arrow!.hidden).toBe(false);
34
+ });
35
+ },
36
+ };
37
+
38
+ /** North-up: nothing to show. */
39
+ export const NorthUp: Story = {
40
+ render: () => (
41
+ <div className="w-full p-4">
42
+ <MapCanvas interactive={false} center={[-79.38, 43.65]} zoom={9}>
43
+ <MapNorthArrow />
44
+ </MapCanvas>
45
+ </div>
46
+ ),
47
+ };
48
+
49
+ /** In another corner, with a translated name. */
50
+ export const Positioned: Story = {
51
+ args: { position: "bottom-right", label: "Norden" },
52
+ render: Default.render,
53
+ };
54
+
55
+ /** The remaining corners. */
56
+ export const OtherCorners: Story = {
57
+ render: () => (
58
+ <div className="w-full p-4">
59
+ <MapCanvas interactive={false} center={[-79.38, 43.65]} zoom={9} bearing={-45}>
60
+ <MapNorthArrow position="top-right" />
61
+ <MapNorthArrow position="bottom-left" />
62
+ </MapCanvas>
63
+ </div>
64
+ ),
65
+ };
@@ -0,0 +1,39 @@
1
+ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ vi.mock("maplibre-gl", async () => {
5
+ const { createMaplibreMock } = await import("../test-utils/maplibre-mock");
6
+ return createMaplibreMock();
7
+ });
8
+
9
+ import { MockMap, resetMaplibreMock } from "../test-utils/maplibre-mock";
10
+ import { MapCanvas } from "../map-canvas";
11
+ import { MapNorthArrow } from "./map-north-arrow";
12
+
13
+ afterEach(() => {
14
+ cleanup();
15
+ resetMaplibreMock();
16
+ });
17
+
18
+ describe("MapNorthArrow", () => {
19
+ it("stays away on a north-up map and appears once the map rotates", async () => {
20
+ render(
21
+ <MapCanvas>
22
+ <MapNorthArrow />
23
+ </MapCanvas>,
24
+ );
25
+ const arrow = await waitFor(() => {
26
+ const el = document.querySelector('[data-slot="map-north-arrow"]');
27
+ expect(el).not.toBeNull();
28
+ return el!;
29
+ });
30
+ expect(arrow).toHaveAttribute("hidden");
31
+ act(() => {
32
+ MockMap.instances[0]!.bearing = 30;
33
+ MockMap.instances[0]!.emit("rotate");
34
+ });
35
+ expect(arrow).not.toHaveAttribute("hidden");
36
+ expect(screen.getByRole("img", { name: "North" })).toBeVisible();
37
+ expect(arrow.querySelector("svg")!.style.transform).toBe("rotate(-30deg)");
38
+ });
39
+ });