@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
@@ -13,11 +13,28 @@ import {
13
13
  useState,
14
14
  type ReactNode,
15
15
  } from "react";
16
- import { Spinner, StatePanel } from "@elabs-ai/components-ui";
16
+ import { Spinner, StatePanel, useLocale } from "@elabs-ai/components-ui";
17
17
  import { cn } from "@elabs-ai/components-ui/lib/cn";
18
18
 
19
+ import { createPlanCrs, type PlanCrs, type PlanExtent } from "../lib/plan-crs";
20
+ import { warnMapOnce } from "../lib/warn-once";
19
21
  import { MapContext, type BasemapTheme } from "./map-context";
20
22
  import { useResolvedBasemapTheme } from "./use-resolved-basemap-theme";
23
+ import {
24
+ MapFrameContext,
25
+ type MapFrameContextValue,
26
+ type MapFrameSide,
27
+ type MapHeight,
28
+ type MapResponsive,
29
+ mapBreakpointForWidth,
30
+ resolveMapHeightStyle,
31
+ useMeasuredMapBreakpoint,
32
+ } from "../lib/use-map-breakpoint";
33
+ import {
34
+ MapAnnotationKey,
35
+ MapAnnotationRegistryProvider,
36
+ useMapAnnotationRegistry,
37
+ } from "../map-annotation/map-annotation";
21
38
 
