@elabs-ai/components-maps 4.2.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +1 -1
  2. package/dist/chunk-2FPB53NG.js +36 -0
  3. package/dist/chunk-2FPB53NG.js.map +1 -0
  4. package/dist/chunk-2VVJRT66.js +37 -0
  5. package/dist/chunk-2VVJRT66.js.map +1 -0
  6. package/dist/chunk-3HXFONRP.js +2 -0
  7. package/dist/chunk-3HXFONRP.js.map +1 -0
  8. package/dist/chunk-3SJN3IYT.js +2 -0
  9. package/dist/chunk-3SJN3IYT.js.map +1 -0
  10. package/dist/chunk-4DANBD5B.js +55 -0
  11. package/dist/chunk-4DANBD5B.js.map +1 -0
  12. package/dist/chunk-5PDVG53B.js +2 -0
  13. package/dist/chunk-5PDVG53B.js.map +1 -0
  14. package/dist/chunk-643R7IJC.js +2 -0
  15. package/dist/chunk-643R7IJC.js.map +1 -0
  16. package/dist/chunk-B3ZDBQ6I.js +164 -0
  17. package/dist/chunk-B3ZDBQ6I.js.map +1 -0
  18. package/dist/chunk-BGCE3RR5.js +35 -0
  19. package/dist/chunk-BGCE3RR5.js.map +1 -0
  20. package/dist/chunk-C4VI37TD.js +485 -0
  21. package/dist/chunk-C4VI37TD.js.map +1 -0
  22. package/dist/chunk-CJJT6AEI.js +179 -0
  23. package/dist/chunk-CJJT6AEI.js.map +1 -0
  24. package/dist/chunk-D2RCPTRO.js +2 -0
  25. package/dist/chunk-D2RCPTRO.js.map +1 -0
  26. package/dist/chunk-DTV7JZVN.js +2 -0
  27. package/dist/chunk-DTV7JZVN.js.map +1 -0
  28. package/dist/chunk-E65Z3MSV.js +80 -0
  29. package/dist/chunk-E65Z3MSV.js.map +1 -0
  30. package/dist/chunk-E7VPNINZ.js +70 -0
  31. package/dist/chunk-E7VPNINZ.js.map +1 -0
  32. package/dist/chunk-FHV5IA6N.js +100 -0
  33. package/dist/chunk-FHV5IA6N.js.map +1 -0
  34. package/dist/chunk-FQ6V7BFZ.js +347 -0
  35. package/dist/chunk-FQ6V7BFZ.js.map +1 -0
  36. package/dist/chunk-G7JDXDJN.js +19 -0
  37. package/dist/chunk-G7JDXDJN.js.map +1 -0
  38. package/dist/chunk-GWULZOMW.js +21 -0
  39. package/dist/chunk-GWULZOMW.js.map +1 -0
  40. package/dist/chunk-GYDTTAVD.js +120 -0
  41. package/dist/chunk-GYDTTAVD.js.map +1 -0
  42. package/dist/chunk-HCOBWIM4.js +48 -0
  43. package/dist/chunk-HCOBWIM4.js.map +1 -0
  44. package/dist/chunk-IAHK5MVT.js +2 -0
  45. package/dist/chunk-IAHK5MVT.js.map +1 -0
  46. package/dist/chunk-JGJ6442I.js +235 -0
  47. package/dist/chunk-JGJ6442I.js.map +1 -0
  48. package/dist/chunk-JYFERXZT.js +18 -0
  49. package/dist/chunk-JYFERXZT.js.map +1 -0
  50. package/dist/chunk-LU2UEKFM.js +53 -0
  51. package/dist/chunk-LU2UEKFM.js.map +1 -0
  52. package/dist/chunk-LVYM6C5C.js +2 -0
  53. package/dist/chunk-LVYM6C5C.js.map +1 -0
  54. package/dist/chunk-MIMDP3EU.js +104 -0
  55. package/dist/chunk-MIMDP3EU.js.map +1 -0
  56. package/dist/chunk-NNBURJHY.js +2 -0
  57. package/dist/chunk-NNBURJHY.js.map +1 -0
  58. package/dist/chunk-NPUC3V37.js +2 -0
  59. package/dist/chunk-NPUC3V37.js.map +1 -0
  60. package/dist/chunk-OEVVNPN3.js +201 -0
  61. package/dist/chunk-OEVVNPN3.js.map +1 -0
  62. package/dist/chunk-PF3R2BXM.js +200 -0
  63. package/dist/chunk-PF3R2BXM.js.map +1 -0
  64. package/dist/chunk-Q2I5YODW.js +2 -0
  65. package/dist/chunk-Q2I5YODW.js.map +1 -0
  66. package/dist/chunk-QDWWP3N7.js +247 -0
  67. package/dist/chunk-QDWWP3N7.js.map +1 -0
  68. package/dist/chunk-QU33KPMP.js +2 -0
  69. package/dist/chunk-QU33KPMP.js.map +1 -0
  70. package/dist/chunk-RK4BUMHS.js +2 -0
  71. package/dist/chunk-RK4BUMHS.js.map +1 -0
  72. package/dist/chunk-RNSLG64F.js +26 -0
  73. package/dist/chunk-RNSLG64F.js.map +1 -0
  74. package/dist/chunk-RV673TB4.js +68 -0
  75. package/dist/chunk-RV673TB4.js.map +1 -0
  76. package/dist/chunk-S7N2WMVT.js +180 -0
  77. package/dist/chunk-S7N2WMVT.js.map +1 -0
  78. package/dist/chunk-T3Z6TCDD.js +2 -0
  79. package/dist/chunk-T3Z6TCDD.js.map +1 -0
  80. package/dist/chunk-T4TJTVVA.js +105 -0
  81. package/dist/chunk-T4TJTVVA.js.map +1 -0
  82. package/dist/chunk-UMOYRCDX.js +103 -0
  83. package/dist/chunk-UMOYRCDX.js.map +1 -0
  84. package/dist/chunk-V2REM6UT.js +185 -0
  85. package/dist/chunk-V2REM6UT.js.map +1 -0
  86. package/dist/chunk-VFBRFGID.js +89 -0
  87. package/dist/chunk-VFBRFGID.js.map +1 -0
  88. package/dist/chunk-W76NLFER.js +103 -0
  89. package/dist/chunk-W76NLFER.js.map +1 -0
  90. package/dist/chunk-WNU4IOGL.js +116 -0
  91. package/dist/chunk-WNU4IOGL.js.map +1 -0
  92. package/dist/chunk-WS4Z7ENS.js +494 -0
  93. package/dist/chunk-WS4Z7ENS.js.map +1 -0
  94. package/dist/chunk-XQT7YLJY.js +497 -0
  95. package/dist/chunk-XQT7YLJY.js.map +1 -0
  96. package/dist/chunk-Y44MEKJV.js +21 -0
  97. package/dist/chunk-Y44MEKJV.js.map +1 -0
  98. package/dist/chunk-Z4EPSOB4.js +53 -0
  99. package/dist/chunk-Z4EPSOB4.js.map +1 -0
  100. package/dist/chunk-Z5OPDBDC.js +2 -0
  101. package/dist/chunk-Z5OPDBDC.js.map +1 -0
  102. package/dist/index.d.ts +1241 -46
  103. package/dist/index.js +189 -1471
  104. package/dist/index.js.map +1 -1
  105. package/dist/lib/arc-math.js +8 -0
  106. package/dist/lib/arc-math.js.map +1 -0
  107. package/dist/lib/map-position.js +11 -0
  108. package/dist/lib/map-position.js.map +1 -0
  109. package/dist/lib/merge-hover-paint.js +10 -0
  110. package/dist/lib/merge-hover-paint.js.map +1 -0
  111. package/dist/lib/plan-crs.js +14 -0
  112. package/dist/lib/plan-crs.js.map +1 -0
  113. package/dist/lib/plan-patterns.js +14 -0
  114. package/dist/lib/plan-patterns.js.map +1 -0
  115. package/dist/lib/plan-regions.js +18 -0
  116. package/dist/lib/plan-regions.js.map +1 -0
  117. package/dist/lib/plan-status.js +14 -0
  118. package/dist/lib/plan-status.js.map +1 -0
  119. package/dist/lib/route-arrow.js +12 -0
  120. package/dist/lib/route-arrow.js.map +1 -0
  121. package/dist/lib/use-map-breakpoint.js +33 -0
  122. package/dist/lib/use-map-breakpoint.js.map +1 -0
  123. package/dist/lib/use-plan-projection.js +9 -0
  124. package/dist/lib/use-plan-projection.js.map +1 -0
  125. package/dist/lib/use-token-color.js +10 -0
  126. package/dist/lib/use-token-color.js.map +1 -0
  127. package/dist/lib/warn-once.js +10 -0
  128. package/dist/lib/warn-once.js.map +1 -0
  129. package/dist/map-annotation/anchor.js +14 -0
  130. package/dist/map-annotation/anchor.js.map +1 -0
  131. package/dist/map-annotation/index.js +21 -0
  132. package/dist/map-annotation/index.js.map +1 -0
  133. package/dist/map-annotation/map-annotation.js +18 -0
  134. package/dist/map-annotation/map-annotation.js.map +1 -0
  135. package/dist/map-arc/index.js +16 -0
  136. package/dist/map-arc/index.js.map +1 -0
  137. package/dist/map-arc/map-arc.js +13 -0
  138. package/dist/map-arc/map-arc.js.map +1 -0
  139. package/dist/map-canvas/index.css +11 -0
  140. package/dist/map-canvas/index.css.map +1 -0
  141. package/dist/map-canvas/index.js +20 -0
  142. package/dist/map-canvas/index.js.map +1 -0
  143. package/dist/map-canvas/map-canvas.css +11 -0
  144. package/dist/map-canvas/map-canvas.css.map +1 -0
  145. package/dist/map-canvas/map-canvas.js +17 -0
  146. package/dist/map-canvas/map-canvas.js.map +1 -0
  147. package/dist/map-canvas/map-context.js +11 -0
  148. package/dist/map-canvas/map-context.js.map +1 -0
  149. package/dist/map-canvas/use-resolved-basemap-theme.js +9 -0
  150. package/dist/map-canvas/use-resolved-basemap-theme.js.map +1 -0
  151. package/dist/map-cluster-layer/index.js +12 -0
  152. package/dist/map-cluster-layer/index.js.map +1 -0
  153. package/dist/map-cluster-layer/map-cluster-layer.js +12 -0
  154. package/dist/map-cluster-layer/map-cluster-layer.js.map +1 -0
  155. package/dist/map-controls/index.js +11 -0
  156. package/dist/map-controls/index.js.map +1 -0
  157. package/dist/map-controls/map-controls.js +11 -0
  158. package/dist/map-controls/map-controls.js.map +1 -0
  159. package/dist/map-geojson/index.js +13 -0
  160. package/dist/map-geojson/index.js.map +1 -0
  161. package/dist/map-geojson/map-geojson.js +13 -0
  162. package/dist/map-geojson/map-geojson.js.map +1 -0
  163. package/dist/map-inset/index.css +11 -0
  164. package/dist/map-inset/index.css.map +1 -0
  165. package/dist/map-inset/index.js +20 -0
  166. package/dist/map-inset/index.js.map +1 -0
  167. package/dist/map-inset/map-inset.css +11 -0
  168. package/dist/map-inset/map-inset.css.map +1 -0
  169. package/dist/map-inset/map-inset.js +20 -0
  170. package/dist/map-inset/map-inset.js.map +1 -0
  171. package/dist/map-legend/index.js +14 -0
  172. package/dist/map-legend/index.js.map +1 -0
  173. package/dist/map-legend/map-corner.js +10 -0
  174. package/dist/map-legend/map-corner.js.map +1 -0
  175. package/dist/map-legend/map-legend.js +11 -0
  176. package/dist/map-legend/map-legend.js.map +1 -0
  177. package/dist/map-marker/index.js +22 -0
  178. package/dist/map-marker/index.js.map +1 -0
  179. package/dist/map-marker/map-marker.js +22 -0
  180. package/dist/map-marker/map-marker.js.map +1 -0
  181. package/dist/map-north-arrow/index.js +11 -0
  182. package/dist/map-north-arrow/index.js.map +1 -0
  183. package/dist/map-north-arrow/map-north-arrow.js +11 -0
  184. package/dist/map-north-arrow/map-north-arrow.js.map +1 -0
  185. package/dist/map-plan/index.js +30 -0
  186. package/dist/map-plan/index.js.map +1 -0
  187. package/dist/map-plan/map-plan-legend.js +10 -0
  188. package/dist/map-plan/map-plan-legend.js.map +1 -0
  189. package/dist/map-plan/map-plan-overlay.js +13 -0
  190. package/dist/map-plan/map-plan-overlay.js.map +1 -0
  191. package/dist/map-plan/map-plan-status.js +9 -0
  192. package/dist/map-plan/map-plan-status.js.map +1 -0
  193. package/dist/map-plan/map-plan-table.js +10 -0
  194. package/dist/map-plan/map-plan-table.js.map +1 -0
  195. package/dist/map-plan/use-plan-selection.js +9 -0
  196. package/dist/map-plan/use-plan-selection.js.map +1 -0
  197. package/dist/map-plan-image/index.js +11 -0
  198. package/dist/map-plan-image/index.js.map +1 -0
  199. package/dist/map-plan-image/map-plan-image.js +11 -0
  200. package/dist/map-plan-image/map-plan-image.js.map +1 -0
  201. package/dist/map-popup/index.js +12 -0
  202. package/dist/map-popup/index.js.map +1 -0
  203. package/dist/map-popup/map-popup.js +12 -0
  204. package/dist/map-popup/map-popup.js.map +1 -0
  205. package/dist/map-route/index.js +12 -0
  206. package/dist/map-route/index.js.map +1 -0
  207. package/dist/map-route/map-route.js +12 -0
  208. package/dist/map-route/map-route.js.map +1 -0
  209. package/dist/map-scale-bar/index.js +18 -0
  210. package/dist/map-scale-bar/index.js.map +1 -0
  211. package/dist/map-scale-bar/map-scale-bar.js +18 -0
  212. package/dist/map-scale-bar/map-scale-bar.js.map +1 -0
  213. package/package.json +5 -5
  214. package/src/index.ts +83 -0
  215. package/src/lib/arc-math.test.ts +19 -0
  216. package/src/lib/arc-math.ts +6 -1
  217. package/src/lib/map-position.ts +77 -0
  218. package/src/lib/merge-hover-paint.ts +44 -13
  219. package/src/lib/plan-crs.parity.test.ts +39 -0
  220. package/src/lib/plan-crs.test.ts +248 -0
  221. package/src/lib/plan-crs.ts +252 -0
  222. package/src/lib/plan-patterns.test.ts +255 -0
  223. package/src/lib/plan-patterns.ts +160 -0
  224. package/src/lib/plan-regions.test.ts +186 -0
  225. package/src/lib/plan-regions.ts +209 -0
  226. package/src/lib/plan-status.test.ts +87 -0
  227. package/src/lib/plan-status.ts +147 -0
  228. package/src/lib/route-arrow.test.ts +88 -0
  229. package/src/lib/route-arrow.ts +59 -0
  230. package/src/lib/use-map-breakpoint.test.ts +83 -0
  231. package/src/lib/use-map-breakpoint.ts +238 -0
  232. package/src/lib/use-plan-projection.ts +157 -0
  233. package/src/lib/warn-once.ts +21 -0
  234. package/src/map-annotation/anchor.ts +92 -0
  235. package/src/map-annotation/index.ts +15 -0
  236. package/src/map-annotation/map-annotation.stories.tsx +113 -0
  237. package/src/map-annotation/map-annotation.test.tsx +106 -0
  238. package/src/map-annotation/map-annotation.tsx +356 -0
  239. package/src/map-arc/map-arc.tsx +26 -5
  240. package/src/map-canvas/map-canvas.stories.tsx +211 -0
  241. package/src/map-canvas/map-canvas.test.tsx +389 -0
  242. package/src/map-canvas/map-canvas.tsx +457 -17
  243. package/src/map-canvas/map-context.ts +11 -0
  244. package/src/map-cluster-layer/map-cluster-layer.tsx +14 -1
  245. package/src/map-controls/map-controls.stories.tsx +16 -0
  246. package/src/map-controls/map-controls.test.tsx +145 -1
  247. package/src/map-controls/map-controls.tsx +57 -12
  248. package/src/map-geojson/map-geojson.stories.tsx +153 -3
  249. package/src/map-geojson/map-geojson.test.tsx +453 -0
  250. package/src/map-geojson/map-geojson.tsx +591 -51
  251. package/src/map-inset/index.ts +1 -0
  252. package/src/map-inset/map-inset.stories.tsx +61 -0
  253. package/src/map-inset/map-inset.test.tsx +117 -0
  254. package/src/map-inset/map-inset.tsx +264 -0
  255. package/src/map-legend/index.ts +10 -0
  256. package/src/map-legend/map-corner.ts +23 -0
  257. package/src/map-legend/map-legend.stories.tsx +171 -0
  258. package/src/map-legend/map-legend.test.tsx +136 -0
  259. package/src/map-legend/map-legend.tsx +264 -0
  260. package/src/map-marker/map-marker.stories.tsx +136 -4
  261. package/src/map-marker/map-marker.test.tsx +328 -3
  262. package/src/map-marker/map-marker.tsx +370 -33
  263. package/src/map-north-arrow/index.ts +1 -0
  264. package/src/map-north-arrow/map-north-arrow.stories.tsx +65 -0
  265. package/src/map-north-arrow/map-north-arrow.test.tsx +39 -0
  266. package/src/map-north-arrow/map-north-arrow.tsx +82 -0
  267. package/src/map-plan/index.ts +13 -0
  268. package/src/map-plan/map-plan-legend.test.tsx +61 -0
  269. package/src/map-plan/map-plan-legend.tsx +83 -0
  270. package/src/map-plan/map-plan-overlay.stories.tsx +260 -0
  271. package/src/map-plan/map-plan-overlay.test.tsx +302 -0
  272. package/src/map-plan/map-plan-overlay.tsx +531 -0
  273. package/src/map-plan/map-plan-status.test.tsx +71 -0
  274. package/src/map-plan/map-plan-status.tsx +77 -0
  275. package/src/map-plan/map-plan-table.test.tsx +86 -0
  276. package/src/map-plan/map-plan-table.tsx +153 -0
  277. package/src/map-plan/use-plan-selection.ts +78 -0
  278. package/src/map-plan-image/index.ts +1 -0
  279. package/src/map-plan-image/map-plan-image.stories.tsx +301 -0
  280. package/src/map-plan-image/map-plan-image.test.tsx +176 -0
  281. package/src/map-plan-image/map-plan-image.tsx +279 -0
  282. package/src/map-popup/map-popup.tsx +15 -10
  283. package/src/map-route/index.ts +1 -1
  284. package/src/map-route/map-route.test.tsx +216 -0
  285. package/src/map-route/map-route.tsx +105 -6
  286. package/src/map-scale-bar/index.ts +9 -0
  287. package/src/map-scale-bar/map-scale-bar.stories.tsx +63 -0
  288. package/src/map-scale-bar/map-scale-bar.test.tsx +65 -0
  289. package/src/map-scale-bar/map-scale-bar.tsx +145 -0
  290. package/src/test-utils/locator-fixture.ts +75 -0
  291. package/src/test-utils/maplibre-mock.ts +256 -19
