@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
@@ -16,8 +16,25 @@ import {
16
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,16 +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
313
  const { t } = useLocale();
146
- const containerRef = useRef<HTMLDivElement>(null);
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;
147
322
  const [mapInstance, setMapInstance] = useState<MapLibreGL.Map | null>(null);
148
323
  const [initFailed, setInitFailed] = useState(false);
149
324
  const [isLoaded, setIsLoaded] = useState(false);
@@ -158,10 +333,16 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
158
333
  const onViewportChangeRef = useRef(onViewportChange);
159
334
  onViewportChangeRef.current = onViewportChange;
160
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
+
161
343
  // Read from the mount-only `styledata` handler below so a `projection` prop
162
344
  // change after mount isn't reapplied with the value captured at mount time.
163
345
  const projectionRef = useRef(projection);
164
- projectionRef.current = projection;
165
346
 
166
347
  const mapStyles = useMemo(() => {
167
348
  // Explicit styles win. Otherwise `blank` opts into the transparent
@@ -178,6 +359,61 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
178
359
  return defaultStyles;
179
360
  }, [styles, blank]);
180
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
+
181
417
  // Expose the map instance to the parent component.
182
418
  useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]);
183
419
 
@@ -188,10 +424,52 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
188
424
  }
189
425
  }, []);
190
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
+
191
457
  // Initialize the map.
192
458
  useEffect(() => {
193
459
  if (!containerRef.current) return;
194
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
+
195
473
  const initialStyle = resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
196
474
  currentStyleRef.current = initialStyle;
197
475
 
@@ -210,8 +488,24 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
210
488
  // `...props` below), or move to tiles licensed without the requirement via
211
489
  // `styles` / `blank`.
212
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 } : {}),
213
504
  ...props,
214
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 : {}),
215
509
  });
216
510
  } catch {
217
511
  // MapLibre throws at construction when WebGL is unavailable (headless
@@ -228,7 +522,7 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
228
522
  styleTimeoutRef.current = setTimeout(() => {
229
523
  setIsStyleLoaded(true);
230
524
  if (projectionRef.current) {
231
- map.setProjection(projectionRef.current);
525
+ applyProjection(map, projectionRef.current);
232
526
  }
233
527
  }, 100);
234
528
  };
@@ -253,9 +547,21 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
253
547
  onViewportChangeRef.current?.(getViewport(map));
254
548
  };
255
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
+
256
561
  map.on("load", loadHandler);
257
562
  map.on("styledata", styleDataHandler);
258
563
  map.on("move", handleMove);
564
+ map.on("movestart", handleMoveStart);
259
565
  setMapInstance(map);
260
566
 
261
567
  return () => {
@@ -263,6 +569,7 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
263
569
  map.off("load", loadHandler);
264
570
  map.off("styledata", styleDataHandler);
265
571
  map.off("move", handleMove);
572
+ map.off("movestart", handleMoveStart);
266
573
  map.remove();
267
574
  setIsLoaded(false);
268
575
  setIsStyleLoaded(false);
@@ -316,9 +623,120 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
316
623
 
317
624
  // Sync projection when the prop changes after mount.
318
625
  useEffect(() => {
319
- if (!mapInstance || !isStyleLoaded || !projection) return;
320
- mapInstance.setProjection(projection);
321
- }, [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;
322
740
 
323
741
  const contextValue = useMemo(
324
742
  () => ({
@@ -326,8 +744,10 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
326
744
  isLoaded: isLoaded && isStyleLoaded,
327
745
  resolvedTheme,
328
746
  themeKey,
747
+ plan: planCrs,
748
+ loading,
329
749
  }),
330
- [mapInstance, isLoaded, isStyleLoaded, resolvedTheme, themeKey],
750
+ [mapInstance, isLoaded, isStyleLoaded, resolvedTheme, themeKey, planCrs, loading],
331
751
  );
332
752
 
333
753
  if (initFailed) {
@@ -345,11 +765,31 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
345
765
 
346
766
  return (
347
767
  <MapContext.Provider value={contextValue}>
348
- <div ref={containerRef} className={cn("relative h-full w-full", className)}>
349
- {(!isLoaded || loading) && <MapLoadingOverlay />}
350
- {/* SSR-safe: children render only when the map exists on the client. */}
351
- {mapInstance && children}
352
- </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>
353
793
  </MapContext.Provider>
354
794
  );
355
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);
@@ -5,6 +5,7 @@ import { useEffect, useId, useMemo } from "react";
5
5
 
6
6
  import { useMap } from "../map-canvas/map-context";
7
7
  import { useTokenColor } from "../lib/use-token-color";
8
+ import { warnMapOnce } from "../lib/warn-once";
8
9
 
9
10
  export type MapClusterLayerProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> =
10
11
  {
@@ -38,6 +39,11 @@ const DEFAULT_CLUSTER_THRESHOLDS: [number, number] = [100, 750];
38
39
  * Clustered point rendering for large point datasets. Cluster circles step
39
40
  * through the status tokens (success → warning → destructive) as the point
40
41
  * count grows; strokes and count labels use the page surface for contrast.
42
+ *
43
+ * NOT for a plan map. The count inside each circle is a `symbol` `text-field`,
44
+ * and a blank style — which every plan uses — ships no glyph endpoint, so the
45
+ * counts would silently render as nothing at all. Cluster plan points yourself
46
+ * and draw the groups with `MapGeoJSON`, or put the count in the DOM overlay.
41
47
  */
42
48
  export function MapClusterLayer<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties>({
43
49
  data,
@@ -49,13 +55,20 @@ export function MapClusterLayer<P extends GeoJSON.GeoJsonProperties = GeoJSON.Ge
49
55
  onPointClick,
50
56
  onClusterClick,
51
57
  }: MapClusterLayerProps<P>) {
52
- const { map, isLoaded } = useMap();
58
+ const { map, isLoaded, plan } = useMap();
53
59
  const id = useId();
54
60
  const sourceId = `cluster-source-${id}`;
55
61
  const clusterLayerId = `clusters-${id}`;
56
62
  const clusterCountLayerId = `cluster-count-${id}`;
57
63
  const unclusteredLayerId = `unclustered-point-${id}`;
58
64
 
65
+ if (plan) {
66
+ warnMapOnce(
67
+ "cluster-layer-plan",
68
+ "<MapClusterLayer> is not for a plan map: its count label is a symbol text-field, and a plan's blank style has no glyph endpoint, so the counts render as nothing.",
69
+ );
70
+ }
71
+
59
72
  const success = useTokenColor("--success");
60
73
  const warning = useTokenColor("--warning");
61
74
  const destructive = useTokenColor("--destructive");