@elabs-ai/components-maps 4.1.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 -1456
  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 +421 -1
  242. package/src/map-canvas/map-canvas.tsx +466 -20
  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 +383 -3
  262. package/src/map-marker/map-marker.tsx +414 -44
  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 +18 -11
  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 +267 -20
@@ -1,12 +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, 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";
17
+ import { useLocale } from "@elabs-ai/components-ui";
8
18
 
9
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";
10
33
 
11
34
  type MarkerContextValue = {
12
35
  marker: MapLibreGL.Marker;
@@ -23,35 +46,68 @@ function useMarkerContext() {
23
46
  return context;
24
47
  }
25
48
 
26
- export type MapMarkerProps = {
27
- /** Longitude coordinate for the marker position. */
28
- longitude: number;
29
- /** Latitude coordinate for the marker position. */
30
- latitude: number;
31
- /** Marker sub-components (MapMarkerContent, MapMarkerPopup, MapMarkerTooltip, MapMarkerLabel). */
32
- 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;
33
80
  /** Callback when the marker is clicked. */
34
81
  onClick?: (e: MouseEvent) => void;
35
82
  /** Callback when the mouse enters the marker. */
36
83
  onMouseEnter?: (e: MouseEvent) => void;
37
84
  /** Callback when the mouse leaves the marker. */
38
85
  onMouseLeave?: (e: MouseEvent) => void;
39
- /** Callback when a drag starts (requires `draggable`). */
40
- onDragStart?: (lngLat: { lng: number; lat: number }) => void;
41
- /** Callback during a drag (requires `draggable`). */
42
- onDrag?: (lngLat: { lng: number; lat: number }) => void;
43
- /** Callback when a drag ends (requires `draggable`). */
44
- 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;
45
98
  } & Omit<MarkerOptions, "element">;
46
99
 
47
100
  /**
48
- * A marker anchored at a lng/lat. Compose the pieces you need:
49
- * `MapMarkerContent` (the visual), `MapMarkerLabel`, `MapMarkerPopup` (opens
50
- * 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).
51
105
  */
52
106
  export function MapMarker({
53
- longitude,
54
- latitude,
107
+ longitude: longitudeProp,
108
+ latitude: latitudeProp,
109
+ x,
110
+ y,
55
111
  children,
56
112
  onClick,
57
113
  onMouseEnter,
@@ -60,9 +116,25 @@ export function MapMarker({
60
116
  onDrag,
61
117
  onDragEnd,
62
118
  draggable = false,
119
+ dragLabel,
120
+ showAt = true,
121
+ label,
63
122
  ...markerOptions
64
123
  }: MapMarkerProps) {
65
- const { map } = useMap();
124
+ const { map, plan } = useMap();
125
+ const { t } = useLocale();
126
+ const visible = resolveMapResponsive(showAt, useMapBreakpoint());
127
+ const [marker, setMarker] = useState<MapLibreGL.Marker | null>(null);
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;
66
138
 
67
139
  const callbacksRef = useRef({
68
140
  onClick,
@@ -81,12 +153,39 @@ export function MapMarker({
81
153
  onDragEnd,
82
154
  };
83
155
 
84
- const marker = useMemo(() => {
156
+ const { anchor, className, offset, rotation, rotationAlignment, pitchAlignment } = markerOptions;
157
+
158
+ // `longitude`/`latitude`/`draggable` at construction time only — the sync
159
+ // effect below corrects them on the very next commit, and are read from a
160
+ // ref here so they don't force a rebuild on every position change (see the
161
+ // `anchor`/`className` note below for what SHOULD force one).
162
+ const initialRef = useRef({ longitude, latitude, draggable });
163
+ initialRef.current = { longitude, latitude, draggable };
164
+
165
+ // Builds (and tears down) the actual MapLibre `Marker` instance. This is a
166
+ // real `useEffect`, never `useMemo`: `useMemo`'s initializer runs during
167
+ // RENDER — including on the server, where `document` doesn't exist — and
168
+ // React offers no guarantee it runs only once (it may discard and recompute
169
+ // a memoized value), which would silently leak marker instances/listeners.
170
+ //
171
+ // `anchor` and `className` have no MapLibre setter (`Marker` bakes both into
172
+ // its constructor), so they are the only options that must REBUILD the
173
+ // instance; every other option (position, draggable, offset, rotation,
174
+ // alignment) has a live setter and is kept in sync by the effect further
175
+ // below without ever recreating the marker.
176
+ useEffect(() => {
85
177
  const markerInstance = new MapLibreGL.Marker({
86
- ...markerOptions,
178
+ anchor,
179
+ className,
87
180
  element: document.createElement("div"),
88
- draggable,
89
- }).setLngLat([longitude, latitude]);
181
+ draggable: initialRef.current.draggable,
182
+ }).setLngLat([initialRef.current.longitude, initialRef.current.latitude]);
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");
90
189
 
91
190
  const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e);
92
191
  const handleMouseEnter = (e: MouseEvent) => callbacksRef.current.onMouseEnter?.(e);
@@ -98,39 +197,50 @@ export function MapMarker({
98
197
 
99
198
  const handleDragStart = () => {
100
199
  const lngLat = markerInstance.getLngLat();
101
- 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
+ );
102
204
  };
103
205
  const handleDrag = () => {
104
206
  const lngLat = markerInstance.getLngLat();
105
- 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
+ );
106
211
  };
107
212
  const handleDragEnd = () => {
108
213
  const lngLat = markerInstance.getLngLat();
109
- 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
+ );
110
218
  };
111
219
 
112
220
  markerInstance.on("dragstart", handleDragStart);
113
221
  markerInstance.on("drag", handleDrag);
114
222
  markerInstance.on("dragend", handleDragEnd);
115
223
 
116
- return markerInstance;
117
- // eslint-disable-next-line react-hooks/exhaustive-deps -- instance created once; position/options are synced by the effect below
118
- }, []);
224
+ setMarker(markerInstance);
225
+ }, [anchor, className]);
119
226
 
227
+ // Attaches/detaches the CURRENT marker instance to the map — its own effect
228
+ // so both a `map` change and an `anchor`/`className`-driven rebuild above
229
+ // are handled the same way, without a double `remove()`.
230
+ // A marker hidden at this tier (`showAt`) is simply not on the map.
120
231
  useEffect(() => {
121
- if (!map) return;
232
+ if (!map || !marker || !visible) return;
122
233
 
123
234
  marker.addTo(map);
124
235
 
125
236
  return () => {
126
237
  marker.remove();
127
238
  };
128
- // eslint-disable-next-line react-hooks/exhaustive-deps -- `marker` is stable (created once above)
129
- }, [map]);
130
-
131
- const { offset, rotation, rotationAlignment, pitchAlignment } = markerOptions;
239
+ }, [map, marker, visible]);
132
240
 
133
241
  useEffect(() => {
242
+ if (!marker) return;
243
+
134
244
  const current = marker.getLngLat();
135
245
  if (current.lng !== longitude || current.lat !== latitude) {
136
246
  marker.setLngLat([longitude, latitude]);
@@ -160,7 +270,238 @@ export function MapMarker({
160
270
  }
161
271
  }, [marker, longitude, latitude, draggable, offset, rotation, rotationAlignment, pitchAlignment]);
162
272
 
163
- return <MarkerContext.Provider value={{ marker, map }}>{children}</MarkerContext.Provider>;
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
+
355
+ // No marker yet (first client tick after mount, or the moment an
356
+ // `anchor`/`className` rebuild is in flight) — render nothing rather than
357
+ // a sub-component (`MapMarkerContent` et al.) reaching into a null marker,
358
+ // and nothing at all on the server.
359
+ if (!marker) {
360
+ return null;
361
+ }
362
+
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
+ );
164
505
  }
165
506
 
166
507
  export interface MapMarkerContentProps {
@@ -189,11 +530,12 @@ function DefaultMarkerIcon() {
189
530
  }
190
531
 
191
532
  function PopupCloseButton({ onClick }: { onClick: () => void }) {
533
+ const { t } = useLocale();
192
534
  return (
193
535
  <button
194
536
  type="button"
195
537
  onClick={onClick}
196
- aria-label="Close popup"
538
+ aria-label={t("maps.popup.close")}
197
539
  className="absolute top-1 right-1 z-10 inline-flex size-5 items-center justify-center rounded-sm text-foreground transition-colors duration-fast hover:bg-muted focus-ring-inset"
198
540
  >
199
541
  <X className="size-3.5" aria-hidden="true" />
@@ -297,17 +639,36 @@ export function MapMarkerTooltip({ children, className, ...popupOptions }: MapMa
297
639
 
298
640
  tooltip.setDOMContent(container);
299
641
 
300
- const handleMouseEnter = () => {
642
+ const show = () => {
301
643
  tooltip.setLngLat(marker.getLngLat()).addTo(map);
302
644
  };
303
- 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");
304
658
 
305
- marker.getElement()?.addEventListener("mouseenter", handleMouseEnter);
306
- 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);
307
664
 
308
665
  return () => {
309
- marker.getElement()?.removeEventListener("mouseenter", handleMouseEnter);
310
- 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");
311
672
  tooltip.remove();
312
673
  };
313
674
  // eslint-disable-next-line react-hooks/exhaustive-deps -- tooltip/marker/container are stable
@@ -351,14 +712,23 @@ export interface MapMarkerLabelProps {
351
712
  * sibling composition rendering the label against the map container instead).
352
713
  */
353
714
  export function MapMarkerLabel({ children, className, position = "top" }: MapMarkerLabelProps) {
354
- const { marker } = useMarkerContext();
715
+ const { marker, map } = useMarkerContext();
716
+ const [labelEl, setLabelEl] = useState<HTMLDivElement | null>(null);
355
717
  const positionClasses = {
356
718
  top: "bottom-full mb-1",
357
719
  bottom: "top-full mt-1",
358
720
  };
359
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
+
360
727
  return createPortal(
361
728
  <div
729
+ ref={setLabelEl}
730
+ data-slot="map-marker-label"
731
+ data-position={position}
362
732
  className={cn(
363
733
  "absolute left-1/2 -translate-x-1/2 whitespace-nowrap",
364
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
+ });