@@ -4,8 +4,15 @@ import type MapLibreGL from "maplibre-gl";
4
4
  import { useEffect, useId, useMemo, useRef } from "react";
5
5
 
6
6
  import { useMap } from "../map-canvas/map-context";
7
- import { mergeHoverPaint } from "../lib/merge-hover-paint";
7
+ import { mergeFeatureStatePaint } from "../lib/merge-hover-paint";
8
+ import type { PlanPoint } from "../lib/plan-crs";
8
9
  import { useTokenColor } from "../lib/use-token-color";
10
+ import { warnMapOnce } from "../lib/warn-once";
11
+
12
+ /** Invisible line width, in px, that makes a hairline outline or a wall touchable. */
13
+ const DEFAULT_HIT_WIDTH = 16;
14
+ /** Half-size, in px, of the box a plan click is allowed to miss by. */
15
+ const DEFAULT_PLAN_PICK_PADDING = 8;
9
16
 
10
17
  export type MapGeoJSONData<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> =
11
18
  | GeoJSON.FeatureCollection<GeoJSON.Geometry, P>
@@ -16,6 +23,31 @@ export type MapGeoJSONData<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJson
16
23
  export type MapFillPaint = NonNullable<MapLibreGL.FillLayerSpecification["paint"]>;
17
24
  export type MapLinePaint = NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
