@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
@@ -0,0 +1,136 @@
1
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { colorScaleFor } from "@elabs-ai/components-ui";
4
+
5
+ vi.mock("maplibre-gl", async () => {
6
+ const { createMaplibreMock } = await import("../test-utils/maplibre-mock");
7
+ return createMaplibreMock();
8
+ });
9
+
10
+ import { resetMaplibreMock } from "../test-utils/maplibre-mock";
11
+ import { MapCanvas } from "../map-canvas";
12
+ import { MapLegend } from "./map-legend";
13
+
14
+ afterEach(() => {
15
+ cleanup();
16
+ resetMaplibreMock();
17
+ });
18
+
19
+ const VALUES = [3, 8, 12, 19, 25, 31, 44, 52, 60];
20
+
21
+ describe("MapLegend", () => {
22
+ it("renders marker rows with their swatches, over a corner", async () => {
23
+ render(
24
+ <MapCanvas>
25
+ <MapLegend
26
+ title="Markers"
27
+ position="top-left"
28
+ items={[
29
+ { label: "Lake", color: "var(--chart-1)", shape: "square" },
30
+ { label: "City", color: "var(--chart-2)" },
31
+ ]}
32
+ />
33
+ </MapCanvas>,
34
+ );
35
+ expect(await screen.findByText("Lake")).toBeInTheDocument();
36
+ const legend = document.querySelector<HTMLElement>('[data-slot="map-legend"]')!;
37
+ expect(legend).toHaveAttribute("data-position", "top-left");
38
+ // Inside the map box, over the corner.
39
+ expect(document.querySelector('[data-slot="map-canvas"]')!.contains(legend)).toBe(true);
40
+ const swatches = legend.querySelectorAll<HTMLElement>('[data-slot="map-legend-swatch"]');
41
+ expect([...swatches].map((s) => s.dataset.shape)).toEqual(["square", "circle"]);
42
+ });
43
+
44
+ it("portals into the strip below the map when positioned below", async () => {
45
+ render(
46
+ <MapCanvas>
47
+ <MapLegend position="below" items={[{ label: "Lake", color: "var(--chart-1)" }]} />
48
+ </MapCanvas>,
49
+ );
50
+ await screen.findByText("Lake");
51
+ const legend = document.querySelector('[data-slot="map-legend"]')!;
52
+ expect(document.querySelector('[data-slot="map-canvas-below"]')!.contains(legend)).toBe(true);
53
+ expect(document.querySelector('[data-slot="map-canvas"]')!.contains(legend)).toBe(false);
54
+ });
55
+
56
+ it("keys a stepped scale with token-reference swatches only", async () => {
57
+ render(
58
+ <MapCanvas>
59
+ <MapLegend scale={{ values: VALUES, spec: { type: "stepped", steps: 4 } }} />
60
+ </MapCanvas>,
61
+ );
62
+ await waitFor(() =>
63
+ expect(document.querySelectorAll('[data-slot="map-legend-step"]')).toHaveLength(4),
64
+ );
65
+ const steps = document.querySelectorAll<HTMLElement>('[data-slot="map-legend-step"]');
66
+ for (const step of steps) {
67
+ expect(step.getAttribute("style")).toMatch(/^background: var\(--chart-seq-\d\);$/);
68
+ }
69
+ // 4 steps → 5 boundary ticks.
70
+ expect(document.querySelectorAll('[data-slot="map-legend-tick"]')).toHaveLength(5);
71
+ });
72
+
73
+ it("draws a continuous scale as a gradient of token references", async () => {
74
+ const scale = colorScaleFor(VALUES, { type: "continuous" });
75
+ render(
76
+ <MapCanvas>
77
+ <MapLegend scale={scale} formatValue={(v) => `${v}%`} />
78
+ </MapCanvas>,
79
+ );
80
+ const gradient = await waitFor(() => {
81
+ const el = document.querySelector<HTMLElement>('[data-slot="map-legend-gradient"]');
82
+ expect(el).not.toBeNull();
83
+ return el!;
84
+ });
85
+ const style = gradient.getAttribute("style") ?? "";
86
+ expect(style).toContain("linear-gradient(to right, var(--chart-seq-1) 0%");
87
+ expect(style).not.toMatch(/#[0-9a-f]{3,8}\b|rgb\(|oklch\(/i);
88
+ expect(screen.getByText("3%")).toBeInTheDocument();
89
+ expect(screen.getByText("60%")).toBeInTheDocument();
90
+ });
91
+
92
+ it("lists a categorical scale as swatch rows", async () => {
93
+ render(
94
+ <MapCanvas>
95
+ <MapLegend
96
+ scale={{
97
+ values: ["Park", "Water", "Park"],
98
+ spec: { type: "stepped", palette: "categorical" },
99
+ }}
100
+ />
101
+ </MapCanvas>,
102
+ );
103
+ expect(await screen.findByText("Park")).toBeInTheDocument();
104
+ expect(screen.getByText("Water")).toBeInTheDocument();
105
+ expect(document.querySelector('[data-slot="map-legend-ramp"]')).toBeNull();
106
+ });
107
+ });
108
+
109
+ describe("MapLegend — a grid key spends the whole strip (c-5)", () => {
110
+ it("lays the grid out with auto-fit, so empty tracks collapse", async () => {
111
+ render(
112
+ <MapCanvas>
113
+ <MapLegend
114
+ layout="grid"
115
+ items={[
116
+ { id: "lake", label: "Lake Ontario", color: "var(--chart-1)", shape: "square" },
117
+ { id: "gta", label: "Greater Toronto Area", color: "var(--chart-2)", shape: "square" },
118
+ { id: "city", label: "City", color: "var(--chart-4)", shape: "circle" },
119
+ { id: "ferry", label: "Ferry route", color: "var(--chart-3)", shape: "line" },
120
+ ]}
121
+ />
122
+ </MapCanvas>,
123
+ );
124
+ const list = await waitFor(() => {
125
+ const found = document.querySelector('[data-slot="map-legend-items"]');
126
+ expect(found).not.toBeNull();
127
+ return found!;
128
+ });
129
+ // `auto-fill` kept 7 tracks of 113.7 px on an 868 px strip for four keys,
130
+ // so "Greater Toronto Area" truncated (scrollWidth 123 vs clientWidth 98)
131
+ // with three empty tracks beside it. jsdom lays out nothing, so the
132
+ // measured proof lives in the story; this locks the layout rule itself.
133
+ expect(list.className).toContain("repeat(auto-fit,minmax(7rem,1fr))");
134
+ expect(list.className).not.toContain("auto-fill");
135
+ });
136
+ });
@@ -0,0 +1,264 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useMemo, type HTMLAttributes, type ReactNode } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import {
6
+ colorScaleFor,
7
+ useLocale,
8
+ type ColorScale,
9
+ type ColorScaleSpec,
10
+ type ColorScaleValue,
11
+ } from "@elabs-ai/components-ui";
12
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
13
+
14
+ import { type MapResponsive, useMapFrameSlot, useMapResponsive } from "../lib/use-map-breakpoint";
15
+ import { MAP_CORNER_CLASSES, type MapCorner } from "./map-corner";
16
+
17
+ /** The mark a key row shows: a point, an area or a line marker. */
18
+ export type MapLegendShape = "circle" | "square" | "line";
19
+
20
+ /** One row of a marker key. */
21
+ export interface MapLegendItem {
22
+ /** Stable identity; defaults to `label`. */
23
+ id?: string;
24
+ label: string;
25
+ /** Any CSS colour — use a token reference (`var(--chart-1)`), never a literal. */
26
+ color: string;
27
+ /** Default `"circle"`. */
28
+ shape?: MapLegendShape;
29
+ }
30
+
31
+ /**
32
+ * A colour scale to key: one `colorScaleFor` already built (share it with the
33
+ * layer it colours), or the values + spec to build one here.
34
+ */
35
+ export type MapLegendScale =
36
+ | ColorScale
37
+ | { values: readonly ColorScaleValue[]; spec: ColorScaleSpec };
38
+
39
+ /** Outside the map box (`above` / `below`) or over one of its corners. */
40
+ export type MapLegendPosition = "above" | "below" | MapCorner;
41
+
42
+ /** `list`: one row per item. `grid`: items flow into as many columns as fit. */
43
+ export type MapLegendLayout = "list" | "grid";
44
+
45
+ export interface MapLegendProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
46
+ /** A heading for the key. */
47
+ title?: ReactNode;
48
+ /** Marker key rows. */
49
+ items?: readonly MapLegendItem[];
50
+ /** A colour ramp (continuous / stepped) or category key, from `colorScaleFor`. */
51
+ scale?: MapLegendScale;
52
+ /** Default `"list"`. */
53
+ layout?: MapLegendLayout;
54
+ /**
55
+ * Where the key sits, optionally per tier — for example
56
+ * `{ base: "top-left", narrow: "below" }` so it stops covering a phone-width
57
+ * map. Default `"above"`.
58
+ */
59
+ position?: MapResponsive<MapLegendPosition>;
60
+ /** Formats a ramp's tick values (default: the locale's number format). */
61
+ formatValue?: (value: number) => string;
62
+ }
63
+
64
+ const SHAPE_CLASSES: Record<MapLegendShape, string> = {
65
+ circle: "size-2.5 rounded-full",
66
+ square: "size-2.5 rounded-xs",
67
+ line: "h-0.5 w-3.5 rounded-full",
68
+ };
69
+
70
+ const LAYOUT_CLASSES: Record<MapLegendLayout, string> = {
71
+ list: "flex flex-col gap-1",
72
+ // `auto-fit`, never `auto-fill` (c-5): `auto-fill` keeps the tracks a wide
73
+ // strip could hold even when nothing occupies them, so four keys on an
74
+ // 868 px strip got 113.7 px each and "Greater Toronto Area" truncated with
75
+ // three empty tracks beside it. `auto-fit` collapses the empty tracks and
76
+ // the real keys share the full width; a key with more items than fit wraps
77
+ // exactly as before.
78
+ grid: "grid grid-cols-[repeat(auto-fit,minmax(7rem,1fr))] gap-x-3 gap-y-1",
79
+ };
80
+
81
+ function isColorScale(scale: MapLegendScale): scale is ColorScale {
82
+ return "colorOf" in scale;
83
+ }
84
+
85
+ function Swatch({ color, shape = "circle" }: { color: string; shape?: MapLegendShape }) {
86
+ return (
87
+ <span
88
+ aria-hidden="true"
89
+ data-slot="map-legend-swatch"
90
+ data-shape={shape}
91
+ className={cn("inline-block shrink-0", SHAPE_CLASSES[shape])}
92
+ style={{ background: color }}
93
+ />
94
+ );
95
+ }
96
+
97
+ interface Tick {
98
+ key: string;
99
+ at: number;
100
+ label: string;
101
+ }
102
+
103
+ /** A ramp: stepped swatches or a continuous gradient, with value ticks under it. */
104
+ function Ramp({ scale, format }: { scale: ColorScale; format: (value: number) => string }) {
105
+ const ticks: Tick[] = [];
106
+ let strip: ReactNode = null;
107
+
108
+ if (scale.type === "stepped" && scale.steps.length > 0) {
109
+ const n = scale.steps.length;
110
+ strip = (
111
+ <div className="flex h-2.5 w-full overflow-hidden rounded-xs">
112
+ {scale.steps.map((step) => (
113
+ <span
114
+ key={`${step.from}-${step.to}`}
115
+ data-slot="map-legend-step"
116
+ className="min-w-0 flex-1"
117
+ style={{ background: step.color }}
118
+ />
119
+ ))}
120
+ </div>
121
+ );
122
+ const first = scale.steps[0];
123
+ if (first) ticks.push({ key: "start", at: 0, label: format(first.from) });
124
+ scale.steps.forEach((step, index) => {
125
+ ticks.push({ key: `to-${step.to}`, at: (index + 1) / n, label: format(step.to) });
126
+ });
127
+ } else if (scale.stops.length > 0) {
128
+ const last = Math.max(1, scale.stops.length - 1);
129
+ const gradient = scale.stops
130
+ .map((stop, index) => `${stop.color} ${(index / last) * 100}%`)
131
+ .join(", ");
132
+ strip = (
133
+ <div
134
+ data-slot="map-legend-gradient"
135
+ className="h-2.5 w-full rounded-xs"
136
+ style={{ background: `linear-gradient(to right, ${gradient})` }}
137
+ />
138
+ );
139
+ const marks = [scale.domain?.[0], scale.center ?? undefined, scale.domain?.[1]];
140
+ for (const value of marks) {
141
+ if (value === undefined) continue;
142
+ const at = scale.positionOf(value);
143
+ if (at === null) continue;
144
+ ticks.push({ key: `v-${value}`, at, label: format(value) });
145
+ }
146
+ }
147
+
148
+ if (!strip) return null;
149
+ return (
150
+ // A ramp reads low → high left to right in every writing direction.
151
+ <div dir="ltr" data-slot="map-legend-ramp" className="flex w-48 max-w-full flex-col gap-1">
152
+ {strip}
153
+ <div className="relative h-4">
154
+ {ticks.map((tick) => (
155
+ <span
156
+ key={tick.key}
157
+ data-slot="map-legend-tick"
158
+ className={cn(
159
+ "absolute top-0 text-meta whitespace-nowrap text-muted-foreground tabular-nums",
160
+ tick.at <= 0 ? "" : tick.at >= 1 ? "-translate-x-full" : "-translate-x-1/2",
161
+ )}
162
+ style={{ left: `${Math.min(1, Math.max(0, tick.at)) * 100}%` }}
163
+ >
164
+ {tick.label}
165
+ </span>
166
+ ))}
167
+ </div>
168
+ </div>
169
+ );
170
+ }
171
+
172
+ /**
173
+ * MapLegend — the map's key: marker rows (`items`) and / or a colour ramp
174
+ * (`scale`, built with `colorScaleFor` from `@elabs-ai/components-ui`, so its
175
+ * colours are token references in every theme). It sits above or below the
176
+ * map box, or over a corner, per tier; the house legend look (`text-meta`,
177
+ * dot markers) matches `charts`' `ChartLegend`. Render inside `<MapCanvas>`.
178
+ */
179
+ export const MapLegend = forwardRef<HTMLDivElement, MapLegendProps>(function MapLegend(
180
+ {
181
+ title,
182
+ items,
183
+ scale: scaleInput,
184
+ layout = "list",
185
+ position: positionProp = "above",
186
+ formatValue,
187
+ className,
188
+ ...props
189
+ },
190
+ ref,
191
+ ) {
192
+ const { formatNumber } = useLocale();
193
+ const position = useMapResponsive(positionProp);
194
+ const outside = position === "above" || position === "below" ? position : null;
195
+ const slot = useMapFrameSlot(outside);
196
+
197
+ const scale = useMemo<ColorScale | null>(() => {
198
+ if (!scaleInput) return null;
199
+ return isColorScale(scaleInput)
200
+ ? scaleInput
201
+ : colorScaleFor(scaleInput.values, scaleInput.spec);
202
+ }, [scaleInput]);
203
+ const format = formatValue ?? ((value: number) => formatNumber(value));
204
+
205
+ const rows: MapLegendItem[] = [
206
+ ...(items ?? []),
207
+ ...(scale?.palette === "categorical"
208
+ ? scale.categories.map((category) => ({
209
+ id: `category-${category.value}`,
210
+ label: String(category.value),
211
+ color: category.color,
212
+ shape: "square" as const,
213
+ }))
214
+ : []),
215
+ ];
216
+ const showRamp = scale !== null && scale.palette !== "categorical";
217
+
218
+ const body = (
219
+ <div
220
+ ref={ref}
221
+ data-slot="map-legend"
222
+ data-position={position}
223
+ data-layout={layout}
224
+ className={cn(
225
+ "flex flex-col gap-1.5 text-meta text-foreground",
226
+ outside
227
+ ? "w-full"
228
+ : cn(
229
+ "absolute z-10 max-w-[calc(100%-1rem)] rounded-md bg-background/90 px-2 py-1.5 shadow-ring-sm",
230
+ MAP_CORNER_CLASSES[position as MapCorner],
231
+ ),
232
+ className,
233
+ )}
234
+ {...props}
235
+ >
236
+ {title && (
237
+ <div data-slot="map-legend-title" className="font-medium">
238
+ {title}
239
+ </div>
240
+ )}
241
+ {rows.length > 0 && (
242
+ <ul
243
+ data-slot="map-legend-items"
244
+ className={cn("m-0 list-none p-0", LAYOUT_CLASSES[layout])}
245
+ >
246
+ {rows.map((item) => (
247
+ <li
248
+ key={item.id ?? item.label}
249
+ data-slot="map-legend-item"
250
+ className="flex min-w-0 items-center gap-1.5"
251
+ >
252
+ <Swatch color={item.color} shape={item.shape} />
253
+ <span className="min-w-0 truncate">{item.label}</span>
254
+ </li>
255
+ ))}
256
+ </ul>
257
+ )}
258
+ {showRamp && scale && <Ramp scale={scale} format={format} />}
259
+ </div>
260
+ );
261
+
262
+ if (outside) return slot ? createPortal(body, slot) : null;
263
+ return body;
264
+ });
@@ -1,7 +1,10 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { MapPin } from "lucide-react";
3
+ import { expect, waitFor } from "storybook/test";
3
4
 