22
39
  /**
23
40
  * Default basemaps: Carto's free light/dark GL styles. These serve ODbL-licensed
@@ -33,6 +50,9 @@ const defaultStyles = {
33
50
  // data visualizations (choropleths, arcs, dot maps) where you draw your own
34
51
  // layers and don't need a street basemap: `<MapCanvas blank>`. The transparent
35
52
  // background lets the themed container show through.
53
+ /** Breathing room, in CSS pixels, between a fitted plan and the viewport edge. */
54
+ const PLAN_FIT_PADDING = 24;
55
+
36
56
  const blankMapStyle: MapLibreGL.StyleSpecification = {
37
57
  version: 8,
38
58
  sources: {},
@@ -59,6 +79,93 @@ export interface MapViewport {
59
79
 
60
80
  export type MapStyleOption = string | MapLibreGL.StyleSpecification;
61
81
 
82
+ /** `"mercator"` / `"globe"`, or a full MapLibre projection spec. */
83
+ export type MapProjectionOption = "mercator" | "globe" | MapLibreGL.ProjectionSpecification;
84
+
85
+ /** The gesture handlers static mode switches off (tooltips and hover keep working). */
86
+ const GESTURE_HANDLERS = [
87
+ "scrollZoom",
88
+ "boxZoom",
89
+ "dragRotate",
90
+ "dragPan",
91
+ "keyboard",
92
+ "doubleClickZoom",
93
+ "touchZoomRotate",
94
+ "touchPitch",
95
+ ] as const;
96
+
97
+ type GestureHandlerKey = (typeof GESTURE_HANDLERS)[number];
98
+
99
+ const STATIC_HANDLER_OPTIONS = Object.fromEntries(
100
+ GESTURE_HANDLERS.map((key) => [key, false]),
101
+ ) as Record<GestureHandlerKey, false>;
102
+
103
+ let warnedProjection = false;
104
+
105
+ /**
106
+ * Apply a projection when this MapLibre build supports switching one
107
+ * (`setProjection`, MapLibre 5+); otherwise leave the map as it is and say so
108
+ * once — a globe is an extra, never a requirement.
109
+ */
110
+ function applyProjection(map: MapLibreGL.Map, projection: MapLibreGL.ProjectionSpecification) {
111
+ if (typeof map.setProjection !== "function") {
112
+ if (!warnedProjection) {
113
+ warnedProjection = true;
114
+ console.warn(
115
+ "[@elabs-ai/components-maps] This MapLibre build cannot switch projections; `projection` is ignored.",
116
+ );
117
+ }
118
+ return;
119
+ }
120
+ map.setProjection(projection);
121
+ }
122
+
123
+ /**
124
+ * Hide (or restore) the basemap's OWN text labels — every symbol layer that
125
+ * draws a `text-field` (c-6 / c-11).
126
+ *
127
+ * A basemap's labels are drawn for a full-size map. Inside a 96 px inset they
128
+ * are sliced mid-word by the frame ("EUROP", "AMERIC") and louder than the
129
+ * globe they caption; on a locator they print a place name the editorial
130
+ * marker names again 16 px away. Both are the locator recipe's answer:
131
+ * the basemap draws the ground, the map's own labels do the naming.
132
+ *
133
+ * `hidden` records what THIS call turned off, so restoring never reveals a
134
+ * layer the style itself shipped hidden.
135
+ */
136
+ function applyBasemapLabels(map: MapLibreGL.Map, visible: boolean, hidden: Set<string>) {
137
+ let style: MapLibreGL.StyleSpecification | undefined;
138
+ try {
139
+ style = map.getStyle?.();
140
+ } catch {
141
+ // style mid-reload
142
+ return;
143
+ }
144
+ if (!style?.layers) return;
145
+ if (visible) {
146
+ for (const id of hidden) {
147
+ if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", "visible");
148
+ }
149
+ hidden.clear();
150
+ return;
151
+ }
152
+ for (const layer of style.layers) {
153
+ if (layer.type !== "symbol") continue;
154
+ if (!layer.layout || !("text-field" in layer.layout)) continue;
155
+ if (hidden.has(layer.id)) continue;
156
+ if (map.getLayoutProperty?.(layer.id, "visibility") === "none") continue;
157
+ map.setLayoutProperty(layer.id, "visibility", "none");
158
+ hidden.add(layer.id);
159
+ }
160
+ }
161
+
162
+ /** A box's measured size as a comparable key, or `null` while it has none. */
163
+ function boxSize(node: HTMLElement): string | null {
164
+ const { width, height } = node.getBoundingClientRect();
165
+ if (!(width > 0) || !(height > 0)) return null;
166
+ return `${Math.round(width)}×${Math.round(height)}`;
167
+ }
168
+
62
169
  /** The imperative handle exposed by `<MapCanvas ref>`: the MapLibre map itself. */
63
170
  export type MapCanvasRef = MapLibreGL.Map;
64
171
 
@@ -84,8 +191,37 @@ export type MapCanvasProps = {
84
191
  * visualizations. Ignored when an explicit `styles` prop is provided.
85
192
  */
86
193
  blank?: boolean;
87
- /** Map projection type. Use `{ type: "globe" }` for a 3D globe view. */
88
- projection?: MapLibreGL.ProjectionSpecification;
194
+ /**
195
+ * Draw the basemap's own place labels. `false` leaves the ground and hides
196
+ * every text label the basemap style carries, so the map's own labels
197
+ * (`MapMarker`'s `label`, `MapAnnotation`) are the only naming on it — the
198
+ * locator recipe, and the default inside `MapInset`, where a full-size
199
+ * label is sliced by the frame. Default `true`.
200
+ */
201
+ basemapLabels?: boolean;
202
+ /**
203
+ * Map projection: `"mercator"` (MapLibre's default) or `"globe"` for a 3D
204
+ * globe view, or a full MapLibre projection spec. Feature-detected: a
205
+ * MapLibre build that cannot switch projections ignores it (with a console
206
+ * note).
207
+ */
208
+ projection?: MapProjectionOption;
209
+ /**
210
+ * `false` makes the map STATIC — the editorial / locator default: no zoom,
211
+ * pan, rotate or keyboard handlers, the default cursor and no tab stop
212
+ * (leave `<MapControls>` out of a static map). Hover and click still reach
213
+ * layers and markers, so tooltips keep working. The viewport can still be set in code
214
+ * (`viewport`, the ref). Default `true`.
215
+ */
216
+ interactive?: boolean;
217
+ /**
218
+ * The map's height: CSS px, or `{ aspect }` (width ÷ height), optionally per
219
+ * tier — `{ base: { aspect: 1.6 }, narrow: { aspect: 1 } }`. Tiers are
220
+ * measured on the map's own width (`narrow` < 480 px, `medium` < 768 px).
221
+ * Unset, the map fills its parent as before; a parent with no height of its
222
+ * own gets `DEFAULT_MAP_HEIGHT` (1.6 : 1, square at `narrow`) instead of 0.
223
+ */
224
+ height?: MapResponsive<MapHeight>;
89
225
  /**
90
226
  * Controlled viewport. When provided together with `onViewportChange`, the
91
227
  * map becomes controlled and the viewport is driven by this prop.
@@ -99,12 +235,34 @@ export type MapCanvasProps = {
99
235
  onViewportChange?: (viewport: MapViewport) => void;
100
236
  /** Show a loading overlay on the map (e.g. while the app fetches map data). */
101
237
  loading?: boolean;
102
- } & Omit<MapLibreGL.MapOptions, "container" | "style">;
238
+ /**
239
+ * Turn the canvas into a CUSTOM (non-geographic) plan map: a floor plan, a
240
+ * factory layout, a train carriage, a rack elevation. Pass the plan's extent
241
+ * (`{ width, height }` in the plan's own units) or a `createPlanCrs(…)`
242
+ * result, and every layer inside then speaks PLAN coordinates instead of
243
+ * lng/lat — shapes, routes, markers and popups alike.
244
+ *
245
+ * The plan is fitted on mount, pan and zoom are clamped to its extent, and
246
+ * rotation and pitch are off (a rotated floor plan is unreadable). Pair it
247
+ * with `blank` and, for a picture under the shapes, `<MapPlanImage>`.
248
+ *
249
+ * The plan coordinate system is synthesized on Web Mercator, so it is not a
250
+ * georeference: distances belong to the plan, and a scale bar would lie.
251
+ */
252
+ plan?: PlanExtent | PlanCrs;
253
+ /**
254
+ * Fit a plan map to its extent on mount and whenever the extent changes
255
+ * (default: true). Ignored on a geographic map, and skipped when the camera
256
+ * is driven through `viewport`.
257
+ */
258
+ fitPlan?: boolean;
259
+ } & Omit<MapLibreGL.MapOptions, "container" | "style" | "interactive">;
103
260
 
104
261
  function MapLoadingOverlay() {
262
+ const { t } = useLocale();
105
263
  return (
106
264
  <div className="absolute inset-0 z-10 flex items-center justify-center bg-background/50 backdrop-blur-xs">
107
- <Spinner label="Loading map" className="size-5" />
265
+ <Spinner label={t("maps.canvas.loading")} className="size-5" />
108
266
  </div>
109
267
  );
110
268
  }
@@ -134,15 +292,33 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
134
292
  theme: themeProp,
135
293
  styles,
136
294
  blank = false,
137
- projection,
295
+ basemapLabels = true,
296
+ projection: projectionProp,
138
297
  viewport,
139
298
  onViewportChange,
140
299
  loading = false,
300
+ interactive,
301
+ height,
302
+ plan: planProp,
303
+ fitPlan = true,
304
+ // Pulled out of `...props` so the constructor and the live plan-limit effect
305
+ // below resolve them the same way.
306
+ minZoom,
307
+ maxZoom,
308
+ maxBounds,
141
309
  ...props
142
310
  },
