@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,82 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useEffect, useRef, useState, type HTMLAttributes } from "react";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+
6
+ import { useMap } from "../map-canvas/map-context";
7
+ import { MAP_CORNER_CLASSES, type MapCorner } from "../map-legend/map-corner";
8
+
9
+ /** Below this rotation (degrees) the map counts as north-up and the arrow stays away. */
10
+ const NORTH_UP_TOLERANCE = 0.5;
11
+
12
+ /** A bearing folded into (-180, 180]. */
13
+ function normalizeBearing(bearing: number): number {
14
+ const b = ((bearing % 360) + 360) % 360;
15
+ return b > 180 ? b - 360 : b;
16
+ }
17
+
18
+ export interface MapNorthArrowProps extends HTMLAttributes<HTMLDivElement> {
19
+ /** Corner of the map (default `"top-left"`). */
20
+ position?: MapCorner;
21
+ /** The arrow's accessible name (default `"North"`). */
22
+ label?: string;
23
+ }
24
+
25
+ /**
26
+ * A north arrow that appears only while the map is rotated (bearing ≠ 0) and
27
+ * turns with it — a north-up map needs none. Static furniture, not a control:
28
+ * `<MapControls showCompass>` is the button that resets the bearing. Render
29
+ * inside `<MapCanvas>`.
30
+ */
31
+ export const MapNorthArrow = forwardRef<HTMLDivElement, MapNorthArrowProps>(function MapNorthArrow(
32
+ { position = "top-left", label = "North", className, ...props },
33
+ ref,
34
+ ) {
35
+ const { map } = useMap();
36
+ const glyphRef = useRef<SVGSVGElement>(null);
37
+ const [rotated, setRotated] = useState(false);
38
+
39
+ useEffect(() => {
40
+ if (!map) return undefined;
41
+ const update = () => {
42
+ const bearing = normalizeBearing(map.getBearing());
43
+ setRotated(Math.abs(bearing) > NORTH_UP_TOLERANCE);
44
+ if (glyphRef.current) glyphRef.current.style.transform = `rotate(${-bearing}deg)`;
45
+ };
46
+ update();
47
+ map.on("rotate", update);
48
+ return () => {
49
+ map.off("rotate", update);
50
+ };
51
+ }, [map]);
52
+
53
+ return (
54
+ <div
55
+ ref={ref}
56
+ role="img"
57
+ aria-label={label}
58
+ data-slot="map-north-arrow"
59
+ hidden={!rotated}
60
+ className={cn(
61
+ "pointer-events-none absolute z-10 flex size-9 items-center justify-center rounded-full bg-background/80 text-foreground",
62
+ MAP_CORNER_CLASSES[position],
63
+ className,
64
+ )}
65
+ {...props}
66
+ >
67
+ <svg ref={glyphRef} viewBox="0 0 24 24" aria-hidden="true" className="size-7">
68
+ {/* The arrow: dark north half, hollow south half. */}
69
+ <path d="M12 3 L16 13 L12 11 Z" fill="currentColor" />
70
+ <path d="M12 3 L8 13 L12 11 Z" fill="currentColor" opacity={0.55} />
71
+ {/* The cartographic "N", drawn as strokes so it is not locale text. */}
72
+ <path
73
+ d="M9.5 21 V15.5 L14.5 21 V15.5"
74
+ fill="none"
75
+ stroke="currentColor"
76
+ strokeWidth={1.5}
77
+ strokeLinejoin="round"
78
+ />
79
+ </svg>
80
+ </div>
81
+ );
82
+ });
@@ -0,0 +1,13 @@
1
+ export {
2
+ MapPlanOverlay,
3
+ type MapPlanOverlayMode,
4
+ type MapPlanOverlayProps,
5
+ } from "./map-plan-overlay";
6
+ export { MapPlanLegend, type MapPlanLegendProps, type PlanStatusLabels } from "./map-plan-legend";
7
+ export { MapPlanTable, type MapPlanTableColumn, type MapPlanTableProps } from "./map-plan-table";
8
+ export { MapPlanStatus, type MapPlanStatusProps } from "./map-plan-status";
9
+ export {
10
+ usePlanSelection,
11
+ type PlanSelection,
12
+ type UsePlanSelectionOptions,
13
+ } from "./use-plan-selection";
@@ -0,0 +1,61 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+
4
+ import { PLAN_STATUSES } from "../lib/plan-status";
5
+ import { MapPlanLegend } from "./map-plan-legend";
6
+
7
+ const LABELS = {
8
+ free: "free",
9
+ occupied: "in use",
10
+ warning: "attention",
11
+ down: "stopped",
12
+ };
13
+
14
+ afterEach(cleanup);
15
+
16
+ describe("MapPlanLegend", () => {
17
+ it("names every status in words, which is the channel greyscale keeps", () => {
18
+ render(<MapPlanLegend labels={LABELS} label="Status key" />);
19
+
20
+ const list = screen.getByRole("list", { name: "Status key" });
21
+ expect(screen.getAllByRole("listitem")).toHaveLength(PLAN_STATUSES.length);
22
+ for (const word of Object.values(LABELS)) {
23
+ expect(list).toHaveTextContent(word);
24
+ }
25
+ });
26
+
27
+ it("gives each swatch its own border style, so the key matches the outlines on the map", () => {
28
+ render(<MapPlanLegend labels={LABELS} />);
29
+
30
+ const swatches = document.querySelectorAll('[data-slot="map-plan-legend-swatch"]');
31
+ expect(swatches).toHaveLength(PLAN_STATUSES.length);
32
+ const styles = new Set(
33
+ [...swatches].map((swatch) =>
34
+ [...swatch.classList].filter((name) => name.startsWith("border")).join(" "),
35
+ ),
36
+ );
37
+ expect(styles.size).toBe(PLAN_STATUSES.length);
38
+ });
39
+
40
+ it("leaves out a status with no word, and renders nothing when none has one", () => {
41
+ const { rerender } = render(<MapPlanLegend labels={{ free: "free", down: "stopped" }} />);
42
+ expect(screen.getAllByRole("listitem")).toHaveLength(2);
43
+
44
+ rerender(<MapPlanLegend labels={{}} />);
45
+ expect(screen.queryByRole("list")).not.toBeInTheDocument();
46
+ });
47
+
48
+ it("shows a count beside the word when one is given", () => {
49
+ render(<MapPlanLegend labels={LABELS} counts={{ free: 4, down: 0 }} />);
50
+
51
+ expect(screen.getByText("4")).toBeInTheDocument();
52
+ expect(screen.getByText("0")).toBeInTheDocument();
53
+ });
54
+
55
+ it("orders the statuses as asked", () => {
56
+ render(<MapPlanLegend labels={LABELS} statuses={["down", "free"]} />);
57
+
58
+ const items = screen.getAllByRole("listitem").map((item) => item.getAttribute("data-status"));
59
+ expect(items).toEqual(["down", "free"]);
60
+ });
61
+ });
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
4
+
5
+ import { PLAN_STATUSES, PLAN_STATUS_ENCODING, type PlanStatus } from "../lib/plan-status";
6
+
7
+ /** Words for each status, supplied by the consumer so they can be localized. */
8
+ export type PlanStatusLabels = Partial<Record<PlanStatus, string>>;
9
+
10
+ export interface MapPlanLegendProps {
11
+ /** The word for each status. A status with no word is left out of the legend. */
12
+ labels: PlanStatusLabels;
13
+ /** Only show these statuses, in this order. Defaults to all four, in table order. */
14
+ statuses?: readonly PlanStatus[];
15
+ /** Optional count per status, e.g. how many rooms are free right now. */
16
+ counts?: Partial<Record<PlanStatus, number>>;
17
+ /** Accessible name for the legend as a whole. */
18
+ label?: string;
19
+ className?: string;
20
+ }
21
+
22
+ /**
23
+ * The key to the plan's status channels — and the place a sighted user learns
24
+ * that the dashes and textures mean something.
25
+ *
26
+ * Each entry repeats the status three ways: the tone as a swatch, the outline
27
+ * style as a rule under it, and the word. The two states that must never be
28
+ * confused with a working one also carry their glyph. In greyscale the outline
29
+ * styles and the words still tell them apart.
30
+ */
31
+ export function MapPlanLegend({
32
+ labels,
33
+ statuses = PLAN_STATUSES,
34
+ counts,
35
+ label,
36
+ className,
37
+ }: MapPlanLegendProps) {
38
+ const entries = statuses.filter((status) => labels[status]);
39
+ if (entries.length === 0) return null;
40
+
41
+ return (
42
+ <ul
43
+ data-slot="map-plan-legend"
44
+ aria-label={label}
45
+ className={cn("flex flex-wrap items-center gap-x-5 gap-y-2", className)}
46
+ >
47
+ {entries.map((status) => {
48
+ const encoding = PLAN_STATUS_ENCODING[status];
49
+ const Icon = encoding.icon;
50
+ const count = counts?.[status];
51
+
52
+ return (
53
+ <li
54
+ key={status}
55
+ data-slot="map-plan-legend-item"
56
+ data-status={status}
57
+ className="flex items-center gap-2 text-caption text-foreground"
58
+ >
59
+ <span
60
+ data-slot="map-plan-legend-swatch"
61
+ aria-hidden="true"
62
+ className={cn(
63
+ "inline-block size-3 rounded-xs",
64
+ encoding.markClass,
65
+ // The outline style, as the swatch's own border: the same
66
+ // second channel the shape on the map carries.
67
+ encoding.dash === "solid" && "border border-foreground/40",
68
+ encoding.dash === "dotted" && "border border-dotted border-foreground/70",
69
+ encoding.dash === "dashed" && "border border-dashed border-foreground/70",
70
+ encoding.dash === "dot-dash" && "border-2 border-dashed border-foreground/70",
71
+ )}
72
+ />
73
+ {Icon && <Icon className={cn("size-3.5", encoding.textClass)} aria-hidden="true" />}
74
+ <span>{labels[status]}</span>
75
+ {count !== undefined && (
76
+ <span className="tabular-nums text-muted-foreground">{count}</span>
77
+ )}
78
+ </li>
79
+ );
80
+ })}
81
+ </ul>
82
+ );
83
+ }
@@ -0,0 +1,260 @@
1
+ import { useMemo, 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 { planRegionsFromGeoJSON, type MapPlanRegion } from "../lib/plan-regions";
10
+ import { PLAN_FILL_OPACITY, planStatusMatch, type PlanStatus } from "../lib/plan-status";
11
+ import { MapPlanLegend } from "./map-plan-legend";
12
+ import { MapPlanOverlay } from "./map-plan-overlay";
13
+ import { MapPlanStatus } from "./map-plan-status";
14
+ import { MapPlanTable } from "./map-plan-table";
15
+ import { usePlanSelection } from "./use-plan-selection";
16
+
17
+ const PLAN = { width: 1200, height: 600, unit: "cm" } as const;
18
+
19
+ interface BayProperties {
20
+ id: string;
21
+ name: string;
22
+ /** Which half of the hall the bay sits in — the group, in group mode. */
23
+ side: string;
24
+ sideLabel: string;
25
+ status: PlanStatus;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ const STATE_WORD: Partial<Record<PlanStatus, string>> = {
30
+ free: "Free",
31
+ occupied: "In use",
32
+ warning: "Needs attention",
33
+ down: "Out of service",
34
+ };
35
+
36
+ /** Four bays, two a side: small enough to read in a story, big enough for group mode. */
37
+ const BAYS: GeoJSON.FeatureCollection<GeoJSON.Polygon, BayProperties> = {
38
+ type: "FeatureCollection",
39
+ features: (
40
+ [
41
+ ["west-1", "Bay 1", "west", "West side", "free", 60, 60],
42
+ ["west-2", "Bay 2", "west", "West side", "occupied", 60, 330],
43
+ ["east-1", "Bay 3", "east", "East side", "warning", 640, 60],
44
+ ["east-2", "Bay 4", "east", "East side", "down", 640, 330],
45
+ ] as [string, string, string, string, PlanStatus, number, number][]
46
+ ).map(([id, name, side, sideLabel, status, x, y]) => ({
47
+ type: "Feature",
48
+ id,
49
+ properties: { id, name, side, sideLabel, status },
50
+ geometry: {
51
+ type: "Polygon",
52
+ coordinates: [
53
+ [
54
+ [x, y],
55
+ [x + 500, y],
56
+ [x + 500, y + 210],
57
+ [x, y + 210],
58
+ [x, y],
59
+ ],
60
+ ],
61
+ },
62
+ })),
63
+ };
64
+
65
+ const meta = {
66
+ title: "Maps/MapPlanOverlay",
67
+ component: MapPlanOverlay,
68
+ tags: ["autodocs"],
69
+ parameters: {
70
+ layout: "fullscreen",
71
+ docs: {
72
+ description: {
73
+ component:
74
+ 'A shape drawn in WebGL is not a DOM element, so it cannot be focused, named or pressed. `MapPlanOverlay` puts one real `<button aria-pressed>` over each region of a plan — roving tabindex, arrow keys that travel the way the plan looks, `Enter` to select, `Escape` to clear — and projects the boxes in a single coalesced pass per frame.\n\nPast what a keyboard can reasonably walk (a train with 192 seats), `mode="groups"` puts one button on each group instead and `Enter` descends into its members. The parts beside it carry the same facts in other channels: `MapPlanLegend` says what the appearances mean, `MapPlanTable` is the plan as words, and `MapPlanStatus` is one polite live region per plan.',
75
+ },
76
+ },
77
+ },
78
+ } satisfies Meta<typeof MapPlanOverlay>;
79
+ export default meta;
80
+ type Story = StoryObj<typeof meta>;
81
+
82
+ function bayState(region: MapPlanRegion) {
83
+ return BAYS.features.find((feature) => feature.properties.id === region.id)?.properties.status;
84
+ }
85
+
86
+ function BayLayers({
87
+ selectedId,
88
+ hoveredId,
89
+ }: {
90
+ selectedId: readonly string[];
91
+ hoveredId: string | null;
92
+ }) {
93
+ const primary = useTokenColor("--primary");
94
+ const free = useTokenColor("--success");
95
+ const busy = useTokenColor("--muted-foreground");
96
+ const warning = useTokenColor("--warning");
97
+ const down = useTokenColor("--destructive");
98
+ const tone: Record<PlanStatus, string> = { free, occupied: busy, warning, down };
99
+
100
+ return (
101
+ <MapGeoJSON<BayProperties>
102
+ id="bays"
103
+ data={BAYS}
104
+ promoteId="id"
105
+ selectedId={selectedId}
106
+ hoveredId={hoveredId}
107
+ fillPaint={{
108
+ "fill-color": planStatusMatch("status", (_encoding, status) => tone[status]) as never,
109
+ "fill-opacity": PLAN_FILL_OPACITY.rest,
110
+ }}
111
+ fillHoverPaint={{ "fill-opacity": PLAN_FILL_OPACITY.hover }}
112
+ fillSelectedPaint={{ "fill-opacity": PLAN_FILL_OPACITY.selected }}
113
+ // The dash is the STATE channel and reads feature properties; selection rides
114
+ // feature-state instead, so the two can never overwrite each other.
115
+ linePaint={{
116
+ "line-color": planStatusMatch("status", (_encoding, status) => tone[status]) as never,
117
+ "line-width": 1.75,
118
+ "line-dasharray": planStatusMatch("status", (encoding) => [
119
+ "literal",
120
+ encoding.dashArray ?? [1, 0],
121
+ ]) as never,
122
+ }}
123
+ lineSelectedPaint={{ "line-color": primary, "line-width": 3.5 }}
124
+ />
125
+ );
126
+ }
127
+
128
+ /** One button per region: the default, for a plan a keyboard can walk. */
129
+ export const Default: Story = {
130
+ render: () => <PlanDemo />,
131
+ play: async ({ canvas }) => {
132
+ // The exact accessible name: the visible label comes first (WCAG 2.5.3), and if the
133
+ // browser ever fails to hand MapLibre a WebGL context this fails instead of passing
134
+ // on an error panel.
135
+ await expect(await canvas.findByRole("button", { name: "Bay 1, Free" })).toBeInTheDocument();
136
+ await expect(canvas.getByRole("list", { name: "Bays in this hall" })).toBeInTheDocument();
137
+ },
138
+ };
139
+
140
+ function PlanDemo({ mode = "regions" as const }: { mode?: "regions" | "groups" }) {
141
+ const regions = useMemo(
142
+ () =>
143
+ planRegionsFromGeoJSON(BAYS, {
144
+ id: "id",
145
+ label: "name",
146
+ description: (properties) => STATE_WORD[properties.status] ?? "",
147
+ group: "side",
148
+ groupLabel: "sideLabel",
149
+ }),
150
+ [],
151
+ );
152
+ const selection = usePlanSelection();
153
+ const [activeId, setActiveId] = useState<string | null>(null);
154
+
155
+ const selected = BAYS.features.find(
156
+ (feature) => feature.properties.id === selection.selectedId,
157
+ )?.properties;
158
+
159
+ return (
160
+ <div className="flex h-[620px] flex-col gap-3 p-4">
161
+ <MapPlanLegend labels={STATE_WORD} label="What each bay’s appearance means" />
162
+
163
+ <div className="relative min-h-0 flex-1 overflow-hidden rounded-md">
164
+ <MapCanvas blank plan={PLAN} className="h-full">
165
+ <BayLayers selectedId={selection.selectedIds} hoveredId={activeId} />
166
+ <MapPlanOverlay
167
+ regions={regions}
168
+ label="Bays in this hall"
169
+ mode={mode}
170
+ describeGroup={(group) => `${group.members.length} bays`}
171
+ selectedId={selection.selectedIds}
172
+ onSelect={(id) => selection.select(id)}
173
+ onActiveChange={setActiveId}
174
+ />
175
+ </MapCanvas>
176
+ </div>
177
+
178
+ <MapPlanStatus
179
+ message={selected ? `${selected.name} selected.` : "Nothing selected in this hall."}
180
+ />
181
+
182
+ <MapPlanTable
183
+ regions={regions}
184
+ caption="Every bay in the hall, with its state"
185
+ nameHeader="Bay"
186
+ status={bayState}
187
+ statusLabels={STATE_WORD}
188
+ statusHeader="State"
189
+ selectedId={selection.selectedIds}
190
+ onSelect={(id) => selection.select(id)}
191
+ />
192
+ </div>
193
+ );
194
+ }
195
+
196
+ /**
197
+ * Group mode: one button per group, `Enter` to go in, `Escape` to come back out,
198
+ * `PageUp`/`PageDown` to change group at either level.
199
+ */
200
+ export const Groups: Story = {
201
+ render: () => <PlanDemo mode="groups" />,
202
+ play: async ({ canvas, userEvent }) => {
203
+ const side = await canvas.findByRole("button", { name: "West side, 2 bays" });
204
+ // A group's button is a way in, not a toggle.
205
+ await expect(side).not.toHaveAttribute("aria-pressed");
206
+ side.focus();
207
+ await userEvent.keyboard("{Enter}");
208
+ await expect(await canvas.findByRole("button", { name: "Bay 1, Free" })).toBeInTheDocument();
209
+ await userEvent.keyboard("{Escape}");
210
+ await expect(await canvas.findByRole("button", { name: "East side, 2 bays" })).toBeVisible();
211
+ },
212
+ };
213
+
214
+ /** The plan as words only — what a printer gets, since a WebGL canvas prints blank. */
215
+ export const AsWords: Story = {
216
+ render: () => {
217
+ const regions = planRegionsFromGeoJSON(BAYS, { id: "id", label: "name" });
218
+ return (
219
+ <div className="space-y-3 p-4">
220
+ <p className="text-caption text-muted-foreground">
221
+ The same facts as the plan, in the channel that survives a printer and a screen reader. In
222
+ a plan meant for paper this table carries <code>printOnly</code>.
223
+ </p>
224
+ <MapPlanTable
225
+ regions={regions}
226
+ caption="Every bay in the hall, with its state"
227
+ nameHeader="Bay"
228
+ status={bayState}
229
+ statusLabels={STATE_WORD}
230
+ statusHeader="State"
231
+ />
232
+ </div>
233
+ );
234
+ },
235
+ };
236
+
237
+ /** Nothing on the plan yet: the canvas keeps its shape and says so once. */
238
+ export const Loading: Story = {
239
+ render: () => (
240
+ <div className="h-[420px] p-4">
241
+ <MapCanvas blank plan={PLAN} loading className="h-full" />
242
+ </div>
243
+ ),
244
+ };
245
+
246
+ /** A plan with no regions on it — a panel, not an empty grid. */
247
+ export const NoRegions: Story = {
248
+ render: () => (
249
+ <div className="relative h-[420px] p-4">
250
+ <MapCanvas blank plan={PLAN} className="h-full" />
251
+ <div className="absolute inset-0 grid place-items-center p-8">
252
+ <StatePanel
253
+ kind="empty"
254
+ title="No bays in this hall"
255
+ description="Import the hall’s bays to see them laid out here."
256
+ />
257
+ </div>
258
+ </div>
259
+ ),
260
+ };