4
5
  import { MapCanvas } from "../map-canvas";
6
+ import { MAP_LABEL_ANCHORS } from "../map-annotation";
7
+ import { LOCATOR_BOUNDS, LOCATOR_PLACES } from "../test-utils/locator-fixture";
5
8
  import {
6
9
  MapMarker,
7
10
  MapMarkerContent,
@@ -32,11 +35,24 @@ export const Default: Story = {
32
35
  ),
33
36
  };
34
37
 
38
+ /** Both markers, with room for the wider label under the second one (c-8). */
39
+ const ALEXANDERPLATZ: [number, number] = [13.405, 52.52];
40
+ const BRANDENBURG_GATE: [number, number] = [13.377, 52.516];
41
+
35
42
  export const WithPopupAndTooltip: Story = {
36
43
  render: () => (
37
44
  <div className="h-[480px]">
38
- <MapCanvas center={[13.41, 52.52]} zoom={12}>
39
- <MapMarker longitude={13.405} latitude={52.52}>
45
+ {/*
46
+ c-8: a fixed center/zoom framed both markers at 900 px only — at
47
+ 380 px the second marker sat half off the start edge and its label
48
+ read "…urg Gate". Fitting the markers keeps both inside at every
49
+ width; the padding is the room the wider label needs.
50
+ */}
51
+ <MapCanvas
52
+ bounds={[BRANDENBURG_GATE, ALEXANDERPLATZ]}
53
+ fitBoundsOptions={{ padding: 72, maxZoom: 14 }}
54
+ >
55
+ <MapMarker longitude={ALEXANDERPLATZ[0]} latitude={ALEXANDERPLATZ[1]}>
40
56
  <MapMarkerContent />
41
57
  <MapMarkerTooltip>Click for details</MapMarkerTooltip>
42
58
  <MapMarkerPopup closeButton>
@@ -46,7 +62,7 @@ export const WithPopupAndTooltip: Story = {
46
62
  </p>
47
63
  </MapMarkerPopup>
48
64
  </MapMarker>
49
- <MapMarker longitude={13.377} latitude={52.516}>
65
+ <MapMarker longitude={BRANDENBURG_GATE[0]} latitude={BRANDENBURG_GATE[1]}>
50
66
  <MapMarkerContent>
51
67
  <MapPin className="size-6 text-primary" aria-hidden="true" />
52
68
  </MapMarkerContent>
@@ -61,11 +77,127 @@ export const Draggable: Story = {
61
77
  render: () => (
62
78
  <div className="h-[480px]">
63
79
  <MapCanvas center={[13.405, 52.52]} zoom={11}>
64
- <MapMarker longitude={13.405} latitude={52.52} draggable>
80
+ <MapMarker longitude={13.405} latitude={52.52} draggable dragLabel="Depot location">
65
81
  <MapMarkerContent />
66
82
  <MapMarkerLabel>Drag me</MapMarkerLabel>
67
83
  </MapMarker>
68
84
  </MapCanvas>
69
85
  </div>
70
86
  ),
87
+ play: async ({ canvasElement }) => {
88
+ // c-4 (WCAG 2.1.1): the story's whole subject is a marker you move, so it
89
+ // has to be movable without a mouse. Before the fix the only tab stop in
90
+ // the story was the map canvas and `element.focus()` did not take.
91
+ const marker = await waitFor(() => {
92
+ const element = canvasElement.querySelector<HTMLElement>(".maplibregl-marker");
93
+ expect(element).not.toBeNull();
94
+ return element as HTMLElement;
95
+ });
96
+ await expect(marker).toHaveAttribute("tabindex", "0");
97
+ await expect(marker).toHaveAccessibleName("Depot location");
98
+
99
+ marker.focus();
100
+ await expect(document.activeElement).toBe(marker);
101
+
102
+ const before = marker.getBoundingClientRect().x;
103
+ marker.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
104
+ marker.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowRight", bubbles: true }));
105
+ await waitFor(() => expect(marker.getBoundingClientRect().x).toBeGreaterThan(before));
106
+ },
107
+ };
108
+
109
+ // A marker's own label has no de-confliction pass: it goes where its anchor
110
+ // puts it, however close the next marker is. The boxed row's labels offset
111
+ // OUTWARD from their point, so at the narrow tier — where a degree of
112
+ // longitude is ~100px and a boxed label is up to 71px wide — two neighbours
113
+ // print on top of each other. That row is therefore shown from `medium` up
114
+ // (`showAt`), which is the prop the docblock already points at.
115
+ const LABEL_SPOTS = [
116
+ { longitude: -79.6, latitude: 44.05 },
117
+ { longitude: -78.6, latitude: 44.05 },
118
+ { longitude: -77.6, latitude: 44.05 },
119
+ { longitude: -76.6, latitude: 44.05 },
120
+ { longitude: -79.6, latitude: 43.45 },
121
+ { longitude: -78.6, latitude: 43.45 },
122
+ { longitude: -77.6, latitude: 43.45 },
123
+ { longitude: -76.6, latitude: 43.45 },
124
+ ] as const;
125
+
126
+ /**
127
+ * A marker's own label, at each of the eight positions around its point;
128
+ * the lower row adds a box (a plate that keeps the text readable over busy
129
+ * ground) and a callout line.
130
+ */
131
+ export const LabelPositions: Story = {
132
+ render: () => (
133
+ <div className="w-full p-4">
134
+ <MapCanvas interactive={false} bounds={LOCATOR_BOUNDS} fitBoundsOptions={{ padding: 48 }}>
135
+ {MAP_LABEL_ANCHORS.map((position, index) => (
136
+ <MapMarker
137
+ key={position}
138
+ {...LABEL_SPOTS[index]!}
139
+ label={{ text: position, position, box: index >= 4, callout: index >= 4 }}
140
+ showAt={index >= 4 ? { base: true, narrow: false } : true}
141
+ >
142
+ <MapMarkerContent />
143
+ </MapMarker>
144
+ ))}
145
+ </MapCanvas>
146
+ </div>
147
+ ),
148
+ play: async ({ canvasElement }) => {
149
+ // Two labels printed through each other read as neither. Measured, per
150
+ // tier: the boxed row is off at narrow, so every tier comes out clean.
151
+ await waitFor(() =>
152
+ expect(
153
+ canvasElement.querySelectorAll('[data-slot="map-marker-label"]').length,
154
+ ).toBeGreaterThan(0),
155
+ );
156
+ const boxes = [
157
+ ...canvasElement.querySelectorAll<HTMLElement>('[data-slot="map-marker-label"]'),
158
+ ].map((el) => el.getBoundingClientRect());
159
+ const overlaps: string[] = [];
160
+ for (const [i, a] of boxes.entries()) {
161
+ for (const b of boxes.slice(i + 1)) {
162
+ const x = Math.min(a.right, b.right) - Math.max(a.left, b.left);
163
+ const y = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
164
+ if (x > 0 && y > 0) overlaps.push(`${Math.round(x * y)}px²`);
165
+ }
166
+ }
167
+ await expect(overlaps).toEqual([]);
168
+ },
169
+ };
170
+
171
+ /**
172
+ * A label with no marker glyph — text placed on the map (a lake or region
173
+ * name) — plus a marker that only shows from `medium` up.
174
+ */
175
+ export const LabelOnlyAndShowAt: Story = {
176
+ render: () => (
177
+ <div className="w-full p-4">
178
+ <MapCanvas interactive={false} bounds={LOCATOR_BOUNDS} fitBoundsOptions={{ padding: 48 }}>
179
+ <MapMarker {...LOCATOR_PLACES.lakeLabel} label={{ text: "Lake Ontario", box: true }} />
180
+ <MapMarker {...LOCATOR_PLACES.toronto} label={{ text: "Toronto", position: "top-left" }}>
181
+ <MapMarkerContent />
182
+ </MapMarker>
183
+ <MapMarker
184
+ {...LOCATOR_PLACES.rochester}
185
+ showAt={{ base: true, narrow: false }}
186
+ label={{ text: "Rochester, NY", position: "right" }}
187
+ >
188
+ <MapMarkerContent />
189
+ </MapMarker>
190
+ </MapCanvas>
191
+ </div>
192
+ ),
193
+ play: async ({ canvasElement }) => {
194
+ const map = canvasElement.querySelector<HTMLElement>('[data-slot="map-canvas"]');
195
+ if (!map || !canvasElement.querySelector(".maplibregl-canvas")) return;
196
+ const narrow = map.getAttribute("data-map-breakpoint") === "narrow";
197
+ await waitFor(() =>
198
+ expect(canvasElement.querySelectorAll('[data-slot="map-marker-label"]')).toHaveLength(
199
+ narrow ? 2 : 3,
200
+ ),
201
+ );
202
+ },
71
203
  };