143
311
  ref,
144
312
  ) {
145
- const containerRef = useRef<HTMLDivElement>(null);
313
+ const { t } = useLocale();
314
+ const containerRef = useRef<HTMLDivElement | null>(null);
315
+ const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(null);
316
+ const setContainer = useCallback((node: HTMLDivElement | null) => {
317
+ containerRef.current = node;
318
+ setContainerNode(node);
319
+ }, []);
320
+ const breakpoint = useMeasuredMapBreakpoint(containerNode);
321
+ const isStatic = interactive === false;
146
322
  const [mapInstance, setMapInstance] = useState<MapLibreGL.Map | null>(null);
147
323
  const [initFailed, setInitFailed] = useState(false);
148
324
  const [isLoaded, setIsLoaded] = useState(false);
@@ -157,6 +333,17 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
157
333
  const onViewportChangeRef = useRef(onViewportChange);
158
334
  onViewportChangeRef.current = onViewportChange;
159
335
 
336
+ // A string shorthand becomes a stable spec object; a spec object passes
337
+ // through as given.
338
+ const projection = useMemo<MapLibreGL.ProjectionSpecification | undefined>(
339
+ () => (typeof projectionProp === "string" ? { type: projectionProp } : projectionProp),
340
+ [projectionProp],
341
+ );
342
+
343
+ // Read from the mount-only `styledata` handler below so a `projection` prop
344
+ // change after mount isn't reapplied with the value captured at mount time.
345
+ const projectionRef = useRef(projection);
346
+
160
347
  const mapStyles = useMemo(() => {
161
348
  // Explicit styles win. Otherwise `blank` opts into the transparent
162
349
  // tile-less basemap; with neither, fall back to the Carto defaults.
@@ -172,6 +359,61 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
172
359
  return defaultStyles;
173
360
  }, [styles, blank]);
174
361
 
362
+ // Normalize `plan` from PRIMITIVES, so an inline `plan={{ width, height }}`
363
+ // literal keeps a stable coordinate system instead of re-running every layer
364
+ // effect on each render. A caller who passes their own memoized `PlanCrs`
365
+ // keeps that identity untouched.
366
+ const planIsCrs = typeof (planProp as PlanCrs | undefined)?.toLngLat === "function";
367
+ const planExtent = planIsCrs ? undefined : (planProp as PlanExtent | undefined);
368
+ const planWidth = planExtent?.width;
369
+ const planHeight = planExtent?.height;
370
+ const planOrigin = planExtent?.origin ?? "top-left";
371
+ const planUnit = planExtent?.unit ?? "px";
372
+ const derivedPlan = useMemo(
373
+ () =>
374
+ planWidth !== undefined && planHeight !== undefined
375
+ ? createPlanCrs({
376
+ width: planWidth,
377
+ height: planHeight,
378
+ origin: planOrigin,
379
+ unit: planUnit,
380
+ })
381
+ : null,
382
+ [planWidth, planHeight, planOrigin, planUnit],
383
+ );
384
+ const planCrs = planIsCrs ? ((planProp as PlanCrs) ?? null) : derivedPlan;
385
+
386
+ // A plan is a flat drawing: the globe would bend it, so refuse the pairing
387
+ // rather than render something the consumer cannot trust.
388
+ const projectionIsGlobe = !!projection && projection.type !== "mercator";
389
+ const effectiveProjection = planCrs && projectionIsGlobe ? undefined : projection;
390
+ projectionRef.current = effectiveProjection;
391
+ if (planCrs && projectionIsGlobe) {
392
+ warnMapOnce(
393
+ "plan-projection",
394
+ `Ignored projection "${projection?.type}" on a plan map: a plan is flat, and only the mercator projection keeps it undistorted.`,
395
+ );
396
+ }
397
+
398
+ // Camera options a plan needs. Merged UNDER `...props`, so an explicit prop
399
+ // always wins.
400
+ const planOptions = useMemo(() => {
401
+ if (!planCrs) return null;
402
+ return {
403
+ minZoom: planCrs.minZoom,
404
+ maxZoom: planCrs.maxZoom,
405
+ maxBounds: planCrs.maxBounds() as MapLibreGL.LngLatBoundsLike,
406
+ // A plan is 2D: rotation and tilt only make it harder to read.
407
+ dragRotate: false,
408
+ pitchWithRotate: false,
409
+ touchPitch: false,
410
+ bearing: 0,
411
+ pitch: 0,
412
+ // A plan is usually embedded in a page, so don't swallow the wheel.
413
+ cooperativeGestures: true,
414
+ };
415
+ }, [planCrs]);
416
+
175
417
  // Expose the map instance to the parent component.
176
418
  useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]);
