@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,216 @@
1
+ import { cleanup, render, 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 { createPlanCrs } from "../lib/plan-crs";
12
+ import { MapRoute } from "./map-route";
13
+
14
+ afterEach(() => {
15
+ cleanup();
16
+ resetMaplibreMock();
17
+ vi.restoreAllMocks();
18
+ });
19
+
20
+ const PLAN = { width: 1000, height: 500 };
21
+
22
+ function lastMap() {
23
+ return MockMap.instances.at(-1)!;
24
+ }
25
+
26
+ function sourceData(map: MockMap) {
27
+ const source = [...map.sources.values()][0]!;
28
+ return source.setDataCalls.at(-1) as GeoJSON.Feature<GeoJSON.LineString> | undefined;
29
+ }
30
+
31
+ /** A 2D context stub, so the generated chevron exists in jsdom too. */
32
+ function stubCanvasContext() {
33
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (kind: string) {
34
+ if (kind !== "2d") return null;
35
+ return {
36
+ strokeStyle: "",
37
+ lineWidth: 0,
38
+ lineCap: "butt",
39
+ lineJoin: "miter",
40
+ clearRect: () => {},
41
+ beginPath: () => {},
42
+ moveTo: () => {},
43
+ lineTo: () => {},
44
+ stroke: () => {},
45
+ getImageData: (_x: number, _y: number, width: number, height: number) => ({
46
+ data: new Uint8ClampedArray(width * height * 4),
47
+ }),
48
+ } as unknown as CanvasRenderingContext2D;
49
+ });
50
+ }
51
+
52
+ describe("MapRoute", () => {
53
+ it("passes geographic coordinates through as they were given", async () => {
54
+ const coordinates: [number, number][] = [
55
+ [13.4, 52.5],
56
+ [2.35, 48.86],
57
+ ];
58
+ render(
59
+ <MapCanvas>
60
+ <MapRoute coordinates={coordinates} />
61
+ </MapCanvas>,
62
+ );
63
+
64
+ await waitFor(() => {
65
+ expect(sourceData(lastMap())).toBeDefined();
66
+ });
67
+ expect(sourceData(lastMap())!.geometry.coordinates).toBe(coordinates);
68
+ });
69
+
70
+ it("converts plan coordinates into the plan's own lng/lat", async () => {
71
+ const coordinates: [number, number][] = [
72
+ [0, 0],
73
+ [1000, 500],
74
+ ];
75
+ render(
76
+ <MapCanvas plan={PLAN}>
77
+ <MapRoute coordinates={coordinates} />
78
+ </MapCanvas>,
79
+ );
80
+
81
+ await waitFor(() => {
82
+ expect(sourceData(lastMap())).toBeDefined();
83
+ });
84
+
85
+ const crs = createPlanCrs(PLAN);
86
+ expect(sourceData(lastMap())!.geometry.coordinates).toEqual([
87
+ crs.toLngLat({ x: 0, y: 0 }),
88
+ crs.toLngLat({ x: 1000, y: 500 }),
89
+ ]);
90
+ // The caller's array is never mutated.
91
+ expect(coordinates[1]).toEqual([1000, 500]);
92
+ });
93
+
94
+ it("draws no arrows by default", async () => {
95
+ render(
96
+ <MapCanvas>
97
+ <MapRoute
98
+ coordinates={[
99
+ [0, 0],
100
+ [10, 10],
101
+ ]}
102
+ />
103
+ </MapCanvas>,
104
+ );
105
+
106
+ await waitFor(() => {
107
+ expect(lastMap().layers.size).toBeGreaterThan(0);
108
+ });
109
+ expect([...lastMap().layers.keys()].some((id) => id.startsWith("route-arrows-"))).toBe(false);
110
+ expect(lastMap().addImageCalls).toHaveLength(0);
111
+ });
112
+
113
+ it("registers the chevron and places it along the line when asked", async () => {
114
+ stubCanvasContext();
115
+ render(
116
+ <MapCanvas>
117
+ <MapRoute
118
+ id="aisle"
119
+ coordinates={[
120
+ [0, 0],
121
+ [10, 10],
122
+ ]}
123
+ direction="forward"
124
+ />
125
+ </MapCanvas>,
126
+ );
127
+
128
+ await waitFor(() => {
129
+ expect(lastMap().getLayer("route-arrows-aisle")).toBeDefined();
130
+ });
131
+
132
+ const [id, , options] = lastMap().addImageCalls.at(-1)!;
133
+ expect(id).toBe("route-arrow-aisle");
134
+ expect(options).toEqual({ pixelRatio: 2 });
135
+
136
+ const layer = lastMap().getLayer("route-arrows-aisle");
137
+ expect(layer.type).toBe("symbol");
138
+ expect(layer.layout).toMatchObject({
139
+ "symbol-placement": "line",
140
+ "icon-image": "route-arrow-aisle",
141
+ "icon-rotate": 0,
142
+ "icon-rotation-alignment": "map",
143
+ });
144
+ // An icon, never a `text-field`: a blank style has no glyph endpoint, so
145
+ // text on a symbol layer would silently render nothing.
146
+ expect(layer.layout["text-field"]).toBeUndefined();
147
+ });
148
+
149
+ it("turns the chevron around for a backward route", async () => {
150
+ stubCanvasContext();
151
+ render(
152
+ <MapCanvas>
153
+ <MapRoute
154
+ id="belt"
155
+ coordinates={[
156
+ [0, 0],
157
+ [10, 10],
158
+ ]}
159
+ direction="backward"
160
+ />
161
+ </MapCanvas>,
162
+ );
163
+
164
+ await waitFor(() => {
165
+ expect(lastMap().getLayer("route-arrows-belt")).toBeDefined();
166
+ });
167
+ expect(lastMap().getLayer("route-arrows-belt").layout["icon-rotate"]).toBe(180);
168
+ });
169
+
170
+ it("leaves the arrow layer out when there is no canvas to draw it on", async () => {
171
+ // Unstubbed jsdom: no 2D context, so no image — and a layer pointing at a
172
+ // missing image would warn on every frame.
173
+ render(
174
+ <MapCanvas>
175
+ <MapRoute
176
+ id="aisle"
177
+ coordinates={[
178
+ [0, 0],
179
+ [10, 10],
180
+ ]}
181
+ direction="forward"
182
+ />
183
+ </MapCanvas>,
184
+ );
185
+
186
+ await waitFor(() => {
187
+ expect(lastMap().layers.size).toBeGreaterThan(0);
188
+ });
189
+ expect(lastMap().getLayer("route-arrows-aisle")).toBeUndefined();
190
+ });
191
+
192
+ it("takes its arrow layer and image away again on unmount", async () => {
193
+ stubCanvasContext();
194
+ const { unmount } = render(
195
+ <MapCanvas>
196
+ <MapRoute
197
+ id="aisle"
198
+ coordinates={[
199
+ [0, 0],
200
+ [10, 10],
201
+ ]}
202
+ direction="forward"
203
+ />
204
+ </MapCanvas>,
205
+ );
206
+
207
+ await waitFor(() => {
208
+ expect(lastMap().getLayer("route-arrows-aisle")).toBeDefined();
209
+ });
210
+ const map = lastMap();
211
+ unmount();
212
+
213
+ expect(map.getLayer("route-arrows-aisle")).toBeUndefined();
214
+ expect(map.hasImage("route-arrow-aisle")).toBe(false);
215
+ });
216
+ });
@@ -1,15 +1,22 @@
1
1
  "use client";
