@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,86 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import type { MapPlanRegion } from "../lib/plan-regions";
6
+ import type { PlanStatus } from "../lib/plan-status";
7
+ import { MapPlanTable } from "./map-plan-table";
8
+
9
+ const REGIONS: MapPlanRegion[] = [
10
+ { id: "a1", label: "Cell A1", bounds: { x: 0, y: 0, width: 10, height: 10 } },
11
+ { id: "a2", label: "Cell A2", bounds: { x: 10, y: 0, width: 10, height: 10 } },
12
+ ];
13
+
14
+ const STATUS: Record<string, PlanStatus> = { a1: "down", a2: "free" };
15
+
16
+ afterEach(cleanup);
17
+
18
+ describe("MapPlanTable", () => {
19
+ it("repeats the plan as words, one row per region", () => {
20
+ render(
21
+ <MapPlanTable
22
+ regions={REGIONS}
23
+ caption="Every cell on the line, with its state"
24
+ status={(region) => STATUS[region.id]}
25
+ statusLabels={{ down: "stopped", free: "free" }}
26
+ statusHeader="State"
27
+ columns={[{ key: "throughput", header: "Units/h", numeric: true, cell: () => 120 }]}
28
+ />,
29
+ );
30
+
31
+ const table = screen.getByRole("table", { name: "Every cell on the line, with its state" });
32
+ expect(table).toBeInTheDocument();
33
+ expect(screen.getAllByRole("row")).toHaveLength(REGIONS.length + 1);
34
+ // The status word is the point: a status that exists only as a colour on the
35
+ // canvas is invisible both to a screen reader and in greyscale.
36
+ expect(screen.getByText("stopped")).toBeInTheDocument();
37
+ expect(screen.getByText("free")).toBeInTheDocument();
38
+ expect(screen.getByRole("columnheader", { name: "State" })).toBeInTheDocument();
39
+ });
40
+
41
+ it("keeps a print-only table out of the screen layout but in the DOM", () => {
42
+ render(<MapPlanTable regions={REGIONS} caption="Cells" printOnly />);
43
+
44
+ const root = document.querySelector('[data-slot="map-plan-table"]')!;
45
+ expect(root.className).toContain("hidden");
46
+ expect(root.className).toContain("print:block");
47
+ expect(screen.getByRole("table")).toBeInTheDocument();
48
+ });
49
+
50
+ it("makes each row a second way to reach a region", async () => {
51
+ const user = userEvent.setup();
52
+ const onSelect = vi.fn();
53
+ render(<MapPlanTable regions={REGIONS} caption="Cells" onSelect={onSelect} />);
54
+
55
+ await user.click(screen.getByRole("button", { name: "Cell A2" }));
56
+
57
+ expect(onSelect).toHaveBeenCalledWith("a2", REGIONS[1]);
58
+ });
59
+
60
+ it("mirrors the selection, single id or many", () => {
61
+ const { rerender } = render(
62
+ <MapPlanTable regions={REGIONS} caption="Cells" onSelect={() => {}} selectedId="a1" />,
63
+ );
64
+ const rows = () => screen.getAllByRole("row").slice(1);
65
+ expect(rows().map((row) => row.getAttribute("aria-selected"))).toEqual(["true", "false"]);
66
+
67
+ rerender(
68
+ <MapPlanTable
69
+ regions={REGIONS}
70
+ caption="Cells"
71
+ onSelect={() => {}}
72
+ selectedId={["a1", "a2"]}
73
+ />,
74
+ );
75
+ expect(rows().map((row) => row.getAttribute("aria-selected"))).toEqual(["true", "true"]);
76
+ });
77
+
78
+ it("does not claim selectability when there is nothing to select", () => {
79
+ render(<MapPlanTable regions={REGIONS} caption="Cells" selectedId="a1" />);
80
+
81
+ for (const row of screen.getAllByRole("row").slice(1)) {
82
+ expect(row).not.toHaveAttribute("aria-selected");
83
+ }
84
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
85
+ });
86
+ });
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import {
5
+ Table,
6
+ TableBody,
7
+ TableCaption,
8
+ TableCell,
9
+ TableHead,
10
+ TableHeader,
11
+ TableRow,
12
+ } from "@elabs-ai/components-ui";
13
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
14
+
15
+ import type { MapPlanRegion } from "../lib/plan-regions";
16
+ import { PLAN_STATUS_ENCODING, type PlanStatus } from "../lib/plan-status";
17
+
18
+ export interface MapPlanTableColumn {
19
+ /** Column key, used for the cell lookup and the React key. */
20
+ key: string;
21
+ /** Column header text. */
22
+ header: string;
23
+ /** Right-align the column — numbers belong on the end edge. */
24
+ numeric?: boolean;
25
+ /** The cell's content for one region. */
26
+ cell: (region: MapPlanRegion) => ReactNode;
27
+ }
28
+
29
+ export interface MapPlanTableProps {
30
+ /** The same regions the plan draws. */
31
+ regions: readonly MapPlanRegion[];
32
+ /** What the table is a table of ("Every room on level 3, with its state"). */
33
+ caption: string;
34
+ /** Extra columns after the name. The status column is built in when `status` is given. */
35
+ columns?: readonly MapPlanTableColumn[];
36
+ /** A region's status, if the plan has one. Adds a status column with the word. */
37
+ status?: (region: MapPlanRegion) => PlanStatus | undefined;
38
+ /** The word for each status — supplied by the consumer, so it can be localized. */
39
+ statusLabels?: Partial<Record<PlanStatus, string>>;
40
+ /** Header for the built-in status column. */
41
+ statusHeader?: string;
42
+ /** Header for the name column. */
43
+ nameHeader?: string;
44
+ /** The selected region id(s), mirrored as a row state. */
45
+ selectedId?: string | readonly string[] | null;
46
+ /** Called when a row is chosen — the table is a second way to reach a region. */
47
+ onSelect?: (id: string, region: MapPlanRegion) => void;
48
+ /**
49
+ * Hide the table on screen and keep it for print (default false). A WebGL
50
+ * canvas prints blank, so a plan that is meant to be printed needs this.
51
+ */
52
+ printOnly?: boolean;
53
+ className?: string;
54
+ }
55
+
56
+ /**
57
+ * The plan as words — the parallel channel for anyone who cannot see the canvas,
58
+ * for anyone who needs to sort or search it, and for the printer, which gets a
59
+ * blank rectangle where the WebGL canvas was.
60
+ *
61
+ * Visible by default rather than `sr-only`: the information is useful to
62
+ * everyone, and a hidden twin quietly rots.
63
+ */
64
+ export function MapPlanTable({
65
+ regions,
66
+ caption,
67
+ columns,
68
+ status,
69
+ statusLabels,
70
+ statusHeader,
71
+ nameHeader = "Name",
72
+ selectedId,
73
+ onSelect,
74
+ printOnly = false,
75
+ className,
76
+ }: MapPlanTableProps) {
77
+ const selected = selectedId == null ? [] : Array.isArray(selectedId) ? selectedId : [selectedId];
78
+
79
+ return (
80
+ <div data-slot="map-plan-table" className={cn(printOnly && "hidden print:block", className)}>
81
+ <Table>
82
+ <TableCaption>{caption}</TableCaption>
83
+ <TableHeader>
84
+ <TableRow>
85
+ <TableHead scope="col">{nameHeader}</TableHead>
86
+ {status && <TableHead scope="col">{statusHeader}</TableHead>}
87
+ {columns?.map((column) => (
88
+ <TableHead key={column.key} scope="col" className={column.numeric ? "text-end" : ""}>
89
+ {column.header}
90
+ </TableHead>
91
+ ))}
92
+ </TableRow>
93
+ </TableHeader>
94
+ <TableBody>
95
+ {regions.map((region) => {
96
+ const regionStatus = status?.(region);
97
+ const encoding = regionStatus ? PLAN_STATUS_ENCODING[regionStatus] : undefined;
98
+ const isSelected = selected.includes(region.id);
99
+
100
+ return (
101
+ <TableRow
102
+ key={region.id}
103
+ data-slot="map-plan-table-row"
104
+ data-selected={isSelected || undefined}
105
+ data-status={regionStatus}
106
+ aria-selected={onSelect ? isSelected : undefined}
107
+ >
108
+ <TableCell>
109
+ {onSelect ? (
110
+ <button
111
+ type="button"
112
+ onClick={() => onSelect(region.id, region)}
113
+ className="rounded-sm text-start focus-ring"
114
+ >
115
+ {region.label}
116
+ </button>
117
+ ) : (
118
+ region.label
119
+ )}
120
+ </TableCell>
121
+ {status && (
122
+ <TableCell>
123
+ {regionStatus && encoding ? (
124
+ <span className="inline-flex items-center gap-1.5">
125
+ {encoding.icon && (
126
+ <encoding.icon
127
+ className={cn("size-3.5", encoding.textClass)}
128
+ aria-hidden="true"
129
+ />
130
+ )}
131
+ <span className={encoding.textClass}>
132
+ {statusLabels?.[regionStatus] ?? regionStatus}
133
+ </span>
134
+ </span>
135
+ ) : null}
136
+ </TableCell>
137
+ )}
138
+ {columns?.map((column) => (
139
+ <TableCell
140
+ key={column.key}
141
+ className={column.numeric ? "text-end tabular-nums" : ""}
142
+ >
143
+ {column.cell(region)}
144
+ </TableCell>
145
+ ))}
146
+ </TableRow>
147
+ );
148
+ })}
149
+ </TableBody>
150
+ </Table>
151
+ </div>
152
+ );
153
+ }
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo, useState } from "react";
4
+
5
+ export interface UsePlanSelectionOptions {
6
+ /** Allow more than one region to be selected at a time (default false). */
7
+ multiple?: boolean;
8
+ /** The ids selected to begin with. */
9
+ initialSelectedIds?: readonly string[];
10
+ /** Called whenever the selection changes. */
11
+ onChange?: (selectedIds: string[]) => void;
12
+ }
13
+
14
+ export interface PlanSelection {
15
+ /** The single selected id, or the last of a multiple selection; `null` for none. */
16
+ selectedId: string | null;
17
+ /** Every selected id, in selection order. */
18
+ selectedIds: string[];
19
+ isSelected: (id: string) => boolean;
20
+ /** Toggle a region; `null` clears everything. */
21
+ select: (id: string | null) => void;
22
+ clear: () => void;
23
+ }
24
+
25
+ /**
26
+ * Selection state for a plan, keyed by region **id** rather than index — a
27
+ * status tick that re-orders the regions must not move the selection with it.
28
+ *
29
+ * Hand `selectedIds` to `<MapGeoJSON selectedId>` for the paint and to
30
+ * `<MapPlanOverlay selectedId>` for the pressed state, so both read one source.
31
+ */
32
+ export function usePlanSelection({
33
+ multiple = false,
34
+ initialSelectedIds,
35
+ onChange,
36
+ }: UsePlanSelectionOptions = {}): PlanSelection {
37
+ const [selectedIds, setSelectedIds] = useState<string[]>(() => [...(initialSelectedIds ?? [])]);
38
+
39
+ const commit = useCallback(
40
+ (next: string[]) => {
41
+ setSelectedIds(next);
42
+ onChange?.(next);
43
+ },
44
+ [onChange],
45
+ );
46
+
47
+ const select = useCallback(
48
+ (id: string | null) => {
49
+ if (id === null) {
50
+ commit([]);
51
+ return;
52
+ }
53
+ setSelectedIds((current) => {
54
+ const next = current.includes(id)
55
+ ? current.filter((entry) => entry !== id)
56
+ : multiple
57
+ ? [...current, id]
58
+ : [id];
59
+ onChange?.(next);
60
+ return next;
61
+ });
62
+ },
63
+ [commit, multiple, onChange],
64
+ );
65
+
66
+ const clear = useCallback(() => commit([]), [commit]);
67
+
68
+ return useMemo(
69
+ () => ({
70
+ selectedId: selectedIds.at(-1) ?? null,
71
+ selectedIds,
72
+ isSelected: (id: string) => selectedIds.includes(id),
73
+ select,
74
+ clear,
75
+ }),
76
+ [selectedIds, select, clear],
77
+ );
78
+ }
@@ -0,0 +1 @@
1
+ export { MapPlanImage, type MapPlanImageProps, type MapPlanImageSource } from "./map-plan-image";
@@ -0,0 +1,301 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect } from "storybook/test";
4
+ import { StatePanel } from "@elabs-ai/components-ui";
5
+
6
+ import { MapCanvas } from "../map-canvas";
7
+ import { MapGeoJSON } from "../map-geojson";
8
+ import { useTokenColor } from "../lib/use-token-color";
9
+ import { MapPlanImage } from "./map-plan-image";
10
+
11
+ /**
12
+ * A small plan, so the story is about the PICTURE rather than about a building:
13
+ * 1200 × 600 of whatever unit the caller works in.
14
+ */
15
+ const PLAN = { width: 1200, height: 600, unit: "cm" } as const;
16
+
17
+ /** Two rooms and a corridor, in plan units — the ink that sits on the picture. */
18
+ const SHAPES: GeoJSON.FeatureCollection<GeoJSON.Polygon, { id: string; name: string }> = {
19
+ type: "FeatureCollection",
20
+ features: [
21
+ {
22
+ type: "Feature",
23
+ id: "west",
24
+ properties: { id: "west", name: "West wing" },
25
+ geometry: {
26
+ type: "Polygon",
27
+ coordinates: [
28
+ [
29
+ [60, 60],
30
+ [560, 60],
31
+ [560, 540],
32
+ [60, 540],
33
+ [60, 60],
34
+ ],
35
+ ],
36
+ },
37
+ },
38
+ {
39
+ type: "Feature",
40
+ id: "east",
41
+ properties: { id: "east", name: "East wing" },
42
+ geometry: {
43
+ type: "Polygon",
44
+ coordinates: [
45
+ [
46
+ [640, 60],
47
+ [1140, 60],
48
+ [1140, 540],
49
+ [640, 540],
50
+ [640, 60],
51
+ ],
52
+ ],
53
+ },
54
+ },
55
+ ],
56
+ };
57
+
58
+ /**
59
+ * Stands in for a scan or a CAD export: a grid drawn onto a canvas, which is one of
60
+ * the sources `MapPlanImage` takes as-is. `hue` shifts the drawing so a floor swap
61
+ * is visible without a second asset.
62
+ */
63
+ function usePlanDrawing(ink: string, step = 100) {
64
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
65
+ const [drawing, setDrawing] = useState<HTMLCanvasElement | null>(null);
66
+
67
+ useEffect(() => {
68
+ const canvas = canvasRef.current ?? document.createElement("canvas");
69
+ canvasRef.current = canvas;
70
+ canvas.width = PLAN.width;
71
+ canvas.height = PLAN.height;
72
+ const context = canvas.getContext("2d");
73
+ if (!context) return;
74
+ context.clearRect(0, 0, canvas.width, canvas.height);
75
+ context.strokeStyle = ink;
76
+ context.lineWidth = 2;
77
+ for (let x = 0; x <= canvas.width; x += step) {
78
+ context.beginPath();
79
+ context.moveTo(x, 0);
80
+ context.lineTo(x, canvas.height);
81
+ context.stroke();
82
+ }
83
+ for (let y = 0; y <= canvas.height; y += step) {
84
+ context.beginPath();
85
+ context.moveTo(0, y);
86
+ context.lineTo(canvas.width, y);
87
+ context.stroke();
88
+ }
89
+ setDrawing(canvas);
90
+ }, [ink, step]);
91
+
92
+ return drawing;
93
+ }
94
+
95
+ const meta = {
96
+ title: "Maps/MapPlanImage",
97
+ component: MapPlanImage,
98
+ tags: ["autodocs"],
99
+ parameters: {
100
+ layout: "fullscreen",
101
+ docs: {
102
+ description: {
103
+ component:
104
+ "The custom background picture of a plan map: a floor plan, a scanned drawing, a CAD export or a canvas, laid out in the plan’s own units rather than on a world map. It takes a URL, a decoded image, a bitmap or a canvas, covers the whole plan by default, and accepts an `extent` in plan units for one wing of it. A swap goes through the existing source, so nothing refetches and nothing cross-fades.\n\nThe picture is context and the data on top is the ink — hold a drawing with baked-in colours back at low opacity, or leave it out and draw the plan as shapes, which themes correctly and never blurs.",
105
+ },
106
+ },
107
+ },
108
+ } satisfies Meta<typeof MapPlanImage>;
109
+ export default meta;
110
+ type Story = StoryObj<typeof meta>;
111
+
112
+ /** The whole plan: the picture underneath, the shapes on top. */
113
+ export const Default: Story = {
114
+ render: () => <PictureUnderShapes />,
115
+ play: async ({ canvas }) => {
116
+ // The picture's own parallel channel: `alt` is the one thing about a WebGL raster
117
+ // that assistive technology can read, so the story locks it.
118
+ await expect(await canvas.findByText("Survey grid, one square per metre")).toBeInTheDocument();
119
+ },
120
+ };
121
+
122
+ function PictureUnderShapes() {
123
+ return (
124
+ <div className="h-[420px] p-4">
125
+ <MapCanvas blank plan={PLAN} className="h-full">
126
+ <Layers />
127
+ </MapCanvas>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ function Layers({ extent }: { extent?: { x: number; y: number; width: number; height: number } }) {
133
+ const ink = useTokenColor("--border-strong");
134
+ const outline = useTokenColor("--foreground");
135
+ const drawing = usePlanDrawing(ink);
136
+
137
+ return (
138
+ <>
139
+ {drawing && (
140
+ <MapPlanImage
141
+ id="drawing"
142
+ src={drawing}
143
+ extent={extent}
144
+ alt="Survey grid, one square per metre"
145
+ opacity={0.35}
146
+ resampling="nearest"
147
+ />
148
+ )}
149
+ <MapGeoJSON
150
+ id="wings"
151
+ data={SHAPES}
152
+ promoteId="id"
153
+ interactive={false}
154
+ fillPaint={false}
155
+ linePaint={{ "line-color": outline, "line-width": 2 }}
156
+ />
157
+ </>
158
+ );
159
+ }
160
+
161
+ /** One wing only: `extent` places the picture in plan units, not across the whole plan. */
162
+ export const OverOneWing: Story = {
163
+ render: () => (
164
+ <div className="h-[420px] p-4">
165
+ <MapCanvas blank plan={PLAN} className="h-full">
166
+ <Layers extent={{ x: 640, y: 60, width: 500, height: 480 }} />
167
+ </MapCanvas>
168
+ </div>
169
+ ),
170
+ };
171
+
172
+ /** A floor swap: the same source, a new drawing — no refetch, no cross-fade. */
173
+ export const FloorSwap: Story = {
174
+ render: () => {
175
+ function Demo() {
176
+ const [fine, setFine] = useState(false);
177
+ return (
178
+ <div className="flex h-[460px] flex-col gap-3 p-4">
179
+ <label className="flex items-center gap-2 text-caption">
180
+ <input
181
+ type="checkbox"
182
+ checked={fine}
183
+ onChange={(event) => setFine(event.target.checked)}
184
+ />
185
+ Half-metre grid
186
+ </label>
187
+ <div className="min-h-0 flex-1">
188
+ <MapCanvas blank plan={PLAN} className="h-full">
189
+ <SwappedDrawing step={fine ? 50 : 100} />
190
+ </MapCanvas>
191
+ </div>
192
+ <p className="text-meta text-muted-foreground">
193
+ The grid ink is a semantic token, resolved inside the canvas — WebGL cannot read a CSS
194
+ variable.
195
+ </p>
196
+ </div>
197
+ );
198
+ }
199
+ return <Demo />;
200
+ },
201
+ };
202
+
203
+ function SwappedDrawing({ step }: { step: number }) {
204
+ const ink = useTokenColor("--border-strong");
205
+ const drawing = usePlanDrawing(ink, step);
206
+ return drawing ? (
207
+ <MapPlanImage id="drawing" src={drawing} alt={`Survey grid, ${step} cm squares`} />
208
+ ) : null;
209
+ }
210
+
211
+ /** `visible={false}` toggles the layer’s visibility; the source stays, so nothing reloads. */
212
+ export const Hidden: Story = {
213
+ render: () => {
214
+ function Demo() {
215
+ const [shown, setShown] = useState(false);
216
+ return (
217
+ <div className="flex h-[460px] flex-col gap-3 p-4">
218
+ <label className="flex items-center gap-2 text-caption">
219
+ <input
220
+ type="checkbox"
221
+ checked={shown}
222
+ onChange={(event) => setShown(event.target.checked)}
223
+ />
224
+ Show the drawing
225
+ </label>
226
+ <div className="min-h-0 flex-1">
227
+ <MapCanvas blank plan={PLAN} className="h-full">
228
+ <ToggledDrawing visible={shown} />
229
+ </MapCanvas>
230
+ </div>
231
+ </div>
232
+ );
233
+ }
234
+ return <Demo />;
235
+ },
236
+ };
237
+
238
+ function ToggledDrawing({ visible }: { visible: boolean }) {
239
+ const ink = useTokenColor("--border-strong");
240
+ const drawing = usePlanDrawing(ink);
241
+ return drawing ? (
242
+ <MapPlanImage id="drawing" src={drawing} visible={visible} alt="Survey grid" />
243
+ ) : null;
244
+ }
245
+
246
+ /** Nothing loaded yet: the canvas keeps the plan’s shape and says so once. */
247
+ export const Loading: Story = {
248
+ render: () => (
249
+ <div className="h-[420px] p-4">
250
+ <MapCanvas blank plan={PLAN} loading className="h-full" />
251
+ </div>
252
+ ),
253
+ };
254
+
255
+ /**
256
+ * The picture failed; the plan did not. `onError` reports it and the shapes stay, so
257
+ * the consumer decides what the gap should look like.
258
+ */
259
+ export const MissingPicture: Story = {
260
+ render: () => {
261
+ function Demo() {
262
+ const [failed, setFailed] = useState(false);
263
+ return (
264
+ <div className="flex h-[460px] flex-col gap-2 p-4">
265
+ {failed && (
266
+ <StatePanel
267
+ kind="error"
268
+ title="Floor drawing unavailable"
269
+ description="The plan is still usable — every wing is drawn from its own coordinates."
270
+ />
271
+ )}
272
+ <div className="min-h-0 flex-1">
273
+ <MapCanvas blank plan={PLAN} className="h-full">
274
+ <MapPlanImage
275
+ id="drawing"
276
+ src="./missing-drawing.png"
277
+ onError={() => setFailed(true)}
278
+ />
279
+ <ShapesOnly />
280
+ </MapCanvas>
281
+ </div>
282
+ </div>
283
+ );
284
+ }
285
+ return <Demo />;
286
+ },
287
+ };
288
+
289
+ function ShapesOnly() {
290
+ const outline = useTokenColor("--foreground");
291
+ return (
292
+ <MapGeoJSON
293
+ id="wings"
294
+ data={SHAPES}
295
+ promoteId="id"
296
+ interactive={false}
297
+ fillPaint={false}
298
+ linePaint={{ "line-color": outline, "line-width": 2 }}
299
+ />
300
+ );
301
+ }