177
419
 
@@ -182,10 +424,52 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
182
424
  }
183
425
  }, []);
184
426
 
427
+ const heightRef = useRef(height);
428
+ heightRef.current = height;
429
+
430
+ // ── Keeping a bounds-framed map framed ─────────────────────────────────────
431
+ // MapLibre fits `bounds` ONCE, against the box it measures at construction.
432
+ // A box that settles later — a responsive height resolving after the first
433
+ // paint, a side panel opening, a tier flip — leaves the viewport fitted to a
434
+ // rectangle that no longer exists (a 348×218 fit kept on a 348×348 box put
435
+ // North America on a Lake Ontario locator). So: re-fit whenever the box
436
+ // changes size, until someone moves the map themselves.
437
+ const { bounds, fitBoundsOptions } = props;
438
+ const refitRef = useRef({ bounds, fitBoundsOptions });
439
+ refitRef.current = { bounds, fitBoundsOptions };
440
+ const isControlledRef = useRef(isControlled);
441
+ isControlledRef.current = isControlled;
442
+ /** `true` once a gesture (or the controlled `viewport`) owns the viewport. */
443
+ const userMovedRef = useRef(false);
444
+ /** The box size the current viewport was fitted against. */
445
+ const fittedSizeRef = useRef<string | null>(null);
446
+
447
+ const syncBox = useCallback((map: MapLibreGL.Map, node: HTMLElement) => {
448
+ const size = boxSize(node);
449
+ if (!size || size === fittedSizeRef.current) return;
450
+ fittedSizeRef.current = size;
451
+ map.resize();
452
+ const { bounds: currentBounds, fitBoundsOptions: currentOptions } = refitRef.current;
453
+ if (!currentBounds || userMovedRef.current || isControlledRef.current) return;
454
+ map.fitBounds(currentBounds, { ...currentOptions, duration: 0 });
455
+ }, []);
456
+
185
457
  // Initialize the map.
