@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
@@ -41,3 +41,19 @@ export const TopLeft: Story = {
41
41
  </div>
42
42
  ),
43
43
  };
44
+
45
+ /**
46
+ * A static editorial map (`interactive={false}`) shows no zoom chrome: the
47
+ * gestures those buttons invite are switched off, so the affordance would lie.
48
+ * The host can still ask for a group explicitly — the fullscreen toggle here
49
+ * works either way.
50
+ */
51
+ export const StaticMap: Story = {
52
+ render: () => (
53
+ <div className="h-[480px]">
54
+ <MapCanvas center={[2.3522, 48.8566]} interactive={false} zoom={11}>
55
+ <MapControls showFullscreen />
56
+ </MapCanvas>
57
+ </div>
58
+ ),
59
+ };
@@ -1,6 +1,7 @@
1
- import { cleanup, render, screen } from "@testing-library/react";
1
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
2
2
  import userEvent from "@testing-library/user-event";
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { LocaleProvider } from "@elabs-ai/components-ui";
4
5
 
5
6
  vi.mock("maplibre-gl", async () => {
6
7
  const { createMaplibreMock } = await import("../test-utils/maplibre-mock");
@@ -30,6 +31,40 @@ describe("MapControls", () => {
30
31
  ).not.toBeInTheDocument();
31
32
  });
32
33
 
34
+ it("hides zoom chrome on a static map, and renders no control box at all", async () => {
35
+ const { container } = render(
36
+ <MapCanvas interactive={false}>
37
+ <MapControls />
38
+ </MapCanvas>,
39
+ );
40
+ await waitFor(() => {
41
+ expect(container.querySelector("[data-slot='map-canvas']")).not.toBeNull();
42
+ });
43
+ expect(screen.queryByRole("button", { name: "Zoom in" })).not.toBeInTheDocument();
44
+ expect(screen.queryByRole("button", { name: "Zoom out" })).not.toBeInTheDocument();
45
+ // No empty, absolutely-positioned control box left behind either.
46
+ expect(container.querySelector(".absolute.z-10")).toBeNull();
47
+ });
48
+
49
+ it("keeps zoom chrome on a static map when the host asks for it", async () => {
50
+ render(
51
+ <MapCanvas interactive={false}>
52
+ <MapControls showZoom />
53
+ </MapCanvas>,
54
+ );
55
+ expect(await screen.findByRole("button", { name: "Zoom in" })).toBeInTheDocument();
56
+ });
57
+
58
+ it("still renders an explicitly enabled group on a static map", async () => {
59
+ render(
60
+ <MapCanvas interactive={false}>
61
+ <MapControls showFullscreen />
62
+ </MapCanvas>,
63
+ );
64
+ expect(await screen.findByRole("button", { name: "Toggle fullscreen" })).toBeInTheDocument();
65
+ expect(screen.queryByRole("button", { name: "Zoom in" })).not.toBeInTheDocument();
66
+ });
67
+
33
68
  it("renders compass, locate and fullscreen controls when enabled", async () => {
34
69
  render(
35
70
  <MapCanvas>
@@ -53,4 +88,113 @@ describe("MapControls", () => {
53
88
  await user.click(await screen.findByRole("button", { name: "Zoom in" }));
54
89
  expect(MockMap.instances[0]!.zoomToCalls.length).toBeGreaterThan(0);
55
90
  });
91
+
92
+ it("takes every label from the locale seam, so an app can translate them", async () => {
93
+ render(
94
+ <LocaleProvider
95
+ locale="de-DE"
96
+ messages={{
97
+ "maps.controls.zoomIn": "Hineinzoomen",
98
+ "maps.controls.zoomOut": "Herauszoomen",
99
+ "maps.controls.resetBearing": "Nach Norden ausrichten",
100
+ "maps.controls.locate": "Meinen Standort finden",
101
+ "maps.controls.fullscreen": "Vollbild umschalten",
102
+ "maps.controls.fit": "Ganzen Plan zeigen",
103
+ }}
104
+ >
105
+ <MapCanvas plan={{ width: 100, height: 50 }}>
106
+ <MapControls showCompass showLocate showFullscreen />
107
+ </MapCanvas>
108
+ </LocaleProvider>,
109
+ );
110
+
111
+ for (const name of [
112
+ "Hineinzoomen",
113
+ "Herauszoomen",
114
+ "Nach Norden ausrichten",
115
+ "Meinen Standort finden",
116
+ "Ganzen Plan zeigen",
117
+ "Vollbild umschalten",
118
+ ]) {
119
+ expect(await screen.findByRole("button", { name })).toBeInTheDocument();
120
+ }
121
+ });
122
+ });
123
+
124
+ describe("MapControls fit button", () => {
125
+ it("stays off a geographic map and appears on a plan", async () => {
126
+ const { unmount } = render(
127
+ <MapCanvas>
128
+ <MapControls />
129
+ </MapCanvas>,
130
+ );
131
+ expect(await screen.findByRole("button", { name: "Zoom in" })).toBeInTheDocument();
132
+ expect(screen.queryByRole("button", { name: "Fit the whole plan" })).not.toBeInTheDocument();
133
+ unmount();
134
+
135
+ render(
136
+ <MapCanvas plan={{ width: 100, height: 50 }}>
137
+ <MapControls />
138
+ </MapCanvas>,
139
+ );
140
+ expect(await screen.findByRole("button", { name: "Fit the whole plan" })).toBeInTheDocument();
141
+ });
142
+
143
+ it("frames the plan's own extent and reports back", async () => {
144
+ const user = userEvent.setup();
145
+ const onFit = vi.fn();
146
+ render(
147
+ <MapCanvas plan={{ width: 100, height: 50 }}>
148
+ <MapControls onFit={onFit} />
149
+ </MapCanvas>,
150
+ );
151
+
152
+ const map = MockMap.instances[0]!;
153
+ const before = map.fitBoundsCalls.length;
154
+ await user.click(await screen.findByRole("button", { name: "Fit the whole plan" }));
155
+
156
+ expect(map.fitBoundsCalls.length).toBe(before + 1);
157
+ expect(map.fitBoundsCalls.at(-1)!.options).toMatchObject({ padding: 24 });
158
+ expect(onFit).toHaveBeenCalledTimes(1);
159
+ });
160
+
161
+ it("frames caller bounds when they are given", async () => {
162
+ const user = userEvent.setup();
163
+ const bounds: [[number, number], [number, number]] = [
164
+ [-1, -2],
165
+ [3, 4],
166
+ ];
167
+ render(
168
+ <MapCanvas plan={{ width: 100, height: 50 }}>
169
+ <MapControls fitBounds={bounds} />
170
+ </MapCanvas>,
171
+ );
172
+
173
+ const map = MockMap.instances[0]!;
174
+ await user.click(await screen.findByRole("button", { name: "Fit the whole plan" }));
175
+
176
+ expect(map.fitBoundsCalls.at(-1)!.bounds).toEqual(bounds);
177
+ });
178
+
179
+ // A static plan map drops the zoom chrome but still needs the fit button: it is
180
+ // the only way back for a reader who cannot pan.
181
+ it("survives a static plan map, where the zoom chrome is gone", async () => {
182
+ render(
183
+ <MapCanvas interactive={false} plan={{ width: 100, height: 50 }}>
184
+ <MapControls />
185
+ </MapCanvas>,
186
+ );
187
+ expect(await screen.findByRole("button", { name: "Fit the whole plan" })).toBeInTheDocument();
188
+ expect(screen.queryByRole("button", { name: "Zoom in" })).not.toBeInTheDocument();
189
+ });
190
+
191
+ it("can be turned off on a plan", async () => {
192
+ render(
193
+ <MapCanvas plan={{ width: 100, height: 50 }}>
194
+ <MapControls showFit={false} />
195
+ </MapCanvas>,
196
+ );
197
+ expect(await screen.findByRole("button", { name: "Zoom in" })).toBeInTheDocument();
198
+ expect(screen.queryByRole("button", { name: "Fit the whole plan" })).not.toBeInTheDocument();
199
+ });
56
200
  });
@@ -1,16 +1,21 @@
1
1
  "use client";
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
4
- import { Locate, Maximize, Minus, Plus } from "lucide-react";
5
- import { Spinner } from "@elabs-ai/components-ui";
4
+ import { Locate, Maximize, Minus, Plus, Scan } from "lucide-react";
5
+ import { Spinner, useLocale } from "@elabs-ai/components-ui";
6
6
  import { cn } from "@elabs-ai/components-ui/lib/cn";
7
7
 
8
+ import { useMapFrame } from "../lib/use-map-breakpoint";
8
9
  import { useMap } from "../map-canvas/map-context";
9
10
 
10
11
  export interface MapControlsProps {
11
12
  /** Position of the controls on the map (default: "bottom-right"). */
12
13
  position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
13
- /** Show zoom in/out buttons (default: true). */
14
+ /**
15
+ * Show zoom in/out buttons. Default: true on an interactive map, false on a
16
+ * static one (`<MapCanvas interactive={false}>`), whose whole point is a
17
+ * fixed view — set `showZoom` explicitly to override either way.
18
+ */
14
19
  showZoom?: boolean;
15
20
  /** Show a compass button to reset bearing/pitch (default: false). */
16
21
  showCompass?: boolean;
@@ -18,6 +23,18 @@ export interface MapControlsProps {
18
23
  showLocate?: boolean;
19
24
  /** Show a fullscreen toggle button (default: false). */
20
25
  showFullscreen?: boolean;
26
+ /**
27
+ * Show a button that frames the whole plan again — a plan map has an edge to
28
+ * come back to, so this defaults to ON there and off on a geographic map.
29
+ */
30
+ showFit?: boolean;
31
+ /**
32
+ * What the fit button frames. Defaults to the plan's own extent; pass bounds
33
+ * to frame something else (one wing, the selected cluster).
34
+ */
35
+ fitBounds?: [[number, number], [number, number]];
36
+ /** Called after the camera has been asked to frame the plan. */
37
+ onFit?: () => void;
21
38
  /** Additional CSS classes for the controls container. */
22
39
  className?: string;
23
40
  /** Callback with user coordinates when located. */
@@ -74,16 +91,26 @@ function ControlButton({
74
91
  /** Branded zoom / compass / locate / fullscreen controls. Render inside `<MapCanvas>`. */
75
92
  export function MapControls({
76
93
  position = "bottom-right",
77
- showZoom = true,
94
+ showZoom,
78
95
  showCompass = false,
79
96
  showLocate = false,
80
97
  showFullscreen = false,
98
+ showFit,
99
+ fitBounds,
81
100
  className,
82
101
  onLocate,
83
102
  onLocateError,
103
+ onFit,
84
104
  }: MapControlsProps) {
85
- const { map } = useMap();
105
+ const { map, plan } = useMap();
106
+ const { t } = useLocale();
107
+ const { interactive } = useMapFrame();
108
+ // A static map (`interactive={false}`) is a fixed editorial view: zoom chrome
109
+ // invites a gesture the map will not answer, and a click leaves the reader on
110
+ // a frame they cannot pan back from. The host may still ask for it.
111
+ const zoomVisible = showZoom ?? interactive;
86
112
  const [waitingForLocation, setWaitingForLocation] = useState(false);
113
+ const fitVisible = showFit ?? plan != null;
87
114
 
88
115
  const handleZoomIn = useCallback(() => {
89
116
  map?.zoomTo(map.getZoom() + 1, { duration: 300 });
@@ -121,6 +148,13 @@ export function MapControls({
121
148
  );
122
149
  }, [map, onLocate, onLocateError]);
123
150
 
151
+ const handleFit = useCallback(() => {
152
+ const bounds = fitBounds ?? plan?.bounds;
153
+ if (!map || !bounds) return;
154
+ map.fitBounds(bounds, { padding: 24, duration: 300 });
155
+ onFit?.();
156
+ }, [fitBounds, map, onFit, plan]);
157
+
124
158
  const handleFullscreen = useCallback(() => {
125
159
  const container = map?.getContainer();
126
160
  if (!container) return;
@@ -131,16 +165,19 @@ export function MapControls({
131
165
  }
132
166
  }, [map]);
133
167
 
168
+ // Nothing to show (a static map with no explicit group) renders no box at all.
169
+ if (!zoomVisible && !fitVisible && !showCompass && !showLocate && !showFullscreen) return null;
170
+
134
171
  return (
135
172
  <div
136
173
  className={cn("absolute z-10 flex flex-col gap-1.5", positionClasses[position], className)}
137
174
  >
138
- {showZoom && (
175
+ {zoomVisible && (
139
176
  <ControlGroup>
140
- <ControlButton onClick={handleZoomIn} label="Zoom in">
177
+ <ControlButton onClick={handleZoomIn} label={t("maps.controls.zoomIn")}>
141
178
  <Plus className="size-4" aria-hidden="true" />
142
179
  </ControlButton>
143
- <ControlButton onClick={handleZoomOut} label="Zoom out">
180
+ <ControlButton onClick={handleZoomOut} label={t("maps.controls.zoomOut")}>
144
181
  <Minus className="size-4" aria-hidden="true" />
145
182
  </ControlButton>
146
183
  </ControlGroup>
@@ -154,20 +191,27 @@ export function MapControls({
154
191
  <ControlGroup>
155
192
  <ControlButton
156
193
  onClick={handleLocate}
157
- label="Find my location"
194
+ label={t("maps.controls.locate")}
158
195
  disabled={waitingForLocation}
159
196
  >
160
197
  {waitingForLocation ? (
161
- <Spinner label="Locating" className="size-4 text-foreground" />
198
+ <Spinner label={t("maps.controls.locating")} className="size-4 text-foreground" />
162
199
  ) : (
163
200
  <Locate className="size-4" aria-hidden="true" />
164
201
  )}
165
202
  </ControlButton>
166
203
  </ControlGroup>
167
204
  )}
205
+ {fitVisible && (
206
+ <ControlGroup>
207
+ <ControlButton onClick={handleFit} label={t("maps.controls.fit")}>
208
+ <Scan className="size-4" aria-hidden="true" />
209
+ </ControlButton>
210
+ </ControlGroup>
211
+ )}
168
212
  {showFullscreen && (
169
213
  <ControlGroup>
170
- <ControlButton onClick={handleFullscreen} label="Toggle fullscreen">
214
+ <ControlButton onClick={handleFullscreen} label={t("maps.controls.fullscreen")}>
171
215
  <Maximize className="size-4" aria-hidden="true" />
172
216
  </ControlButton>
173
217
  </ControlGroup>
@@ -178,6 +222,7 @@ export function MapControls({
178
222
 
179
223
  function CompassButton({ onClick }: { onClick: () => void }) {
180
224
  const { map } = useMap();
225
+ const { t } = useLocale();
181
226
  const compassRef = useRef<SVGSVGElement>(null);
182
227
 
183
228
  useEffect(() => {
@@ -202,7 +247,7 @@ function CompassButton({ onClick }: { onClick: () => void }) {
202
247
  }, [map]);
203
248
 
204
249
  return (
205
- <ControlButton onClick={onClick} label="Reset bearing to north">
250
+ <ControlButton onClick={onClick} label={t("maps.controls.resetBearing")}>
206
251
  <svg
207
252
  ref={compassRef}
208
253
  viewBox="0 0 24 24"
@@ -1,8 +1,10 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, waitFor } from "storybook/test";
3
4
  import { resolveTokenColor } from "@elabs-ai/components-tokens";
4
5
 
5
- import { MapCanvas } from "../map-canvas";
6
+ import { MapCanvas, type MapCanvasRef } from "../map-canvas";
7
+ import { GREATER_TORONTO, LAKE_ONTARIO, LOCATOR_BOUNDS } from "../test-utils/locator-fixture";
6
8
  import { MapGeoJSON } from "./map-geojson";
7
9
 
8
10
  /**
@@ -64,6 +66,39 @@ function makeRegions(): GeoJSON.FeatureCollection<GeoJSON.Polygon, RegionProps>
64
66
 
65
67
  const regions = makeRegions();
66
68
 
69
+ /**
70
+ * The regions' own extent, as `[[west, south], [east, north]]` (c-9). A fixed
71
+ * `center`/`zoom` framed the grid for a wide box only: at 380 px two of the six
72
+ * columns fell outside the map with no edge cue and no way to pan a static map
73
+ * back to them. Fitting the data keeps every region inside the box at every
74
+ * width, exactly as the `PatternAndVignette` story already does.
75
+ */
76
+ function boundsOf(
77
+ collection: GeoJSON.FeatureCollection<GeoJSON.Polygon, RegionProps>,
78
+ ): [[number, number], [number, number]] {
79
+ let west = Infinity,
80
+ south = Infinity,
81
+ east = -Infinity,
82
+ north = -Infinity;
83
+ for (const feature of collection.features) {
84
+ for (const ring of feature.geometry.coordinates) {
85
+ for (const [lng, lat] of ring) {
86
+ west = Math.min(west, lng);
87
+ east = Math.max(east, lng);
88
+ south = Math.min(south, lat);
89
+ north = Math.max(north, lat);
90
+ }
91
+ }
92
+ }
93
+ return [
94
+ [west, south],
95
+ [east, north],
96
+ ];
97
+ }
98
+
99
+ const REGION_BOUNDS = boundsOf(regions);
100
+ const REGION_FIT = { padding: 16 };
101
+
67
102
  const meta = {
68
103
  title: "Maps/MapGeoJSON",
69
104
  component: MapGeoJSON,
@@ -73,15 +108,68 @@ const meta = {
73
108
  export default meta;
74
109
  type Story = StoryObj<typeof meta>;
75
110
 
111
+ /** The map the framing assertion below measures. */
112
+ let framedMap: MapCanvasRef | null = null;
113
+
76
114
  /** Neutral fills + hairline outlines from the theme tokens, on a blank canvas. */
77
115
  export const Default: Story = {
78
116
  render: () => (
79
117
  <div className="h-[480px]">
80
- <MapCanvas blank center={[10, 48]} zoom={3.2}>
118
+ <MapCanvas
119
+ blank
120
+ bounds={REGION_BOUNDS}
121
+ fitBoundsOptions={REGION_FIT}
122
+ ref={(map) => {
123
+ framedMap = map;
124
+ }}
125
+ >
81
126
  <MapGeoJSON data={regions} />
82
127
  </MapCanvas>
83
128
  </div>
84
129
  ),
130
+ play: async ({ canvasElement }) => {
131
+ // c-9: at phone width the fixed `center`/`zoom` put two of the six region
132
+ // columns outside the box — 38.7 % of the data's area, with no edge cue,
133
+ // no scale change and no message, on a map nobody can pan. Squeeze the
134
+ // box to the reviewer's 380 px and check every corner of the data is
135
+ // still painted inside it.
136
+ const box = canvasElement.querySelector<HTMLElement>("div");
137
+ if (!box) throw new Error("the story's map box is missing");
138
+ box.style.width = "380px";
139
+
140
+ const [[west, south], [east, north]] = REGION_BOUNDS;
141
+ await waitFor(
142
+ () => {
143
+ const map = framedMap;
144
+ expect(map).not.toBeNull();
145
+ const canvas = map!.getContainer().getBoundingClientRect();
146
+ expect(Math.round(canvas.width)).toBe(380);
147
+ for (const corner of [
148
+ [west, south],
149
+ [east, south],
150
+ [east, north],
151
+ [west, north],
152
+ ] as [number, number][]) {
153
+ const point = map!.project(corner);
154
+ expect(point.x).toBeGreaterThanOrEqual(0);
155
+ expect(point.x).toBeLessThanOrEqual(canvas.width);
156
+ expect(point.y).toBeGreaterThanOrEqual(0);
157
+ expect(point.y).toBeLessThanOrEqual(canvas.height);
158
+ }
159
+ },
160
+ { timeout: 8000 },
161
+ );
162
+ // Hand the story back its own width — a play runs in the dev Storybook
163
+ // too — and check the map re-frames itself on the way back out.
164
+ box.style.width = "";
165
+ await waitFor(() => {
166
+ const canvas = framedMap!.getContainer().getBoundingClientRect();
167
+ expect(Math.round(canvas.width)).toBeGreaterThan(380);
168
+ const point = framedMap!.project([east, north]);
169
+ expect(point.x).toBeLessThanOrEqual(canvas.width);
170
+ expect(point.x).toBeGreaterThanOrEqual(0);
171
+ });
172
+ },
85
173
  };
86
174
 
87
175
  function ChoroplethDemo() {
@@ -91,7 +179,7 @@ function ChoroplethDemo() {
91
179
  const primary = useThemedTokenColor("--primary");
92
180
  return (
93
181
  <div className="relative h-[480px]">
94
- <MapCanvas blank center={[10, 48]} zoom={3.2}>
182
+ <MapCanvas blank bounds={REGION_BOUNDS} fitBoundsOptions={REGION_FIT}>
95
183
  <MapGeoJSON<RegionProps>
96
184
  data={regions}
97
185
  promoteId="name"
@@ -109,6 +197,12 @@ function ChoroplethDemo() {
109
197
  ] as never,
110
198
  }}
111
199
  fillHoverPaint={{ "fill-opacity": 1 }}
200
+ // c-2: the fill lives in WebGL, so without this the 24 values are
201
+ // reachable by mouse only. One visually-hidden button per region
202
+ // carries the same sentence the readout prints.
203
+ featureLabel={(feature) =>
204
+ `${feature.properties.name}: ${(feature.properties.value * 100).toFixed(0)}%`
205
+ }
112
206
  onHover={(e) => setHovered(e?.feature.properties ?? null)}
113
207
  />
114
208
  </MapCanvas>
@@ -121,4 +215,60 @@ function ChoroplethDemo() {
121
215
 
122
216
  export const InteractiveHover: Story = {
123
217
  render: () => <ChoroplethDemo />,
218
+ play: async ({ canvasElement }) => {
219
+ // c-2 (WCAG 2.1.1 / 1.3.1): before the fix the only tab stop in the story
220
+ // was the map canvas, and none of the 24 region values had any DOM at all.
221
+ const list = await waitFor(() => {
222
+ const element = canvasElement.querySelector('[data-slot="map-geojson-keyboard-list"]');
223
+ expect(element).not.toBeNull();
224
+ return element as HTMLElement;
225
+ });
226
+ const buttons = [...list.querySelectorAll("button")];
227
+ await expect(buttons).toHaveLength(24);
228
+ await expect(buttons[0]).toHaveAccessibleName("Region 1: 37%");
229
+
230
+ // Focusing a region updates the very readout the pointer drives.
231
+ buttons[9]?.focus();
232
+ await waitFor(() =>
233
+ expect(canvasElement.textContent).toContain(
234
+ buttons[9]?.textContent ?? "no region under focus",
235
+ ),
236
+ );
237
+ },
238
+ };
239
+
240
+ function AreaMarkersDemo() {
241
+ const lake = useThemedTokenColor("--chart-1");
242
+ const region = useThemedTokenColor("--chart-2");
243
+ return (
244
+ <div className="w-full p-4">
245
+ <MapCanvas interactive={false} bounds={LOCATOR_BOUNDS} fitBoundsOptions={{ padding: 32 }}>
246
+ <MapGeoJSON
247
+ id="gta"
248
+ data={GREATER_TORONTO}
249
+ fillPaint={{ "fill-color": region }}
250
+ fillOpacity={0.3}
251
+ vignette={{ width: 10, opacity: 0.4 }}
252
+ linePaint={false}
253
+ />
254
+ <MapGeoJSON
255
+ id="lake"
256
+ data={LAKE_ONTARIO}
257
+ fillPaint={{ "fill-color": lake }}
258
+ pattern={{ kind: "stripes", width: 2, gap: 4 }}
259
+ vignette={{ width: 14, opacity: 0.35 }}
260
+ linePaint={{ "line-color": lake, "line-width": 1 }}
261
+ />
262
+ </MapCanvas>
263
+ </div>
264
+ );
265
+ }
266
+
267
+ /**
268
+ * Area markers for a locator: a striped fill (a second channel besides
269
+ * colour — it survives greyscale) and a soft vignette glow along the edge.
270
+ * `fillOpacity` sets the tint under the stripes.
271
+ */
272
+ export const PatternAndVignette: Story = {
273
+ render: () => <AreaMarkersDemo />,
124
274
  };