2
2
 
3
3
  import type MapLibreGL from "maplibre-gl";
4
- import { useEffect, useId } from "react";
4
+ import { useEffect, useId, useMemo } from "react";
5
5
 
6
6
  import { useMap } from "../map-canvas/map-context";
7
7
  import { useTokenColor } from "../lib/use-token-color";
8
+ import { createRouteArrowImage, routeArrowImageId } from "../lib/route-arrow";
9
+
10
+ /** Which way the route runs, drawn as chevrons along the line. */
11
+ export type MapRouteDirection = "none" | "forward" | "backward";
8
12
 
9
13
  export interface MapRouteProps {
10
14
  /** Optional unique identifier for the route layer. */
11
15
  id?: string;
12
- /** Array of [longitude, latitude] coordinate pairs defining the route. */
16
+ /**
17
+ * Array of [longitude, latitude] coordinate pairs defining the route — or, in
18
+ * a `<MapCanvas plan>`, [x, y] pairs in plan units.
19
+ */
13
20
  coordinates: [number, number][];
14
21
  /** Line color as a CSS color value. Defaults to the theme's `--primary` token. */
15
22
  color?: string;
@@ -19,6 +26,14 @@ export interface MapRouteProps {
19
26
  opacity?: number;
20
27
  /** Dash pattern [dash length, gap length] for dashed lines. */
21
28
  dashArray?: [number, number];
29
+ /**
30
+ * Draw chevrons along the line to show which way it runs (default: "none").
31
+ * A conveyor, a one-way aisle and a walking route all need this, and an arrow
32
+ * is a second channel that greyscale keeps.
33
+ */
34
+ direction?: MapRouteDirection;
35
+ /** Distance between chevrons in px (default: 80). */
36
+ directionSpacing?: number;
22
37
  /** Callback when the route line is clicked. */
23
38
  onClick?: () => void;
24
39
  /** Callback when the mouse enters the route line. */
@@ -37,20 +52,31 @@ export function MapRoute({
37
52
  width = 3,
38
53
  opacity = 0.8,
39
54
  dashArray,
55
+ direction = "none",
56
+ directionSpacing = 80,
40
57
  onClick,
41
58
  onMouseEnter,
42
59
  onMouseLeave,
43
60
  interactive = true,
44
61
  }: MapRouteProps) {
45
- const { map, isLoaded } = useMap();
62
+ const { map, isLoaded, plan } = useMap();
46
63
  const autoId = useId();
47
64
  const id = propId ?? autoId;
48
65
  const sourceId = `route-source-${id}`;
49
66
  const layerId = `route-layer-${id}`;
67
+ const arrowLayerId = `route-arrows-${id}`;
68
+ const arrowImageId = routeArrowImageId(id);
50
69
 
51
70
  const primary = useTokenColor("--primary");
52
71
  const lineColor = color ?? primary;
53
72
 
73
+ // On a plan map the pairs are plan units; on a geographic one they are handed
74
+ // through untouched, identity included.
75
+ const resolvedCoordinates = useMemo(
76
+ () => (plan ? coordinates.map((point) => plan.toLngLat(point)) : coordinates),
77
+ [plan, coordinates],
78
+ );
79
+
54
80
  // Add source and layer on mount.
55
81
  useEffect(() => {
56
82
  if (!isLoaded || !map) return;
@@ -90,17 +116,90 @@ export function MapRoute({
90
116
 
91
117
  // When coordinates change, update the source data.
92
118
  useEffect(() => {
93
- if (!isLoaded || !map || coordinates.length < 2) return;
119
+ if (!isLoaded || !map || resolvedCoordinates.length < 2) return;
94
120
 
95
121
  const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
96
122
  if (source) {
97
123
  source.setData({
98
124
  type: "Feature",
99
125
  properties: {},
100
- geometry: { type: "LineString", coordinates },
126
+ geometry: { type: "LineString", coordinates: resolvedCoordinates },
101
127
  });
102
128
  }
103
- }, [isLoaded, map, coordinates, sourceId]);
129
+ }, [isLoaded, map, resolvedCoordinates, sourceId]);
130
+
131
+ // Direction chevrons: a generated icon, re-registered whenever the ink
132
+ // changes, with the layer added only once the image really exists.
133
+ useEffect(() => {
134
+ if (!isLoaded || !map || direction === "none") return;
135
+
136
+ const image = createRouteArrowImage(lineColor);
137
+ if (!image) return;
138
+
139
+ let cancelled = false;
140
+
141
+ const register = () => {
142
+ try {
143
+ if (map.hasImage(arrowImageId)) {
144
+ map.updateImage(arrowImageId, image as unknown as ImageData);
145
+ } else {
146
+ map.addImage(arrowImageId, image as unknown as ImageData, { pixelRatio: 2 });
147
+ }
148
+ if (!map.getLayer(arrowLayerId)) {
149
+ map.addLayer({
150
+ id: arrowLayerId,
151
+ type: "symbol",
152
+ source: sourceId,
153
+ layout: {
154
+ "symbol-placement": "line",
155
+ "symbol-spacing": directionSpacing,
156
+ "icon-image": arrowImageId,
157
+ "icon-rotate": direction === "backward" ? 180 : 0,
158
+ "icon-rotation-alignment": "map",
159
+ "icon-allow-overlap": true,
160
+ "icon-ignore-placement": true,
161
+ },
162
+ paint: { "icon-opacity": opacity },
163
+ });
164
+ } else {
165
+ map.setLayoutProperty(arrowLayerId, "symbol-spacing", directionSpacing);
166
+ map.setLayoutProperty(arrowLayerId, "icon-rotate", direction === "backward" ? 180 : 0);
167
+ map.setPaintProperty(arrowLayerId, "icon-opacity", opacity);
168
+ }
169
+ } catch {
170
+ // style mid-reload; `styledata` re-runs this
171
+ }
172
+ };
173
+
174
+ register();
175
+ // A theme swap calls setStyle, which drops every style image AND every layer
176
+ // this component added.
177
+ const onStyleData = () => {
178
+ if (!cancelled) register();
179
+ };
180
+ map.on("styledata", onStyleData);
181
+
182
+ return () => {
183
+ cancelled = true;
184
+ map.off("styledata", onStyleData);
185
+ try {
186
+ if (map.getLayer(arrowLayerId)) map.removeLayer(arrowLayerId);
187
+ if (map.hasImage(arrowImageId)) map.removeImage(arrowImageId);
188
+ } catch {
189
+ // style may be mid-reload
190
+ }
191
+ };
192
+ }, [
193
+ isLoaded,
194
+ map,
195
+ direction,
196
+ directionSpacing,
197
+ lineColor,
198
+ opacity,
199
+ sourceId,
200
+ arrowLayerId,
201
+ arrowImageId,
202
+ ]);
104
203
 
105
204
  // Sync paint when styling (or the resolved theme color) changes.
106
205
  useEffect(() => {
@@ -0,0 +1,9 @@
1
+ export {
2
+ MapScaleBar,
3
+ computeMapScale,
4
+ metresPerPixel,
5
+ niceDistance,
6
+ type MapScaleBarProps,
7
+ type MapScaleUnit,
8
+ type MapScale,
9
+ } from "./map-scale-bar";
@@ -0,0 +1,63 @@
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 { MapScaleBar } from "./map-scale-bar";
6
+
7
+ const meta = {
8
+ title: "Maps/MapScaleBar",
9
+ component: MapScaleBar,
10
+ tags: ["autodocs"],
11
+ parameters: { layout: "fullscreen" },
12
+ } satisfies Meta<typeof MapScaleBar>;
13
+ export default meta;
14
+ type Story = StoryObj<typeof meta>;
15
+
16
+ /**
17
+ * A kilometre bar at the bottom-left. Its length snaps to a round distance
18
+ * (1, 2, 5 × 10ⁿ) and is recomputed whenever the view or the map width
19
+ * changes — a narrow map gets a shorter bar.
20
+ */
21
+ export const Default: Story = {
22
+ render: (args) => (
23
+ <div className="w-full p-4">
24
+ <MapCanvas interactive={false} center={[-78.2, 43.7]} zoom={6.5}>
25
+ <MapScaleBar {...args} />
26
+ </MapCanvas>
27
+ </div>
28
+ ),
29
+ play: async ({ canvasElement }) => {
30
+ if (!canvasElement.querySelector(".maplibregl-canvas")) return;
31
+ const bar = await waitFor(() => {
32
+ const el = canvasElement.querySelector<HTMLElement>('[data-slot="map-scale-bar"]');
33
+ expect(el).not.toBeNull();
34
+ expect(Number(el!.dataset.distance)).toBeGreaterThan(0);
35
+ return el!;
36
+ });
37
+ await expect(bar).toHaveAttribute("data-unit", "kilometer");
38
+ },
39
+ };
40
+
41
+ /** Miles, in the bottom-right corner. */
42
+ export const Miles: Story = {
43
+ args: { unit: "mi", position: "bottom-right" },
44
+ render: Default.render,
45
+ };
46
+
47
+ /** Zoomed in far enough that the bar switches to metres. */
48
+ export const Metres: Story = {
49
+ args: { position: "top-left" },
50
+ render: (args) => (
51
+ <div className="w-full p-4">
52
+ <MapCanvas interactive={false} center={[-79.38, 43.65]} zoom={15}>
53
+ <MapScaleBar {...args} />
54
+ </MapCanvas>
55
+ </div>
56
+ ),
57
+ };
58
+
59
+ /** The last corner. */
60
+ export const TopRight: Story = {
61
+ args: { position: "top-right" },
62
+ render: Default.render,
63
+ };
@@ -0,0 +1,65 @@
1
+ import { act, cleanup, render, screen } 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 { MapScaleBar, computeMapScale, metresPerPixel, niceDistance } from "./map-scale-bar";
12
+
13
+ afterEach(() => {
14
+ cleanup();
15
+ resetMaplibreMock();
16
+ });
17
+
18
+ describe("computeMapScale", () => {
19
+ it("measures Web Mercator metres per pixel", () => {
20
+ // 512 px world at zoom 0 on the equator.
21
+ expect(metresPerPixel(0, 0)).toBeCloseTo(40_075_016.686 / 512, 3);
22
+ expect(metresPerPixel(60, 1)).toBeCloseTo(40_075_016.686 / 512 / 4, 3);
23
+ });
24
+
25
+ it("rounds to 1 / 2 / 5 × 10ⁿ", () => {
26
+ expect(niceDistance(87)).toBe(50);
27
+ expect(niceDistance(230)).toBe(200);
28
+ expect(niceDistance(1.4)).toBe(1);
29
+ expect(niceDistance(0)).toBe(0);
30
+ });
31
+
32
+ it("recomputes the distance for a narrower bar and switches units", () => {
33
+ const wide = computeMapScale(43.6, 7, "km", 112);
34
+ const narrow = computeMapScale(43.6, 6.3, "km", 80);
35
+ expect(wide.unit).toBe("kilometer");
36
+ expect(narrow.value).not.toBe(wide.value);
37
+ expect(wide.px).toBeLessThanOrEqual(112);
38
+ expect(narrow.px).toBeLessThanOrEqual(80);
39
+ expect(computeMapScale(43.6, 16, "km", 100).unit).toBe("meter");
40
+ expect(computeMapScale(43.6, 7, "mi", 100).unit).toBe("mile");
41
+ expect(computeMapScale(43.6, 16, "mi", 100).unit).toBe("foot");
42
+ });
43
+ });
44
+
45
+ describe("MapScaleBar", () => {
46
+ it("renders a locale-formatted distance and updates as the map moves", async () => {
47
+ render(
48
+ <MapCanvas>
49
+ <MapScaleBar />
50
+ </MapCanvas>,
51
+ );
52
+ const bar = await screen.findByText(/km/);
53
+ const first = bar.textContent;
54
+ const map = MockMap.instances[0]!;
55
+ act(() => {
56
+ map.zoom = 4;
57
+ map.emit("move");
58
+ });
59
+ expect(screen.getByText(/km/).textContent).not.toBe(first);
60
+ expect(document.querySelector('[data-slot="map-scale-bar"]')).toHaveAttribute(
61
+ "data-unit",
62
+ "kilometer",
63
+ );
64
+ });
65
+ });
@@ -0,0 +1,145 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useEffect, useState, type HTMLAttributes } from "react";
4
+ import { useLocale } from "@elabs-ai/components-ui";
5
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
6
+
7
+ import { useMap } from "../map-canvas/map-context";
8
+ import { useMapBreakpoint } from "../lib/use-map-breakpoint";
9
+ import { MAP_CORNER_CLASSES, type MapCorner } from "../map-legend/map-corner";
10
+
11
+ /** Metric (`km`, falling back to metres) or imperial (`mi`, falling back to feet). */
12
+ export type MapScaleUnit = "km" | "mi";
13
+
14
+ /** The Earth's equatorial circumference in metres (WGS 84), as MapLibre uses it. */
15
+ const EARTH_CIRCUMFERENCE_M = 40_075_016.686;
16
+ /** MapLibre's world size at zoom 0, in CSS px. */
17
+ const TILE_SIZE = 512;
18
+ const METRES_PER_MILE = 1609.344;
19
+ const METRES_PER_FOOT = 0.3048;
20
+
21
+ /** The longest the bar may draw, per tier (CSS px). */
22
+ const MAX_BAR_PX = { wide: 112, medium: 96, narrow: 80 } as const;
23
+
24
+ /** Metres one CSS px covers at `latitude` and `zoom` (Web Mercator). */
25
+ export function metresPerPixel(latitude: number, zoom: number): number {
26
+ return (EARTH_CIRCUMFERENCE_M * Math.cos((latitude * Math.PI) / 180)) / (TILE_SIZE * 2 ** zoom);
27
+ }
28
+
29
+ /** The largest 1 / 2 / 5 × 10ⁿ that is ≤ `max`. */
30
+ export function niceDistance(max: number): number {
31
+ if (!(max > 0) || !Number.isFinite(max)) return 0;
32
+ const magnitude = 10 ** Math.floor(Math.log10(max));
33
+ for (const step of [5, 2, 1]) {
34
+ if (step * magnitude <= max) return step * magnitude;
35
+ }
36
+ return magnitude;
37
+ }
38
+
39
+ export interface MapScale {
40
+ /** The bar's length in CSS px. */
41
+ px: number;
42
+ /** The distance the bar stands for, in `unit`. */
43
+ value: number;
44
+ /** An `Intl.NumberFormat` unit identifier. */
45
+ unit: "kilometer" | "meter" | "mile" | "foot";
46
+ }
47
+
48
+ /**
49
+ * A round distance that fits in `maxPx` at this latitude and zoom, in `unit`
50
+ * — kilometres (metres below 1 km) or miles (feet below 1 mi).
51
+ */
52
+ export function computeMapScale(
53
+ latitude: number,
54
+ zoom: number,
55
+ unit: MapScaleUnit,
56
+ maxPx: number,
57
+ ): MapScale {
58
+ const perPx = metresPerPixel(latitude, zoom);
59
+ const maxMetres = perPx * maxPx;
60
+ const [big, small, bigM, smallM] =
61
+ unit === "mi"
62
+ ? (["mile", "foot", METRES_PER_MILE, METRES_PER_FOOT] as const)
63
+ : (["kilometer", "meter", 1000, 1] as const);
64
+ const useBig = maxMetres >= bigM;
65
+ const perUnit = useBig ? bigM : smallM;
66
+ const value = niceDistance(maxMetres / perUnit);
67
+ const px = perPx > 0 ? (value * perUnit) / perPx : 0;
68
+ return { px: Math.max(0, px), value, unit: useBig ? big : small };
69
+ }
70
+
71
+ export interface MapScaleBarProps extends HTMLAttributes<HTMLDivElement> {
72
+ /** `km` (default) or `mi`. */
73
+ unit?: MapScaleUnit;
74
+ /** Corner of the map (default `"bottom-left"`). */
75
+ position?: MapCorner;
76
+ }
77
+
78
+ /**
79
+ * A scale bar for the map's centre latitude — a round distance ("50 km") and
80
+ * a bar of that length, re-measured as the map moves or resizes. The bar gets
81
+ * shorter at `narrow`. Render inside `<MapCanvas>`.
82
+ */
83
+ export const MapScaleBar = forwardRef<HTMLDivElement, MapScaleBarProps>(function MapScaleBar(
84
+ { unit = "km", position = "bottom-left", className, ...props },
85
+ ref,
86
+ ) {
87
+ const { map } = useMap();
88
+ const { formatNumber } = useLocale();
89
+ const maxPx = MAX_BAR_PX[useMapBreakpoint()];
90
+ const [scale, setScale] = useState<MapScale | null>(null);
91
+
92
+ useEffect(() => {
93
+ if (!map) return undefined;
94
+ const update = () => {
95
+ const next = computeMapScale(map.getCenter().lat, map.getZoom(), unit, maxPx);
96
+ setScale((prev) =>
97
+ prev &&
98
+ prev.value === next.value &&
99
+ prev.unit === next.unit &&
100
+ Math.abs(prev.px - next.px) < 0.5
101
+ ? prev
102
+ : next,
103
+ );
104
+ };
105
+ update();
106
+ map.on("move", update);
107
+ map.on("resize", update);
108
+ return () => {
109
+ map.off("move", update);
110
+ map.off("resize", update);
111
+ };
112
+ }, [map, unit, maxPx]);
113
+
114
+ if (!scale || scale.value <= 0) return null;
115
+ const label = formatNumber(scale.value, {
116
+ style: "unit",
117
+ unit: scale.unit,
118
+ unitDisplay: "short",
119
+ });
120
+
121
+ return (
122
+ <div
123
+ ref={ref}
124
+ data-slot="map-scale-bar"
125
+ data-unit={scale.unit}
126
+ data-distance={scale.value}
127
+ className={cn(
128
+ "pointer-events-none absolute z-10 flex flex-col items-start gap-0.5 rounded-sm bg-background/80 px-1.5 py-1",
129
+ MAP_CORNER_CLASSES[position],
130
+ className,
131
+ )}
132
+ {...props}
133
+ >
134
+ <span data-slot="map-scale-bar-label" className="text-meta text-foreground tabular-nums">
135
+ {label}
136
+ </span>
137
+ <span
138
+ aria-hidden="true"
139
+ data-slot="map-scale-bar-bar"
140
+ className="block h-1.5 border-x-2 border-b-2 border-foreground"
141
+ style={{ width: Math.round(scale.px) }}
142
+ />
143
+ </div>
144
+ );
145
+ });