186
458
  useEffect(() => {
187
459
  if (!containerRef.current) return;
188
460
 
461
+ // The tier is measured in a layout effect whose re-render lands AFTER this
462
+ // effect, so the box may still carry the wide-tier height here. Size it for
463
+ // its real tier first: MapLibre sizes its canvas (and fits `bounds`) from
464
+ // the box at construction, and drops the first resize it observes.
465
+ Object.assign(
466
+ containerRef.current.style,
467
+ resolveMapHeightStyle(
468
+ heightRef.current,
469
+ mapBreakpointForWidth(containerRef.current.getBoundingClientRect().width),
470
+ ),
471
+ );
472
+
189
473
  const initialStyle = resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
190
474
  currentStyleRef.current = initialStyle;
191
475
 
@@ -204,8 +488,24 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
204
488
  // `...props` below), or move to tiles licensed without the requirement via
205
489
  // `styles` / `blank`.
206
490
  attributionControl: false,
491
+ ...(planOptions ?? {}),
492
+ // `bounds` fits the plan in the very first frame, so a plan map never
493
+ // flashes at lng/lat 0,0 before the fit effect below runs. Skipped when
494
+ // the caller drives the camera themselves.
495
+ ...(planOptions && !viewport?.center
496
+ ? {
497
+ bounds: planCrs!.bounds as MapLibreGL.LngLatBoundsLike,
498
+ fitBoundsOptions: { padding: PLAN_FIT_PADDING, animate: false },
499
+ }
500
+ : {}),
501
+ ...(minZoom !== undefined ? { minZoom } : {}),
502
+ ...(maxZoom !== undefined ? { maxZoom } : {}),
503
+ ...(maxBounds !== undefined ? { maxBounds } : {}),
207
504
  ...props,
208
505
  ...viewport,
506
+ // Static mode: gestures off, but MapLibre's own `interactive` stays on —
507
+ // it would detach EVERY listener, tooltips and hover included.
508
+ ...(isStatic ? STATIC_HANDLER_OPTIONS : {}),
209
509
  });
210
510
  } catch {
211
511
  // MapLibre throws at construction when WebGL is unavailable (headless
@@ -221,8 +521,8 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
221
521
  // race conditions on setStyle without force-updating every layer.
222
522
  styleTimeoutRef.current = setTimeout(() => {
223
523
  setIsStyleLoaded(true);
224
- if (projection) {
225
- map.setProjection(projection);
524
+ if (projectionRef.current) {
525
+ applyProjection(map, projectionRef.current);
226
526
  }
227
527
  }, 100);
228
528
  };
@@ -247,9 +547,21 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
247
547
  onViewportChangeRef.current?.(getViewport(map));
248
548
  };
249
549
 