18
25
 
26
+ /**
27
+ * A hatch drawn over the fill — the locator-map "area marker" look. `width` is
28
+ * a stripe's thickness and `gap` the space between stripes, both in CSS px
29
+ * measured across the 45° stripes. Stripes take the fill colour (a plain
30
+ * `fill-color`; an expression falls back to the `--foreground` token).
31
+ */
32
+ export interface MapGeoJSONPattern {
33
+ kind: "stripes";
34
+ /** Stripe thickness in px (default 2). */
35
+ width?: number;
36
+ /** Space between stripes in px (default 4). */
37
+ gap?: number;
38
+ }
39
+
40
+ /**
41
+ * A soft glow around the area's edge — a second, blurred line layer under the
42
+ * fill, in the fill colour. `width` is how far the glow reaches out, in px.
43
+ */
44
+ export interface MapGeoJSONVignette {
45
+ /** Glow reach in px (default 12). */
46
+ width?: number;
47
+ /** Glow opacity, 0–1 (default 0.35). */
48
+ opacity?: number;
49
+ }
50
+
19
51
  /** A rendered feature with strongly-typed `properties`. */
20
52
  export type MapGeoJSONFeature<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> =
21
53
  Omit<MapLibreGL.MapGeoJSONFeature, "properties"> & { properties: P };
