@elabs-ai/components-maps 4.2.0 → 5.1.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
@@ -0,0 +1,88 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { createRouteArrowImage, ROUTE_ARROW_SIZE, routeArrowImageId } from "./route-arrow";
4
+
5
+ interface Stroked {
6
+ points: [number, number][];
7
+ strokeStyle: string;
8
+ }
9
+
10
+ /** A 2D context that records the path instead of rasterizing it. */
11
+ function stubCanvasContext() {
12
+ const drawn: Stroked = { points: [], strokeStyle: "" };
13
+
14
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (kind: string) {
15
+ if (kind !== "2d") return null;
16
+ const context = {
17
+ set strokeStyle(value: string) {
18
+ drawn.strokeStyle = value;
19
+ },
20
+ get strokeStyle() {
21
+ return drawn.strokeStyle;
22
+ },
23
+ lineWidth: 0,
24
+ lineCap: "butt",
25
+ lineJoin: "miter",
26
+ clearRect: () => {},
27
+ beginPath: () => {},
28
+ moveTo: (x: number, y: number) => drawn.points.push([x, y]),
29
+ lineTo: (x: number, y: number) => drawn.points.push([x, y]),
30
+ stroke: () => {},
31
+ getImageData: (_x: number, _y: number, width: number, height: number) => ({
32
+ data: new Uint8ClampedArray(width * height * 4),
33
+ }),
34
+ };
35
+ return context as unknown as CanvasRenderingContext2D;
36
+ });
37
+
38
+ return drawn;
39
+ }
40
+
41
+ afterEach(() => {
42
+ vi.restoreAllMocks();
43
+ });
44
+
45
+ describe("routeArrowImageId", () => {
46
+ it("is one image per route, since the ink is per route", () => {
47
+ expect(routeArrowImageId("aisle")).toBe("route-arrow-aisle");
48
+ expect(routeArrowImageId("aisle")).not.toBe(routeArrowImageId("conveyor"));
49
+ });
50
+ });
51
+
52
+ describe("createRouteArrowImage", () => {
53
+ it("returns null with no 2D context, so the caller leaves the layer out", () => {
54
+ // jsdom, unstubbed: an arrow layer pointing at a missing image draws nothing
55
+ // and warns every frame, so the caller must be able to tell.
56
+ expect(createRouteArrowImage("#000")).toBeNull();
57
+ });
58
+
59
+ it("draws a chevron pointing along +x, in the ink it was given", () => {
60
+ const drawn = stubCanvasContext();
61
+
62
+ const image = createRouteArrowImage("rgb(4, 5, 6)");
63
+
64
+ expect(image).toEqual({
65
+ width: ROUTE_ARROW_SIZE,
66
+ height: ROUTE_ARROW_SIZE,
67
+ data: new Uint8ClampedArray(ROUTE_ARROW_SIZE * ROUTE_ARROW_SIZE * 4),
68
+ });
69
+ expect(drawn.strokeStyle).toBe("rgb(4, 5, 6)");
70
+
71
+ // Three points: back-top, tip, back-bottom. The tip is the rightmost point
72
+ // and sits on the middle row — MapLibre aligns a line-placed symbol's x axis
73
+ // with the line, so +x is "forward".
74
+ expect(drawn.points).toHaveLength(3);
75
+ const [top, tip, bottom] = drawn.points as [
76
+ [number, number],
77
+ [number, number],
78
+ [number, number],
79
+ ];
80
+ expect(tip[0]).toBeGreaterThan(top[0]);
81
+ expect(tip[0]).toBeGreaterThan(bottom[0]);
82
+ expect(tip[1]).toBeCloseTo(ROUTE_ARROW_SIZE / 2, 5);
83
+ expect(top[1]).toBeLessThan(tip[1]);
84
+ expect(bottom[1]).toBeGreaterThan(tip[1]);
85
+ // Symmetric about the middle row, so the chevron is not lopsided.
86
+ expect(top[1] + bottom[1]).toBeCloseTo(ROUTE_ARROW_SIZE, 5);
87
+ });
88
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The direction chevron for `<MapRoute direction>`, drawn on a canvas and
3
+ * registered with `map.addImage`.
4
+ *
5
+ * An icon rather than a text glyph on purpose: a blank MapLibre style ships no
6
+ * glyph endpoint, so a `symbol` layer's `text-field` renders nothing at all —
7
+ * while `icon-image` needs no network and no sprite.
8
+ */
9
+
10
+ /** Tile edge, in px. Drawn at `pixelRatio: 2`, so it reads ~12 px on screen. */
11
+ export const ROUTE_ARROW_SIZE = 24;
12
+
13
+ /** Image id for one route's chevron. Per route, because the ink is per route. */
14
+ export function routeArrowImageId(routeId: string): string {
15
+ return `route-arrow-${routeId}`;
16
+ }
17
+
18
+ /** A raw RGBA image MapLibre accepts, without depending on the DOM's `ImageData`. */
19
+ export interface RouteArrowImage {
20
+ width: number;
21
+ height: number;
22
+ data: Uint8ClampedArray;
23
+ }
24
+
25
+ /**
26
+ * A chevron pointing along +x. MapLibre aligns a line-placed symbol's x axis
27
+ * with the line's direction, so +x is "forward"; `icon-rotate: 180` is backward.
28
+ *
29
+ * Returns `null` where there is no 2D context (jsdom) — the caller then leaves
30
+ * the arrow layer out rather than pointing it at a missing image, which
31
+ * MapLibre would draw as nothing while warning on every frame.
32
+ */
33
+ export function createRouteArrowImage(ink: string): RouteArrowImage | null {
34
+ if (typeof document === "undefined") return null;
35
+
36
+ const canvas = document.createElement("canvas");
37
+ canvas.width = ROUTE_ARROW_SIZE;
38
+ canvas.height = ROUTE_ARROW_SIZE;
39
+ const context = canvas.getContext("2d");
40
+ if (!context) return null;
41
+
42
+ const size = ROUTE_ARROW_SIZE;
43
+ const inset = size * 0.22;
44
+ const mid = size / 2;
45
+
46
+ context.clearRect(0, 0, size, size);
47
+ context.strokeStyle = ink;
48
+ context.lineWidth = size * 0.16;
49
+ context.lineCap = "round";
50
+ context.lineJoin = "round";
51
+ context.beginPath();
52
+ context.moveTo(inset, inset);
53
+ context.lineTo(size - inset, mid);
54
+ context.lineTo(inset, size - inset);
55
+ context.stroke();
56
+
57
+ const image = context.getImageData(0, 0, size, size);
58
+ return { width: size, height: size, data: image.data };
59
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ DEFAULT_MAP_HEIGHT,
5
+ MAP_BREAKPOINT_THRESHOLDS,
6
+ mapBreakpointForWidth,
7
+ resolveMapHeightStyle,
8
+ resolveMapResponsive,
9
+ } from "./use-map-breakpoint";
10
+
11
+ describe("map breakpoints (a documented copy of the charts tiers)", () => {
12
+ it("uses the charts thresholds: narrow < 480, medium < 768", () => {
13
+ expect(MAP_BREAKPOINT_THRESHOLDS).toEqual({ narrow: 480, medium: 768 });
14
+ expect(mapBreakpointForWidth(348)).toBe("narrow");
15
+ expect(mapBreakpointForWidth(479.5)).toBe("narrow");
16
+ expect(mapBreakpointForWidth(480)).toBe("medium");
17
+ expect(mapBreakpointForWidth(568)).toBe("medium");
18
+ expect(mapBreakpointForWidth(768)).toBe("wide");
19
+ expect(mapBreakpointForWidth(868)).toBe("wide");
20
+ });
21
+
22
+ it("treats an unmeasured width as wide", () => {
23
+ expect(mapBreakpointForWidth(0)).toBe("wide");
24
+ expect(mapBreakpointForWidth(Number.NaN)).toBe("wide");
25
+ });
26
+
27
+ it("resolves per-tier values desktop-first", () => {
28
+ const value = { base: "a", medium: "b", narrow: "c" };
29
+ expect(resolveMapResponsive(value, "wide")).toBe("a");
30
+ expect(resolveMapResponsive(value, "medium")).toBe("b");
31
+ expect(resolveMapResponsive(value, "narrow")).toBe("c");
32
+ expect(resolveMapResponsive({ base: true, narrow: false }, "medium")).toBe(true);
33
+ expect(resolveMapResponsive({ base: true, narrow: false }, "narrow")).toBe(false);
34
+ expect(resolveMapResponsive(false, "narrow")).toBe(false);
35
+ });
36
+
37
+ it("defaults the height to 1.6 : 1, square at narrow", () => {
38
+ expect(DEFAULT_MAP_HEIGHT).toEqual({ base: { aspect: 1.6 }, narrow: { aspect: 1 } });
39
+ expect(resolveMapHeightStyle(undefined, "wide")).toEqual({
40
+ aspectRatio: "1.6 / 1",
41
+ transitionProperty: "none",
42
+ });
43
+ expect(resolveMapHeightStyle(undefined, "narrow")).toEqual({
44
+ aspectRatio: "1 / 1",
45
+ transitionProperty: "none",
46
+ });
47
+ });
48
+
49
+ // The engine measures this box. A transitioned height/aspect only lands on
50
+ // the next animation frame — and the reduced-motion clamp gives EVERY element
51
+ // a 0.01 ms duration, so "no motion" is exactly where the map used to be
52
+ // measured mid-change and fit its bounds to a box that never existed.
53
+ it("never lets the box's geometry animate, whichever branch sets it", () => {
54
+ for (const style of [
55
+ resolveMapHeightStyle(undefined, "narrow"),
56
+ resolveMapHeightStyle(420, "narrow"),
57
+ resolveMapHeightStyle({ aspect: 2 }, "wide"),
58
+ ]) {
59
+ expect(style.transitionProperty).toBe("none");
60
+ }
61
+ });
62
+
63
+ it("lets an explicit height win, per tier", () => {
64
+ expect(resolveMapHeightStyle(420, "narrow")).toEqual({
65
+ height: 420,
66
+ transitionProperty: "none",
67
+ });
68
+ expect(resolveMapHeightStyle({ base: { aspect: 2 }, narrow: 360 }, "wide")).toEqual({
69
+ height: "auto",
70
+ aspectRatio: "2 / 1",
71
+ transitionProperty: "none",
72
+ });
73
+ expect(resolveMapHeightStyle({ base: { aspect: 2 }, narrow: 360 }, "narrow")).toEqual({
74
+ height: 360,
75
+ transitionProperty: "none",
76
+ });
77
+ // An invalid height falls back to the default instead of collapsing.
78
+ expect(resolveMapHeightStyle(-5, "wide")).toEqual({
79
+ aspectRatio: "1.6 / 1",
80
+ transitionProperty: "none",
81
+ });
82
+ });
83
+ });
@@ -0,0 +1,238 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The map's responsive contract: container tiers, `MapResponsive<T>` and the
5
+ * frame context `<MapCanvas>` provides to its furniture (RM-125).
6
+ *
7
+ * A DELIBERATE COPY of `@elabs-ai/components-charts`' `chart-breakpoint.ts`
8
+ * (ADR 0039), with the same thresholds and the same desktop-first
9
+ * `{ base, medium?, narrow? }` shape. `maps` may not import `charts` (both are
10
+ * layer-2 leaves — `pnpm check --rule dep-direction`), so the few lines live
11
+ * here too. Change a threshold in both files or in neither.
12
+ *
13
+ * - Three container tiers, measured on the MAP's own width, never the
14
+ * viewport: `narrow` (< 480 px), `medium` (< 768 px), `wide`.
15
+ * - `MapResponsive<T>`: one value, or `{ base, medium?, narrow? }` — an
16
+ * override applies at its tier and every narrower tier that sets nothing.
17
+ * A plain value structurally matches `charts`' `Responsive<T>`, so one
18
+ * object can drive a chart and a map.
19
+ */
20
+
21
+ import {
22
+ type CSSProperties,
23
+ createContext,
24
+ useContext,
25
+ useEffect,
26
+ useLayoutEffect,
27
+ useState,
28
+ } from "react";
29
+
30
+ // ── Tiers ────────────────────────────────────────────────────────────────────
31
+
32
+ /** A map container's width tier — the same three tiers as a chart's. */
33
+ export type MapBreakpoint = "narrow" | "medium" | "wide";
34
+
35
+ /** Every tier, narrowest first. */
36
+ export const MAP_BREAKPOINTS: readonly MapBreakpoint[] = ["narrow", "medium", "wide"];
37
+
38
+ /**
39
+ * A width (CSS px) below `narrow` is narrow; below `medium` is medium; anything
40
+ * else is wide. A boundary belongs to the wider tier (480 → medium). Same
41
+ * numbers as `CHART_BREAKPOINT_THRESHOLDS`.
42
+ */
43
+ export const MAP_BREAKPOINT_THRESHOLDS = { narrow: 480, medium: 768 } as const;
44
+
45
+ /**
46
+ * The tier for a measured container width. `0` or a non-finite width means
47
+ * "not measured yet" (server render, first pass, `display: none`) and
48
+ * resolves to `wide`, so a first paint matches the pre-tier layout.
49
+ */
50
+ export function mapBreakpointForWidth(width: number): MapBreakpoint {
51
+ if (!Number.isFinite(width) || width <= 0) return "wide";
52
+ if (width < MAP_BREAKPOINT_THRESHOLDS.narrow) return "narrow";
53
+ if (width < MAP_BREAKPOINT_THRESHOLDS.medium) return "medium";
54
+ return "wide";
55
+ }
56
+
57
+ // ── MapResponsive<T> ─────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Per-tier values, desktop-first: `base` is the WIDE value (the opposite of
61
+ * Tailwind's unprefixed, mobile-first class); overrides go down.
62
+ */
63
+ export interface MapResponsiveByBreakpoint<T> {
64
+ /** The value at `wide`, and the fallback for every tier that sets nothing. */
65
+ base: T;
66
+ /** At `medium` — and at `narrow` too, unless `narrow` is set. */
67
+ medium?: T;
68
+ /** At `narrow` only. */
69
+ narrow?: T;
70
+ }
71
+
72
+ /**
73
+ * One value for every tier, or per-tier values. `T` must never be an object
74
+ * type with its own `base` key (that key marks the per-tier form). Read it
75
+ * through {@link resolveMapResponsive} / {@link useMapResponsive}.
76
+ */
77
+ export type MapResponsive<T> = T | MapResponsiveByBreakpoint<T>;
78
+
79
+ /** True for the per-tier form: a plain object with its own `base` key. */
80
+ export function isMapResponsiveByBreakpoint<T>(
81
+ value: MapResponsive<T>,
82
+ ): value is MapResponsiveByBreakpoint<T> {
83
+ return (
84
+ typeof value === "object" &&
85
+ value !== null &&
86
+ !Array.isArray(value) &&
87
+ Object.prototype.hasOwnProperty.call(value, "base")
88
+ );
89
+ }
90
+
91
+ /**
92
+ * The value for `breakpoint`: `wide` → `base`; `medium` → `medium ?? base`;
93
+ * `narrow` → `narrow ?? medium ?? base`. "Not set" means `undefined`.
94
+ */
95
+ export function resolveMapResponsive<T>(value: MapResponsive<T>, breakpoint: MapBreakpoint): T {
96
+ if (!isMapResponsiveByBreakpoint(value)) return value as T;
97
+ if (breakpoint === "wide") return value.base;
98
+ if (breakpoint === "narrow" && value.narrow !== undefined) return value.narrow;
99
+ return value.medium !== undefined ? value.medium : value.base;
100
+ }
101
+
102
+ // ── Height ───────────────────────────────────────────────────────────────────
103
+
104
+ /** CSS px, or the map's width ÷ height (`{ aspect: 2 }` = twice as wide as tall). */
105
+ export type MapHeight = number | { aspect: number };
106
+
107
+ /**
108
+ * The height a `<MapCanvas>` falls back to when its parent gives it none:
109
+ * 1.6 : 1, and square at `narrow` — an aspect-locked map at 380 px would
110
+ * otherwise shrink to a strip nobody can read.
111
+ */
112
+ export const DEFAULT_MAP_HEIGHT: MapResponsiveByBreakpoint<MapHeight> = {
113
+ base: { aspect: 1.6 },
114
+ narrow: { aspect: 1 },
115
+ };
116
+
117
+ function validHeight(value: MapHeight | undefined): MapHeight | undefined {
118
+ if (typeof value === "number") return Number.isFinite(value) && value > 0 ? value : undefined;
119
+ if (value && Number.isFinite(value.aspect) && value.aspect > 0) return value;
120
+ return undefined;
121
+ }
122
+
123
+ /**
124
+ * The map box's geometry is LAYOUT THE ENGINE MEASURES, so it must never
125
+ * interpolate. `transition-property` defaults to `all`, and the house
126
+ * reduced-motion clamp sets `transition-duration: 0.01ms !important` on every
127
+ * element — which turns a height / aspect-ratio change into a (very short)
128
+ * transition that still lands one animation frame later. Under a busy WebGL
129
+ * first paint that frame arrived ~0.7 s late, so MapLibre measured the box
130
+ * mid-transition (348×218 on a box that ends up 348×348) and fitted `bounds`
131
+ * to a rectangle that never existed. `none` beats any stylesheet from the
132
+ * inline style, and the clamp only forces DURATIONS, never the property list.
133
+ */
134
+ const NO_GEOMETRY_TRANSITION = { transitionProperty: "none" } as const;
135
+
136
+ /**
137
+ * The container style for a `height` at `breakpoint`. An explicit `height`
138
+ * sets the box outright (`height: auto` releases the `h-full` class for an
139
+ * aspect). Without one, only an `aspect-ratio` is set — CSS ignores it while
140
+ * the parent gives the map a definite height, so a map in a sized parent
141
+ * looks exactly as before; a map in an unsized parent stops collapsing to 0.
142
+ * Every branch pins {@link NO_GEOMETRY_TRANSITION} so the box resizes in the
143
+ * same frame the rule changes.
144
+ */
145
+ export function resolveMapHeightStyle(
146
+ height: MapResponsive<MapHeight> | undefined,
147
+ breakpoint: MapBreakpoint,
148
+ ): CSSProperties {
149
+ const own =
150
+ height === undefined ? undefined : validHeight(resolveMapResponsive(height, breakpoint));
151
+ if (typeof own === "number") return { ...NO_GEOMETRY_TRANSITION, height: own };
152
+ if (own) return { ...NO_GEOMETRY_TRANSITION, height: "auto", aspectRatio: `${own.aspect} / 1` };
153
+ const fallback = resolveMapResponsive(DEFAULT_MAP_HEIGHT, breakpoint);
154
+ return typeof fallback === "number"
155
+ ? { ...NO_GEOMETRY_TRANSITION }
156
+ : { ...NO_GEOMETRY_TRANSITION, aspectRatio: `${fallback.aspect} / 1` };
157
+ }
158
+
159
+ // ── Measuring ────────────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Measures `node` with a `ResizeObserver` and returns its tier. Re-renders
163
+ * only when the TIER changes.
164
+ */
165
+ export function useMeasuredMapBreakpoint(node: Element | null): MapBreakpoint {
166
+ const [breakpoint, setBreakpoint] = useState<MapBreakpoint>("wide");
167
+ // Layout effect: the first measure lands before paint, so a narrow map never
168
+ // flashes its wide furniture.
169
+ useLayoutEffect(() => {
170
+ if (!node) return undefined;
171
+ const update = (width: number) => {
172
+ const next = mapBreakpointForWidth(width);
173
+ setBreakpoint((prev) => (prev === next ? prev : next));
174
+ };
175
+ update(node.getBoundingClientRect().width);
176
+ if (typeof ResizeObserver === "undefined") return undefined;
177
+ const observer = new ResizeObserver((entries) => {
178
+ const entry = entries[entries.length - 1];
179
+ if (entry) update(entry.contentRect.width);
180
+ });
181
+ observer.observe(node);
182
+ return () => observer.disconnect();
183
+ }, [node]);
184
+ return breakpoint;
185
+ }
186
+
187
+ // ── Frame context ────────────────────────────────────────────────────────────
188
+
189
+ /** Where furniture outside the map box goes: the strip above or below it. */
190
+ export type MapFrameSide = "above" | "below";
191
+
192
+ export interface MapFrameContextValue {
193
+ /** The map container's measured tier. */
194
+ breakpoint: MapBreakpoint;
195
+ /** `false` when the canvas is static (`<MapCanvas interactive={false}>`). */
196
+ interactive: boolean;
197
+ /** The strips around the map box; `null` until something asks for one. */
198
+ slots: Record<MapFrameSide, HTMLElement | null>;
199
+ /** Ask the canvas to render a strip; returns the release function. */
200
+ requestSlot: (side: MapFrameSide) => () => void;
201
+ }
202
+
203
+ const noopRelease = () => {};
204
+
205
+ const DEFAULT_FRAME: MapFrameContextValue = {
206
+ breakpoint: "wide",
207
+ interactive: true,
208
+ slots: { above: null, below: null },
209
+ requestSlot: () => noopRelease,
210
+ };
211
+
212
+ export const MapFrameContext = createContext<MapFrameContextValue>(DEFAULT_FRAME);
213
+
214
+ /** The frame `<MapCanvas>` provides: tier, static flag, outside strips. */
215
+ export function useMapFrame(): MapFrameContextValue {
216
+ return useContext(MapFrameContext);
217
+ }
218
+
219
+ /** The enclosing map's tier (`wide` outside a `<MapCanvas>`). */
220
+ export function useMapBreakpoint(): MapBreakpoint {
221
+ return useContext(MapFrameContext).breakpoint;
222
+ }
223
+
224
+ /** `value` resolved at the enclosing map's tier. */
225
+ export function useMapResponsive<T>(value: MapResponsive<T>): T {
226
+ return resolveMapResponsive(value, useMapBreakpoint());
227
+ }
228
+
229
+ /**
230
+ * The strip above / below the map box to portal into, or `null` while it is
231
+ * not needed (`side` is `null`) or not mounted yet. Asking renders the strip;
232
+ * the canvas drops it again when nothing asks.
233
+ */
234
+ export function useMapFrameSlot(side: MapFrameSide | null): HTMLElement | null {
235
+ const { slots, requestSlot } = useContext(MapFrameContext);
236
+ useEffect(() => (side ? requestSlot(side) : undefined), [side, requestSlot]);
237
+ return side ? slots[side] : null;
238
+ }
@@ -0,0 +1,157 @@
1
+ "use client";
2
+
3
+ import type MapLibreGL from "maplibre-gl";
4
+ import { useCallback, useEffect, useRef } from "react";
5
+
6
+ import type { PlanCrs } from "./plan-crs";
7
+ import type { MapPlanRegion } from "./plan-regions";
8
+
9
+ /** Where a region ended up on screen, in CSS pixels relative to the canvas. */
10
+ export interface PlanScreenBox {
11
+ x: number;
12
+ y: number;
13
+ width: number;
14
+ height: number;
15
+ }
16
+
17
+ export interface UsePlanProjectionOptions {
18
+ map: MapLibreGL.Map | null;
19
+ plan: PlanCrs | null;
20
+ /**
21
+ * The regions to track. Keep this array MEMOIZED: it is an effect dependency,
22
+ * and a fresh array every render re-binds the map listeners every render.
23
+ */
24
+ regions: readonly MapPlanRegion[];
25
+ /** Turn tracking off (while the map is still loading, say). */
26
+ enabled?: boolean;
27
+ /** Called after each frame's DOM writes, with the boxes that were written. */
28
+ onFrame?: (boxes: ReadonlyMap<string, PlanScreenBox>) => void;
29
+ }
30
+
31
+ /**
32
+ * ONE projection pass for every plan overlay element, coalesced into a single
33
+ * `requestAnimationFrame` and writing straight to the DOM — no React state per
34
+ * frame, no per-element listener. A MapLibre `Marker` binds its own `move`
35
+ * listener and projects itself, so forty rooms would mean eighty listeners and
36
+ * forty uncoordinated writes; this is one listener set and one write pass.
37
+ *
38
+ * Each region is projected from its FOUR plan corners to a screen box, so the
39
+ * box stays right even where the projection is not a plain scale.
40
+ */
41
+ export function usePlanProjection({
42
+ map,
43
+ plan,
44
+ regions,
45
+ enabled = true,
46
+ onFrame,
47
+ }: UsePlanProjectionOptions) {
48
+ const elementsRef = useRef(new Map<string, HTMLElement>());
49
+ const boxesRef = useRef(new Map<string, PlanScreenBox>());
50
+ const frameRef = useRef<number | null>(null);
51
+ const latestRef = useRef({ map, plan, regions, enabled, onFrame });
52
+ latestRef.current = { map, plan, regions, enabled, onFrame };
53
+
54
+ /** The whole write pass. Reads refs only, so it never needs re-creating. */
55
+ const write = useCallback(() => {
56
+ frameRef.current = null;
57
+ const {
58
+ map: currentMap,
59
+ plan: currentPlan,
60
+ regions: currentRegions,
61
+ enabled: isEnabled,
62
+ onFrame: notify,
63
+ } = latestRef.current;
64
+ if (!currentMap || !currentPlan || !isEnabled) return;
65
+
66
+ const boxes = boxesRef.current;
67
+ boxes.clear();
68
+
69
+ for (const region of currentRegions) {
70
+ const element = elementsRef.current.get(region.id);
71
+ const { x, y, width, height } = region.bounds;
72
+ const corners: [number, number][] = [
73
+ [x, y],
74
+ [x + width, y],
75
+ [x + width, y + height],
76
+ [x, y + height],
77
+ ];
78
+
79
+ let minX = Number.POSITIVE_INFINITY;
80
+ let minY = Number.POSITIVE_INFINITY;
81
+ let maxX = Number.NEGATIVE_INFINITY;
82
+ let maxY = Number.NEGATIVE_INFINITY;
83
+ for (const [cornerX, cornerY] of corners) {
84
+ const point = currentMap.project(currentPlan.toLngLat({ x: cornerX, y: cornerY }));
85
+ if (point.x < minX) minX = point.x;
86
+ if (point.y < minY) minY = point.y;
87
+ if (point.x > maxX) maxX = point.x;
88
+ if (point.y > maxY) maxY = point.y;
89
+ }
90
+
91
+ const box: PlanScreenBox = {
92
+ x: minX,
93
+ y: minY,
94
+ width: Math.max(1, maxX - minX),
95
+ height: Math.max(1, maxY - minY),
96
+ };
97
+ boxes.set(region.id, box);
98
+
99
+ if (!element) continue;
100
+ // Physical `left`/`top`, deliberately: `map.project` returns screen
101
+ // pixels, which do not mirror in RTL — a logical inset would put every
102
+ // region on the wrong side of the canvas.
103
+ element.style.left = "0px";
104
+ element.style.top = "0px";
105
+ element.style.width = `${Math.round(box.width)}px`;
106
+ element.style.height = `${Math.round(box.height)}px`;
107
+ element.style.transform = `translate(${Math.round(box.x)}px, ${Math.round(box.y)}px)`;
108
+ }
109
+
110
+ notify?.(boxes);
111
+ }, []);
112
+
113
+ /** Ask for a frame. Repeated calls inside one frame collapse into one pass. */
114
+ const refresh = useCallback(() => {
115
+ if (!latestRef.current.enabled) return;
116
+ if (frameRef.current != null) return;
117
+ if (typeof requestAnimationFrame !== "function") {
118
+ write();
119
+ return;
120
+ }
121
+ frameRef.current = requestAnimationFrame(write);
122
+ }, [write]);
123
+
124
+ /** `ref` callback for a region's element. */
125
+ const register = useCallback(
126
+ (id: string, element: HTMLElement | null) => {
127
+ if (element) elementsRef.current.set(id, element);
128
+ else elementsRef.current.delete(id);
129
+ refresh();
130
+ },
131
+ [refresh],
132
+ );
133
+
134
+ useEffect(() => {
135
+ if (!map || !plan || !enabled) return;
136
+
137
+ // Write once synchronously, so the first paint already has the geometry
138
+ // rather than a stack of boxes at the origin.
139
+ write();
140
+
141
+ map.on("move", refresh);
142
+ map.on("moveend", refresh);
143
+ map.on("resize", refresh);
144
+
145
+ return () => {
146
+ map.off("move", refresh);
147
+ map.off("moveend", refresh);
148
+ map.off("resize", refresh);
149
+ if (frameRef.current != null) {
150
+ cancelAnimationFrame(frameRef.current);
151
+ frameRef.current = null;
152
+ }
153
+ };
154
+ }, [map, plan, enabled, regions, refresh, write]);
155
+
156
+ return { register, refresh, boxes: boxesRef };
157
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Dev-only warnings for the silent failure modes a map wrapper cannot type away
3
+ * — a plan coordinate handed to a geographic canvas, a missing `promoteId`, a
4
+ * GeoJSON URL that cannot be transformed. Mirrors `warnChartOnce` in
5
+ * `@elabs-ai/components-charts` (kept local: `dep-direction` forbids the import).
6
+ */
7
+
8
+ const warned = new Set<string>();
9
+
10
+ /** `console.warn` once per `key` per page load, and never in production. */
11
+ export function warnMapOnce(key: string, message: string): void {
12
+ if (process.env.NODE_ENV === "production") return;
13
+ if (warned.has(key)) return;
14
+ warned.add(key);
15
+ console.warn(`[maps] ${message}`);
16
+ }
17
+
18
+ /** Test-only: forget which warnings have already fired. */
19
+ export function resetMapWarnings(): void {
20
+ warned.clear();
21
+ }