550
+ // A gesture carries the DOM event that caused it; our own `fitBounds` /
551
+ // `jumpTo` does not. Once a person has moved the map, a later box change
552
+ // resizes the canvas but must never yank the view back to `bounds`.
553
+ const handleMoveStart = (event?: { originalEvent?: unknown }) => {
554
+ if (event?.originalEvent) userMovedRef.current = true;
555
+ };
556
+
557
+ // The size the constructor fitted `bounds` against — the baseline every
558
+ // later box change is compared to.
559
+ fittedSizeRef.current = boxSize(containerRef.current);
560
+
250
561
  map.on("load", loadHandler);
251
562
  map.on("styledata", styleDataHandler);
252
563
  map.on("move", handleMove);
564
+ map.on("movestart", handleMoveStart);
253
565
  setMapInstance(map);
254
566
 
255
567
  return () => {
@@ -257,6 +569,7 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
257
569
  map.off("load", loadHandler);
258
570
  map.off("styledata", styleDataHandler);
259
571
  map.off("move", handleMove);
572
+ map.off("movestart", handleMoveStart);
260
573
  map.remove();
261
574
  setIsLoaded(false);
262
575
  setIsStyleLoaded(false);
@@ -310,9 +623,120 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
310
623
 
311
624
  // Sync projection when the prop changes after mount.
312
625
  useEffect(() => {
313
- if (!mapInstance || !isStyleLoaded || !projection) return;
314
- mapInstance.setProjection(projection);
315
- }, [mapInstance, isStyleLoaded, projection]);
626
+ if (!mapInstance || !isStyleLoaded || !effectiveProjection) return;
627
+ applyProjection(mapInstance, effectiveProjection);
628
+ }, [mapInstance, isStyleLoaded, effectiveProjection]);
629
+
630
+ // Keep a plan map's camera limits live — unlike the geographic options above,
631
+ // which are init-time. A plan's extent is often only known once its image has
632
+ // decoded, and a multi-floor building can change it after mount.
633
+ useEffect(() => {
634
+ if (!mapInstance || !planCrs) return;
635
+ mapInstance.setMinZoom(minZoom ?? planCrs.minZoom);
636
+ mapInstance.setMaxZoom(maxZoom ?? planCrs.maxZoom);
637
+ mapInstance.setMaxBounds((maxBounds ?? planCrs.maxBounds()) as MapLibreGL.LngLatBoundsLike);
638
+ }, [mapInstance, planCrs, minZoom, maxZoom, maxBounds]);
639
+
640
+ // Fit the plan once per extent. Keyed on the extent rather than on the object
641
+ // so a floor swap at the same size does not yank the user's view.
642
+ const fittedExtentRef = useRef<string | null>(null);
643
+ useEffect(() => {
644
+ if (!mapInstance || !isLoaded || !planCrs || !fitPlan) return;
645
+ if (isControlled || viewport?.center) return;
646
+
647
+ const { width, height, origin } = planCrs.extent;
648
+ const extentKey = `${width}x${height}:${origin}`;
649
+ if (fittedExtentRef.current === extentKey) return;
650
+ fittedExtentRef.current = extentKey;
651
+
652
+ mapInstance.fitBounds(planCrs.bounds as MapLibreGL.LngLatBoundsLike, {
653
+ padding: PLAN_FIT_PADDING,
654
+ animate: false,
655
+ });
656
+ }, [mapInstance, isLoaded, planCrs, fitPlan, isControlled, viewport?.center]);
657
+
658
+ // Basemap labels on / off (c-6, c-11). Re-runs after every style load, so a
659
+ // theme flip (which swaps the whole style) does not bring the labels back.
660
+ const hiddenLabelLayersRef = useRef<Set<string>>(new Set());
661
+ useEffect(() => {
662
+ if (!mapInstance || !isStyleLoaded) return;
663
+ applyBasemapLabels(mapInstance, basemapLabels, hiddenLabelLayersRef.current);
664
+ }, [mapInstance, isStyleLoaded, basemapLabels, themeKey]);
665
+
666
+ // Static mode on / off after mount. Untouched until the map is first made
667
+ // static, so an interactive map keeps MapLibre's own setup exactly.
668
+ const handlerOptionsRef = useRef<Partial<Record<GestureHandlerKey, unknown>>>(props);
669
+ handlerOptionsRef.current = props;
670
+ const wasStaticRef = useRef(false);
671
+ useEffect(() => {
672
+ if (!mapInstance) return;
673
+ if (!isStatic && !wasStaticRef.current) return;
674
+ wasStaticRef.current = true;
675
+ for (const key of GESTURE_HANDLERS) {
676
+ const handler = mapInstance[key] as { enable?: () => void; disable?: () => void } | undefined;
677
+ if (isStatic) handler?.disable?.();
678
+ else if (handlerOptionsRef.current[key] !== false) handler?.enable?.();
679
+ }
680
+ // MapLibre's grab cursor hangs off this class; a static map keeps the
681
+ // default arrow (a layer's hover still sets its own pointer on the canvas).
682
+ mapInstance.getCanvasContainer().classList.toggle("maplibregl-interactive", !isStatic);
683
+ mapInstance.getCanvas().tabIndex = isStatic ? -1 : 0;
684
+ }, [mapInstance, isStatic]);
685
+
686
+ // c-10: the canvas is a tab stop MapLibre owns, so before this it painted
687
+ // the BROWSER's default focus ring (`1px auto rgb(0, 95, 204)` in light,
688
+ // `rgb(153, 200, 255)` in dark) — visible, but not the theme's. The house
689
+ // indicator is a utility class, so the element simply wears it.
690
+ useEffect(() => {
691
+ if (!mapInstance) return;
692
+ mapInstance.getCanvas()?.classList.add("focus-ring");
693
+ }, [mapInstance]);
694
+
695
+ // Watch the box: any size change resizes the canvas and re-fits `bounds`.
696
+ useEffect(() => {
697
+ if (!mapInstance || !containerNode) return undefined;
698
+ const sync = () => syncBox(mapInstance, containerNode);
699
+ sync();
700
+ if (typeof ResizeObserver === "undefined") return undefined;
701
+ const observer = new ResizeObserver(sync);
702
+ observer.observe(containerNode);
703
+ return () => observer.disconnect();
704
+ }, [mapInstance, containerNode, syncBox]);
705
+
706
+ // A tier change can swap the box's height (the default goes square at
707
+ // `narrow`); sync at commit rather than wait on the observer's next frame.
708
+ const heightStyle = resolveMapHeightStyle(height, breakpoint);
709
+ const heightKey = `${heightStyle.height ?? ""}|${heightStyle.aspectRatio ?? ""}`;
710
+ const appliedHeightKeyRef = useRef(heightKey);
711
+ useEffect(() => {
712
+ if (!mapInstance || !containerNode || heightKey === appliedHeightKeyRef.current) return;
713
+ appliedHeightKeyRef.current = heightKey;
714
+ syncBox(mapInstance, containerNode);
715
+ }, [mapInstance, containerNode, heightKey, syncBox]);
716
+
717
+ // Furniture outside the map box (legends `above` / `below`, the narrow
718
+ // annotation key) portals into strips rendered only while something asks.
719
+ const [slotRequests, setSlotRequests] = useState<Record<MapFrameSide, number>>({
720
+ above: 0,
721
+ below: 0,
722
+ });
723
+ const [aboveEl, setAboveEl] = useState<HTMLDivElement | null>(null);
724
+ const [belowEl, setBelowEl] = useState<HTMLDivElement | null>(null);
725
+ const requestSlot = useCallback((side: MapFrameSide) => {
726
+ setSlotRequests((prev) => ({ ...prev, [side]: prev[side] + 1 }));
727
+ return () => setSlotRequests((prev) => ({ ...prev, [side]: Math.max(0, prev[side] - 1) }));
728
+ }, []);
729
+ const frameValue = useMemo<MapFrameContextValue>(
730
+ () => ({
731
+ breakpoint,
732
+ interactive: !isStatic,
733
+ slots: { above: aboveEl, below: belowEl },
734
+ requestSlot,
735
+ }),
736
+ [breakpoint, isStatic, aboveEl, belowEl, requestSlot],
737
+ );
738
+ const annotations = useMapAnnotationRegistry(breakpoint);
739
+ const showBelow = slotRequests.below > 0 || annotations.rows.length > 0;
316
740
 