@@ -28,18 +60,33 @@ export type MapGeoJSONEvent<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJso
28
60
  longitude: number;
29
61
  /** Latitude of the cursor at the time of the event. */
30
62
  latitude: number;
31
- /** The underlying MapLibre mouse event for advanced use cases. */
32
- originalEvent: MapLibreGL.MapLayerMouseEvent;
63
+ /**
64
+ * The cursor in PLAN units, when the canvas declares a `plan` extent —
65
+ * otherwise `null`. What a plan consumer actually wants: metres along the
66
+ * hall, not a synthesized longitude.
67
+ */
68
+ plan: PlanPoint | null;
69
+ /**
70
+ * The underlying MapLibre mouse event for advanced use cases. `null` when
71
+ * the feature was reached from the KEYBOARD rather than a pointer — there is
72
+ * no mouse event behind that (c-4/c-2, WCAG 2.1.1).
73
+ */
74
+ originalEvent: MapLibreGL.MapLayerMouseEvent | null;
33
75
  };
34
76
 
35
77
  export type MapGeoJSONProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = {
36
- /** GeoJSON data (FeatureCollection, Feature, Geometry) or a URL to fetch it from. */
78
+ /**
79
+ * GeoJSON data (FeatureCollection, Feature, Geometry) or a URL to fetch it
80
+ * from. On a canvas with a `plan` extent the coordinates are PLAN units —
81
+ * `[x, y]` in the plan's own drawing units — and are converted for you.
82
+ */
37
83
  data: MapGeoJSONData<P>;
38
84
  /** Optional unique identifier prefix for the source/layers. Auto-generated if not provided. */
39
85
  id?: string;
40
86
  /**
41
- * Feature property to promote to the feature `id`. Required for hover
42
- * feature-state (`fillHoverPaint`) and stable `onHover`/`onClick` payloads.
87
+ * Feature property to promote to the feature `id`. Required for hover and
88
+ * selected feature-state (`fillHoverPaint`, `selectedId`) and stable
89
+ * `onHover`/`onClick` payloads.
43
90
  */
44
91
  promoteId?: string;
45
92
  /**
@@ -61,12 +108,66 @@ export type MapGeoJSONProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJso
61
108
  * as a `case` expression keyed on hover feature-state. Requires `promoteId`.
62
109
  */
63
110
  fillHoverPaint?: MapFillPaint;
111
+ /**
112
+ * The fill layer's opacity, 0–1 — shorthand for `fillPaint["fill-opacity"]`
113
+ * (which wins when both are set). Default 1, or 0.25 with a `pattern`, so
114
+ * the stripes read over a tint of the same colour.
115
+ */
116
+ fillOpacity?: number;
117
+ /** Hatch the areas with stripes (see {@link MapGeoJSONPattern}). */
118
+ pattern?: MapGeoJSONPattern;
119
+ /** Glow around the areas' edges (see {@link MapGeoJSONVignette}). */
120
+ vignette?: MapGeoJSONVignette;
121
+ /** The outline equivalent of `fillHoverPaint`. Requires `promoteId`. */
122
+ lineHoverPaint?: MapLinePaint;
123
+ /**
124
+ * Paint merged onto the fill layer for the feature(s) named by `selectedId`.
125
+ * Applied outside hover, so a selected feature still looks selected while the
126
+ * cursor is over it. Requires `promoteId`.
127
+ */
128
+ fillSelectedPaint?: MapFillPaint;
129
+ /** The outline equivalent of `fillSelectedPaint`. Requires `promoteId`. */
130
+ lineSelectedPaint?: MapLinePaint;
131
+ /**
132
+ * The selected feature id(s) — the promoted `promoteId` values. Selection is
133
+ * the consumer's state; this only paints it.
134
+ */
135
+ selectedId?: string | number | readonly (string | number)[] | null;
136
+ /**
137
+ * Take over the highlight: when set, the hover paint follows THIS id instead
138
+ * of the pointer. That is how `MapPlanOverlay` makes a keyboard-focused room
139
+ * light up exactly as a hovered one does — one channel, one appearance.
140
+ */
141
+ hoveredId?: string | number | null;
64
142
  /** Callback when a feature is clicked. */
65
143
  onClick?: (e: MapGeoJSONEvent<P>) => void;
66
144
  /** Callback fired when the hovered feature changes; `null` when the cursor leaves. */
67
145
  onHover?: (e: MapGeoJSONEvent<P> | null) => void;
68
- /** Whether features respond to mouse events (default: false). */
146
+ /**
147
+ * Whether features respond to mouse events. Defaults to `false` on a
148
+ * geographic canvas and `true` on a plan canvas, where the shapes ARE the
149
+ * subject rather than a backdrop.
150
+ */
69
151
  interactive?: boolean;
152
+ /**
153
+ * The text of a feature in the keyboard list an `interactive` map renders —
154
+ * one visually-hidden button per feature, so the values a pointer reveals
155
+ * on hover are reachable by Tab and readable by a screen reader (WCAG 2.1.1
156
+ * and 1.3.1). Say what the region is AND what it is worth — the same
157
+ * sentence a hover readout prints. Defaults to the promoted id.
158
+ */
159
+ featureLabel?: (feature: MapGeoJSONFeature<P>, index: number) => string;
160
+ /**
161
+ * How far, in px, a click may miss a shape and still land on it. Defaults to
162
+ * 8 px on a plan (so a 20 px seat is a 36 px touch target) and 0 elsewhere.
163
+ * An exact hit always wins over a padded one.
164
+ */
165
+ pickPadding?: number;
166
+ /**
167
+ * Width, in px, of the invisible line that makes an outline-only shape
168
+ * touchable (default 16). Only used when `fillPaint` is `false`.
169
+ */
170
+ hitWidth?: number;
70
171
  /** Optional MapLibre layer id to insert the layers before (z-order control). */
71
172
  beforeId?: string;
72
173
  };
@@ -76,6 +177,16 @@ export type MapGeoJSONProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJso
76
177
  * `MapRoute` / `MapArc` — drop it inside `<MapCanvas>` (typically with `blank`)
77
178
  * for choropleths and region/data maps. For full control over expressions and
78
179
  * multiple layers, manage layers directly via `useMap()` instead.