317
741
  const contextValue = useMemo(
318
742
  () => ({
@@ -320,8 +744,10 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
320
744
  isLoaded: isLoaded && isStyleLoaded,
321
745
  resolvedTheme,
322
746
  themeKey,
747
+ plan: planCrs,
748
+ loading,
323
749
  }),
324
- [mapInstance, isLoaded, isStyleLoaded, resolvedTheme, themeKey],
750
+ [mapInstance, isLoaded, isStyleLoaded, resolvedTheme, themeKey, planCrs, loading],
325
751
  );
326
752
 
327
753
  if (initFailed) {
@@ -329,8 +755,8 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
329
755
  <div className={cn("relative h-full w-full", className)}>
330
756
  <StatePanel
331
757
  kind="error"
332
- title="Map unavailable"
333
- description="This browser can’t render WebGL maps."
758
+ title={t("maps.canvas.unavailableTitle")}
759
+ description={t("maps.canvas.unavailableDescription")}
334
760
  className="h-full"
335
761
  />
336
762
  </div>
@@ -339,11 +765,31 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
339
765
 
340
766
  return (
341
767
  <MapContext.Provider value={contextValue}>
342
- <div ref={containerRef} className={cn("relative h-full w-full", className)}>
343
- {(!isLoaded || loading) && <MapLoadingOverlay />}
344
- {/* SSR-safe: children render only when the map exists on the client. */}
345
- {mapInstance && children}
346
- </div>
768
+ <MapFrameContext.Provider value={frameValue}>
769
+ <MapAnnotationRegistryProvider value={annotations.value}>
770
+ {slotRequests.above > 0 && (
771
+ <div ref={setAboveEl} data-slot="map-canvas-above" className="pb-2" />
772
+ )}
773
+ <div
774
+ ref={setContainer}
775
+ data-slot="map-canvas"
776
+ data-map-breakpoint={breakpoint}
777
+ data-interactive={isStatic ? "false" : undefined}
778
+ className={cn("relative h-full w-full", className)}
779
+ style={heightStyle}
780
+ >
781
+ {(!isLoaded || loading) && <MapLoadingOverlay />}
782
+ {/* SSR-safe: children render only when the map exists on the client. */}
783
+ {mapInstance && children}
784
+ </div>
785
+ {showBelow && (
786
+ <div data-slot="map-canvas-below" className="flex flex-col gap-2 pt-2">
787
+ <div ref={setBelowEl} data-slot="map-canvas-below-furniture" className="contents" />
788
+ {annotations.rows.length > 0 && <MapAnnotationKey rows={annotations.rows} />}
789
+ </div>
790
+ )}
791
+ </MapAnnotationRegistryProvider>
792
+ </MapFrameContext.Provider>
347
793
  </MapContext.Provider>
348
794
  );
349
795
  });
@@ -3,6 +3,8 @@
3
3
  import type MapLibreGL from "maplibre-gl";
4
4
  import { createContext, use } from "react";
5
5
 
6
+ import type { PlanCrs } from "../lib/plan-crs";
7
+
6
8
  /** Light-or-dark flavor of the active basemap (derived from the brand theme). */
7
9
  export type BasemapTheme = "light" | "dark";
8
10
 
@@ -18,6 +20,15 @@ export interface MapContextValue {
18
20
  * as a dependency key to re-resolve semantic token colors for WebGL paint.
19
21
  */
20
22
  themeKey: string;
23
+ /**
24
+ * The plan coordinate system when this canvas is a CUSTOM (non-geographic)
25
+ * plan map — a floor plan, factory layout or carriage — and `null` for an
26
+ * ordinary geographic map. Layer components read it to convert the plan
27
+ * coordinates they are handed, and to report plan coordinates back.
28
+ */
29
+ plan: PlanCrs | null;
30
+ /** True while the consumer has asked for a loading overlay over the map. */
31
+ loading: boolean;
21
32
  }
22
33
 
23
34
  export const MapContext = createContext<MapContextValue | null>(null);