180
+ *
181
+ * On a canvas that declares a plan extent it is also the shape layer of a
182
+ * custom (non-geographic) map: rooms, machine cells, aisles and seats written
183
+ * in the plan's own units.
184
+ *
185
+ * ```tsx
186
+ * <MapCanvas blank plan={{ width: 2400, height: 1600, unit: "cm" }}>
187
+ * <MapGeoJSON data={roomsInCentimetres} promoteId="id" selectedId={roomId} />
188
+ * </MapCanvas>
189
+ * ```
79
190
  */
80
191
  export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties>({
81
192
  data,
@@ -84,40 +195,116 @@ export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJson
84
195
  fillPaint,
85
196
  linePaint,
86
197
  fillHoverPaint,
198
+ fillOpacity,
199
+ pattern,
200
+ vignette,
201
+ lineHoverPaint,
202
+ fillSelectedPaint,
203
+ lineSelectedPaint,
204
+ selectedId,
205
+ hoveredId,
87
206
  onClick,
88
207
  onHover,
89
- interactive = false,
208
+ interactive: interactiveProp,
209
+ featureLabel,
210
+ pickPadding,
211
+ hitWidth = DEFAULT_HIT_WIDTH,
90
212
  beforeId,
91
213
  }: MapGeoJSONProps<P>) {
92
- const { map, isLoaded } = useMap();
214
+ const { map, isLoaded, plan } = useMap();
93
215
  const autoId = useId();
94
216
  const id = propId ?? autoId;
95
217
  const sourceId = `geojson-source-${id}`;
96
218
  const fillLayerId = `geojson-fill-${id}`;
97
219
  const lineLayerId = `geojson-line-${id}`;
220
+ const patternLayerId = `geojson-pattern-${id}`;
221
+ const vignetteLayerId = `geojson-vignette-${id}`;
222
+ const patternImageId = `geojson-pattern-image-${id}`;
223
+ const hitLayerId = `geojson-hit-${id}`;
224
+
225
+ const interactive = interactiveProp ?? plan != null;
226
+ const pickPad = pickPadding ?? (plan ? DEFAULT_PLAN_PICK_PADDING : 0);
98
227
 
99
228
  // Theme-driven neutral defaults: landmass = the mid-neutral `--border` rung,
100
229
  // separators = the page surface. Both re-resolve on theme change.
101
230
  const defaultFill = useTokenColor("--border");
102
231
  const defaultLine = useTokenColor("--background");
232
+ const defaultInk = useTokenColor("--foreground");
103
233
 
104
234
  const showFill = fillPaint !== false;
105
235
  const showLine = linePaint !== false;
236
+ // Without a fill there is nothing to click: a hairline outline is a 1 px
237
+ // target and a wall or conveyor has no interior at all.
238
+ const showHit = interactive && !showFill;
239
+
240
+ // Plan units in, Mercator out. A URL cannot be converted — the fetch happens
241
+ // inside the engine — so say so once instead of drawing the plan in the
242
+ // Atlantic.
243
+ if (plan && typeof data === "string") {
244
+ warnMapOnce(
245
+ "geojson-plan-url",
246
+ "<MapGeoJSON data={url}> cannot be converted to plan units. Fetch the GeoJSON yourself and pass the object, or pass coordinates already in lng/lat.",
247
+ );
248
+ }
249
+ if (interactive && !promoteId) {
250
+ warnMapOnce(
251
+ "geojson-promote-id",
252
+ "<MapGeoJSON interactive> needs `promoteId` to tell features apart: without it hover and selection paint nothing.",
253
+ );
254
+ }
255
+
256
+ const resolvedData = useMemo(
257
+ () => (plan && typeof data !== "string" ? plan.toGeoJSON(data) : data),
258
+ [plan, data],
259
+ );
106
260
 
261
+ const baseOpacity = fillOpacity ?? (pattern ? 0.25 : undefined);
107
262
  const mergedFillPaint = useMemo(
108
- () => mergeHoverPaint({ "fill-color": defaultFill, ...(fillPaint || {}) }, fillHoverPaint),
109
- [defaultFill, fillPaint, fillHoverPaint],
263
+ () =>
264
+ mergeFeatureStatePaint(
265
+ {
266
+ "fill-color": defaultFill,
267
+ ...(baseOpacity !== undefined ? { "fill-opacity": baseOpacity } : {}),
268
+ ...(fillPaint || {}),
269
+ },
270
+ { hover: fillHoverPaint, selected: fillSelectedPaint },
271
+ ),
272
+ [defaultFill, baseOpacity, fillPaint, fillHoverPaint, fillSelectedPaint],
110
273
  );
274
+ // The colour stripes and the glow take: the fill's own plain colour, or ink.
275
+ const configuredFill = fillPaint ? fillPaint["fill-color"] : undefined;
276
+ const areaColor =
277
+ configuredFill === undefined
278
+ ? defaultFill
279
+ : typeof configuredFill === "string"
280
+ ? configuredFill
281
+ : defaultInk;
282
+ const stripeWidth = Math.max(1, pattern?.width ?? 2);
283
+ const stripeGap = Math.max(0, pattern?.gap ?? 4);
284
+ const vignetteWidth = Math.max(0, vignette?.width ?? 12);
285
+ const vignetteOpacity = Math.min(1, Math.max(0, vignette?.opacity ?? 0.35));
286
+ const hasPattern = pattern !== undefined;
287
+ const hasVignette = vignette !== undefined;
288
+ // The tile drawn last; a new one is drawn only when colour or size change.
289
+ const patternDrawnRef = useRef<string | null>(null);
111
290
  const mergedLinePaint = useMemo(
112
- () => ({
113
- "line-color": defaultLine,
114
- "line-width": 0.5,
115
- ...(linePaint || {}),
116
- }),
117
- [defaultLine, linePaint],
291
+ () =>
292
+ mergeFeatureStatePaint(
293
+ {
294
+ "line-color": defaultLine,
295
+ "line-width": 0.5,
296
+ ...(linePaint || {}),
297
+ },
298
+ { hover: lineHoverPaint, selected: lineSelectedPaint },
299
+ ),
300
+ [defaultLine, linePaint, lineHoverPaint, lineSelectedPaint],
118
301
  );
119
- const latestRef = useRef({ onClick, onHover });
120
- latestRef.current = { onClick, onHover };
302
+ const latestRef = useRef({ onClick, onHover, plan });
303
+ latestRef.current = { onClick, onHover, plan };
304
+ // c-2: the pointer path owns the hover feature-state inside its effect; the
305
+ // keyboard list below drives the SAME highlight through this handle, so a
306
+ // focused region lights up exactly as a hovered one does.
307
+ const setHoverRef = useRef<((next: string | number | null) => void) | null>(null);
121
308
 
122
309
  // Add source on mount.
123
310
  useEffect(() => {
@@ -125,14 +312,18 @@ export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJson
125
312
 
126
313
  map.addSource(sourceId, {
127
314
  type: "geojson",
128
- data,
315
+ data: resolvedData as never,
129
316
  ...(promoteId ? { promoteId } : {}),
130
317
  });
131
318
 
132
319
  return () => {
133
320
  try {
321
+ if (map.getLayer(hitLayerId)) map.removeLayer(hitLayerId);
134
322
  if (map.getLayer(lineLayerId)) map.removeLayer(lineLayerId);
323
+ if (map.getLayer(patternLayerId)) map.removeLayer(patternLayerId);
135
324
  if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId);
325
+ if (map.getLayer(vignetteLayerId)) map.removeLayer(vignetteLayerId);
326
+ if (map.hasImage(patternImageId)) map.removeImage(patternImageId);
136
327
  if (map.getSource(sourceId)) map.removeSource(sourceId);
137
328
  } catch {
138
329
  // style may be mid-reload
@@ -145,8 +336,8 @@ export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJson
145
336
  useEffect(() => {
146
337
  if (!isLoaded || !map) return;
147
338
  const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
148
- source?.setData(data as never);
149
- }, [isLoaded, map, data, sourceId]);
339
+ source?.setData(resolvedData as never);
340
+ }, [isLoaded, map, resolvedData, sourceId]);
150
341
 
151
342
  // Sync layers and paint when visibility or styling changes.
152
343
  useEffect(() => {
@@ -183,6 +374,21 @@ export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJson
183
374
  map.removeLayer(lineLayerId);
184
375
  }
185
376
 
377
+ // Invisible, but still picked: MapLibre hit-tests geometry, not opacity.
378
+ if (showHit && !map.getLayer(hitLayerId)) {
379
+ map.addLayer(
380
+ {
381
+ id: hitLayerId,
382
+ type: "line",
383
+ source: sourceId,
384
+ paint: { "line-color": defaultLine, "line-opacity": 0, "line-width": hitWidth },
385
+ },
386
+ beforeId,
387
+ );
388
+ } else if (!showHit && map.getLayer(hitLayerId)) {
389
+ map.removeLayer(hitLayerId);
390
+ }
391
+
186
392
  if (showFill && map.getLayer(fillLayerId)) {
187
393
  for (const [key, value] of Object.entries(mergedFillPaint)) {
188
394
  map.setPaintProperty(fillLayerId, key as keyof MapFillPaint, value as never);
@@ -193,51 +399,181 @@ export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJson
193
399
  map.setPaintProperty(lineLayerId, key as keyof MapLinePaint, value as never);
194
400
  }
195
401
  }
402
+
403
+ // Vignette: a blurred line UNDER the fill, so the glow reads outside it.
404
+ const vignettePaint: MapLinePaint = {
405
+ "line-color": areaColor,
406
+ "line-width": vignetteWidth * 2,
407
+ "line-blur": vignetteWidth,
408
+ "line-opacity": vignetteOpacity,
409
+ };
410
+ if (hasVignette && !map.getLayer(vignetteLayerId)) {
411
+ const before = map.getLayer(fillLayerId)
412
+ ? fillLayerId
413
+ : map.getLayer(lineLayerId)
414
+ ? lineLayerId
415
+ : beforeId;
416
+ map.addLayer(
417
+ { id: vignetteLayerId, type: "line", source: sourceId, paint: vignettePaint },
418
+ before,
419
+ );
420
+ } else if (hasVignette) {
421
+ for (const [key, value] of Object.entries(vignettePaint)) {
422
+ map.setPaintProperty(vignetteLayerId, key as keyof MapLinePaint, value as never);
423
+ }
424
+ } else if (map.getLayer(vignetteLayerId)) {
425
+ map.removeLayer(vignetteLayerId);
426
+ }
427
+
428
+ // Pattern: a stripe tile drawn in the area colour, on its own fill layer
429
+ // between the fill and the outline. Re-drawn when the colour or size changes.
430
+ if (hasPattern) {
431
+ const drawKey = `${areaColor}|${stripeWidth}|${stripeGap}`;
432
+ if (patternDrawnRef.current !== drawKey || !map.hasImage(patternImageId)) {
433
+ const image = stripeImage(areaColor, stripeWidth, stripeGap);
434
+ if (image) {
435
+ if (map.hasImage(patternImageId)) map.removeImage(patternImageId);
436
+ map.addImage(patternImageId, image.data, { pixelRatio: image.pixelRatio });
437
+ patternDrawnRef.current = drawKey;
438
+ }
439
+ }
440
+ if (map.hasImage(patternImageId)) {
441
+ if (!map.getLayer(patternLayerId)) {
442
+ map.addLayer(
443
+ {
444
+ id: patternLayerId,
445
+ type: "fill",
446
+ source: sourceId,
447
+ paint: { "fill-pattern": patternImageId },
448
+ },
449
+ map.getLayer(lineLayerId) ? lineLayerId : beforeId,
450
+ );
451
+ }
452
+ }
453
+ } else {
454
+ if (map.getLayer(patternLayerId)) map.removeLayer(patternLayerId);
455
+ if (map.hasImage(patternImageId)) map.removeImage(patternImageId);
456
+ patternDrawnRef.current = null;
457
+ }
458
+
459
+ if (showHit && map.getLayer(hitLayerId)) {
460
+ map.setPaintProperty(hitLayerId, "line-width", hitWidth);
461
+ }
196
462
  }, [
463
+ areaColor,
464
+ hasPattern,
465
+ stripeWidth,
466
+ stripeGap,
467
+ hasVignette,
468
+ vignetteWidth,
469
+ vignetteOpacity,
470
+ patternLayerId,
471
+ vignetteLayerId,
472
+ patternImageId,
197
473
  isLoaded,
198
474
  map,
199
475
  sourceId,
200
476
  fillLayerId,
201
477
  lineLayerId,
478
+ hitLayerId,
202
479
  showFill,
203
480
  showLine,
481
+ showHit,
482
+ hitWidth,
483
+ defaultLine,
204
484
  mergedFillPaint,
205
485
  mergedLinePaint,
206
486
  beforeId,
207
487
  ]);
208
488
 
209
- // Interaction handlers (bound to the fill layer).
489
+ // Selection is the consumer's state; mirror it into feature-state and clear
490
+ // whatever fell out of the set, so paint never sticks to a deselected shape.
491
+ const selectedKey = useMemo(() => {
492
+ if (selectedId == null) return "";
493
+ return (Array.isArray(selectedId) ? selectedId : [selectedId]).join("");
494
+ }, [selectedId]);
495
+ const appliedSelectionRef = useRef<(string | number)[]>([]);
210
496
  useEffect(() => {
211
- if (!isLoaded || !map || !interactive || !showFill) return;
497
+ if (!isLoaded || !map || !map.getSource(sourceId)) return;
498
+ const next =
499
+ selectedId == null
500
+ ? []
501
+ : ((Array.isArray(selectedId) ? [...selectedId] : [selectedId]) as (string | number)[]);
212
502
 
213
- let hoveredId: string | number | null = null;
503
+ for (const previous of appliedSelectionRef.current) {
504
+ if (!next.includes(previous)) {
505
+ map.setFeatureState({ source: sourceId, id: previous }, { selected: false });
506
+ }
507
+ }
508
+ for (const current of next) {
509
+ map.setFeatureState({ source: sourceId, id: current }, { selected: true });
510
+ }
511
+ appliedSelectionRef.current = next;
512
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the ids themselves so an inline array literal doesn't re-run this
513
+ }, [isLoaded, map, sourceId, selectedKey]);
514
+
515
+ // A controlled highlight: the overlay's keyboard focus and the pointer must
516
+ // produce the same appearance, so when `hoveredId` is given the pointer stops
517
+ // writing hover state and this effect owns it instead.
518
+ const hoverIsControlled = hoveredId !== undefined;
519
+ const appliedHoverRef = useRef<string | number | null>(null);
520
+ useEffect(() => {
521
+ if (!hoverIsControlled || !isLoaded || !map || !map.getSource(sourceId)) return;
522
+ const previous = appliedHoverRef.current;
523
+ if (previous != null && previous !== hoveredId) {
524
+ map.setFeatureState({ source: sourceId, id: previous }, { hover: false });
525
+ }
526
+ if (hoveredId != null) {
527
+ map.setFeatureState({ source: sourceId, id: hoveredId }, { hover: true });
528
+ }
529
+ appliedHoverRef.current = hoveredId ?? null;
530
+ }, [hoverIsControlled, hoveredId, isLoaded, map, sourceId]);
531
+
532
+ // Interaction handlers, bound to whichever layer carries the shape: the fill,
533
+ // or the invisible hit line when there is no fill.
534
+ const pickLayerId = showFill ? fillLayerId : hitLayerId;
535
+ useEffect(() => {
536
+ if (!isLoaded || !map || !interactive) return;
537
+ if (!showFill && !showHit) return;
538
+
539
+ let pointerHoverId: string | number | null = null;
214
540
 
215
541
  const setHover = (next: string | number | null) => {
216
- if (next === hoveredId) return;
542
+ if (hoverIsControlled) return;
543
+ if (next === pointerHoverId) return;
217
544
  const sourceExists = !!map.getSource(sourceId);
218
- if (hoveredId != null && sourceExists) {
219
- map.setFeatureState({ source: sourceId, id: hoveredId }, { hover: false });
545
+ if (pointerHoverId != null && sourceExists) {
546
+ map.setFeatureState({ source: sourceId, id: pointerHoverId }, { hover: false });
220
547
  }
221
- hoveredId = next;
548
+ pointerHoverId = next;
222
549
  if (next != null && sourceExists) {
223
550
  map.setFeatureState({ source: sourceId, id: next }, { hover: true });
224
551
  }
225
552
  };
226
553
 
554
+ const payload = (
555
+ feature: MapLibreGL.MapGeoJSONFeature,
556
+ e: MapLibreGL.MapLayerMouseEvent,
557
+ ): MapGeoJSONEvent<P> => {
558
+ const planCrs = latestRef.current.plan;
559
+ return {
560
+ feature: feature as unknown as MapGeoJSONFeature<P>,
561
+ longitude: e.lngLat.lng,
562
+ latitude: e.lngLat.lat,
563
+ plan: planCrs ? planCrs.toPlan([e.lngLat.lng, e.lngLat.lat]) : null,
564
+ originalEvent: e,
565
+ };
566
+ };
567
+
227
568
  const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => {
228
569
  const feature = e.features?.[0];
229
570
  if (!feature) return;
230
571
  map.getCanvas().style.cursor = "pointer";
231
572
 
232
573
  const featureId = feature.id;
233
- if (featureId === hoveredId) return;
574
+ if (featureId === pointerHoverId) return;
234
575
  setHover(featureId ?? null);
235
- latestRef.current.onHover?.({
236
- feature: feature as unknown as MapGeoJSONFeature<P>,
237
- longitude: e.lngLat.lng,
238
- latitude: e.lngLat.lat,
239
- originalEvent: e,
240
- });
576
+ latestRef.current.onHover?.(payload(feature, e));
241
577
  };
242
578
 
243
579
  const handleMouseLeave = () => {
@@ -246,29 +582,233 @@ export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJson
246
582
  latestRef.current.onHover?.(null);
247
583
  };
248
584
 
585
+ /**
586
+ * An exact hit always wins; only when nothing is under the pointer does the
587
+ * padded box get a say. On a plan that turns a 20 px seat into a target a
588
+ * fingertip can actually land on.
589
+ */
590
+ const pickPadded = (e: MapLibreGL.MapLayerMouseEvent) => {
591
+ const exact = map.queryRenderedFeatures(e.point, { layers: [pickLayerId] });
592
+ if (exact.length > 0) return exact[0];
593
+ const { x, y } = e.point;
594
+ const box = [
595
+ [x - pickPad, y - pickPad],
596
+ [x + pickPad, y + pickPad],
597
+ ] as unknown as Parameters<typeof map.queryRenderedFeatures>[0];
598
+ return map.queryRenderedFeatures(box, { layers: [pickLayerId] })[0];
599
+ };
600
+
601
+ setHoverRef.current = setHover;
602
+
249
603
  const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => {
250
- const feature = e.features?.[0];
604
+ const feature = pickPad > 0 ? pickPadded(e) : e.features?.[0];
251
605
  if (!feature) return;
252
- latestRef.current.onClick?.({
253
- feature: feature as unknown as MapGeoJSONFeature<P>,
254
- longitude: e.lngLat.lng,
255
- latitude: e.lngLat.lat,
256
- originalEvent: e,
257
- });
606
+ latestRef.current.onClick?.(payload(feature, e));
258
607
  };
259
608
 
260
- map.on("mousemove", fillLayerId, handleMouseMove);
261
- map.on("mouseleave", fillLayerId, handleMouseLeave);
262
- map.on("click", fillLayerId, handleClick);
609
+ map.on("mousemove", pickLayerId, handleMouseMove);
610
+ map.on("mouseleave", pickLayerId, handleMouseLeave);
611
+ // A padded click must hear about the misses too, so it listens on the map.
612
+ if (pickPad > 0) {
613
+ map.on("click", handleClick);
614
+ } else {
615
+ map.on("click", pickLayerId, handleClick);
616
+ }
263
617
 
264
618
  return () => {
265
- map.off("mousemove", fillLayerId, handleMouseMove);
266
- map.off("mouseleave", fillLayerId, handleMouseLeave);
267
- map.off("click", fillLayerId, handleClick);
619
+ map.off("mousemove", pickLayerId, handleMouseMove);
620
+ map.off("mouseleave", pickLayerId, handleMouseLeave);
621
+ if (pickPad > 0) {
622
+ map.off("click", handleClick);
623
+ } else {
624
+ map.off("click", pickLayerId, handleClick);
625
+ }
268
626
  setHover(null);
627
+ setHoverRef.current = null;
269
628
  map.getCanvas().style.cursor = "";
270
629
  };
271
- }, [isLoaded, map, fillLayerId, sourceId, interactive, showFill]);
630
+ }, [
631
+ isLoaded,
632
+ map,
633
+ pickLayerId,
634
+ sourceId,
635
+ interactive,
636
+ showFill,
637
+ showHit,
638
+ pickPad,
639
+ hoverIsControlled,
640
+ ]);
641
+
642
+ // c-2 (WCAG 2.1.1, 1.3.1): the fill layer lives in WebGL, so an interactive
643
+ // choropleth had no DOM at all — its values were reachable by mouse only,
644
+ // with no tab stop, no text alternative and nothing for a screen reader to
645
+ // read. One visually-hidden button per feature gives every region a tab stop
646
+ // whose name IS its value; focusing it lights the region and fires the same
647
+ // `onHover` the pointer does, so a host readout follows the keyboard for
648
+ // free. Hidden, not absent: the map itself is the picture.
649
+ const keyboardFeatures = useMemo(
650
+ () => (interactive && showFill ? listKeyboardFeatures<P>(data, promoteId) : []),
651
+ [interactive, showFill, data, promoteId],
652
+ );
653
+
654
+ const reach = (entry: KeyboardFeature<P>) => {
655
+ setHoverRef.current?.(entry.id ?? null);
656
+ latestRef.current.onHover?.({
657
+ feature: entry.feature,
658
+ longitude: entry.longitude,
659
+ latitude: entry.latitude,
660
+ // A plan map's keyboard path is `MapPlanOverlay`'s job, so this list
661
+ // never has plan units to hand back.
662
+ plan: null,
663
+ originalEvent: null,
664
+ });
665
+ };
666
+
667
+ if (keyboardFeatures.length === 0) {
668
+ return null;
669
+ }
670
+
671
+ return (
672
+ <div className="sr-only" data-slot="map-geojson-keyboard-list">
673
+ <ul>
674
+ {keyboardFeatures.map((entry, index) => (
675
+ <li key={entry.key}>
676
+ <button
677
+ onBlur={() => {
678
+ setHoverRef.current?.(null);
679
+ latestRef.current.onHover?.(null);
680
+ }}
681
+ onClick={() =>
682
+ latestRef.current.onClick?.({
683
+ feature: entry.feature,
684
+ longitude: entry.longitude,
685
+ latitude: entry.latitude,
686
+ plan: null,
687
+ originalEvent: null,
688
+ })
689
+ }
690
+ onFocus={() => reach(entry)}
691
+ type="button"
692
+ >
693
+ {featureLabel?.(entry.feature, index) ?? entry.fallbackLabel}
694
+ </button>
695
+ </li>
696
+ ))}
697
+ </ul>
698
+ </div>
699
+ );
700
+ }
701
+
702
+ /** One row of the keyboard list: a feature, where it sits and what it is called. */
703
+ interface KeyboardFeature<P extends GeoJSON.GeoJsonProperties> {
704
+ key: string;
705
+ id: string | number | undefined;
706
+ feature: MapGeoJSONFeature<P>;
707
+ longitude: number;
708
+ latitude: number;
709
+ fallbackLabel: string;
710
+ }
711
+
712
+ /** Every coordinate in a geometry, flattened — enough for a bounding box. */
713
+ function geometryPositions(geometry: GeoJSON.Geometry): GeoJSON.Position[] {
714
+ if (geometry.type === "GeometryCollection") {
715
+ return geometry.geometries.flatMap(geometryPositions);
716
+ }
717
+ if (geometry.type === "Point") {
718
+ return [geometry.coordinates];
719
+ }
720
+ // Every remaining geometry nests Positions 1–3 arrays deep.
721
+ const flatten = (value: unknown): GeoJSON.Position[] =>
722
+ Array.isArray(value) && typeof value[0] === "number"
723
+ ? [value as GeoJSON.Position]
724
+ : Array.isArray(value)
725
+ ? value.flatMap(flatten)
726
+ : [];
727
+ return flatten(geometry.coordinates);
728
+ }
729
+
730
+ /** The centre of a geometry's bounding box — where a keyboard "hover" lands. */
731
+ function geometryCentre(geometry: GeoJSON.Geometry): [number, number] {
732
+ const positions = geometryPositions(geometry);
733
+ if (positions.length === 0) return [0, 0];
734
+ let west = Number.POSITIVE_INFINITY;
735
+ let east = Number.NEGATIVE_INFINITY;
736
+ let south = Number.POSITIVE_INFINITY;
737
+ let north = Number.NEGATIVE_INFINITY;
738
+ for (const [lng, lat] of positions) {
739
+ west = Math.min(west, lng ?? 0);
740
+ east = Math.max(east, lng ?? 0);
741
+ south = Math.min(south, lat ?? 0);
742
+ north = Math.max(north, lat ?? 0);
743
+ }
744
+ return [(west + east) / 2, (south + north) / 2];
745
+ }
272
746
 
273
- return null;
747
+ /**
748
+ * The features a keyboard list can offer. A `data` URL is fetched by MapLibre
749
+ * itself and never reaches React, and a bare Geometry carries no properties to
750
+ * read — both give an empty list rather than a row that says nothing.
751
+ */
752
+ function listKeyboardFeatures<P extends GeoJSON.GeoJsonProperties>(
753
+ data: MapGeoJSONData<P>,
754
+ promoteId: string | undefined,
755
+ ): KeyboardFeature<P>[] {
756
+ if (typeof data === "string") return [];
757
+ const features =
758
+ data.type === "FeatureCollection" ? data.features : data.type === "Feature" ? [data] : [];
759
+ return features.map((feature, index) => {
760
+ const promoted = promoteId ? feature.properties?.[promoteId] : undefined;
761
+ const id =
762
+ typeof promoted === "string" || typeof promoted === "number"
763
+ ? promoted
764
+ : typeof feature.id === "string" || typeof feature.id === "number"
765
+ ? feature.id
766
+ : undefined;
767
+ const [longitude, latitude] = geometryCentre(feature.geometry);
768
+ return {
769
+ key: `${id ?? index}`,
770
+ id,
771
+ feature: feature as unknown as MapGeoJSONFeature<P>,
772
+ longitude,
773
+ latitude,
774
+ fallbackLabel: `${id ?? index + 1}`,
775
+ };
776
+ });
777
+ }
778
+
779
+ /**
780
+ * One seamless tile of 45° stripes, `width` thick with `gap` between them
781
+ * (both measured across the stripes), in `color` — any CSS colour, so a
782
+ * resolved token works as-is. `null` where no 2D canvas exists (tests, SSR).
783
+ */
784
+ function stripeImage(
785
+ color: string,
786
+ width: number,
787
+ gap: number,
788
+ ): { data: ImageData; pixelRatio: number } | null {
789
+ if (typeof document === "undefined") return null;
790
+ const pixelRatio = Math.max(1, Math.round(globalThis.devicePixelRatio || 1));
791
+ // A 45° stripe period of (width + gap) across the stripes is √2 × that along x.
792
+ const size = Math.max(2, Math.round((width + gap) * Math.SQRT2 * pixelRatio));
793
+ const canvas = document.createElement("canvas");
794
+ canvas.width = size;
795
+ canvas.height = size;
796
+ let context: CanvasRenderingContext2D | null = null;
797
+ try {
798
+ context = canvas.getContext("2d");
799
+ } catch {
800
+ context = null;
801
+ }
802
+ if (!context) return null;
803
+ context.strokeStyle = color;
804
+ context.lineWidth = width * pixelRatio;
805
+ context.lineCap = "square";
806
+ context.beginPath();
807
+ // x + y = k·size for k = 0, 1, 2 covers the tile, corners included.
808
+ for (const k of [0, 1, 2]) {
809
+ context.moveTo(k * size - size, size);
810
+ context.lineTo(k * size, 0);
811
+ }
812
+ context.stroke();
813
+ return { data: context.getImageData(0, 0, size, size), pixelRatio };
274
814
  }