@elabs-ai/components-maps 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +1 -1
  2. package/dist/chunk-2FPB53NG.js +36 -0
  3. package/dist/chunk-2FPB53NG.js.map +1 -0
  4. package/dist/chunk-2VVJRT66.js +37 -0
  5. package/dist/chunk-2VVJRT66.js.map +1 -0
  6. package/dist/chunk-3HXFONRP.js +2 -0
  7. package/dist/chunk-3HXFONRP.js.map +1 -0
  8. package/dist/chunk-3SJN3IYT.js +2 -0
  9. package/dist/chunk-3SJN3IYT.js.map +1 -0
  10. package/dist/chunk-4DANBD5B.js +55 -0
  11. package/dist/chunk-4DANBD5B.js.map +1 -0
  12. package/dist/chunk-5PDVG53B.js +2 -0
  13. package/dist/chunk-5PDVG53B.js.map +1 -0
  14. package/dist/chunk-643R7IJC.js +2 -0
  15. package/dist/chunk-643R7IJC.js.map +1 -0
  16. package/dist/chunk-B3ZDBQ6I.js +164 -0
  17. package/dist/chunk-B3ZDBQ6I.js.map +1 -0
  18. package/dist/chunk-BGCE3RR5.js +35 -0
  19. package/dist/chunk-BGCE3RR5.js.map +1 -0
  20. package/dist/chunk-C4VI37TD.js +485 -0
  21. package/dist/chunk-C4VI37TD.js.map +1 -0
  22. package/dist/chunk-CJJT6AEI.js +179 -0
  23. package/dist/chunk-CJJT6AEI.js.map +1 -0
  24. package/dist/chunk-D2RCPTRO.js +2 -0
  25. package/dist/chunk-D2RCPTRO.js.map +1 -0
  26. package/dist/chunk-DTV7JZVN.js +2 -0
  27. package/dist/chunk-DTV7JZVN.js.map +1 -0
  28. package/dist/chunk-E65Z3MSV.js +80 -0
  29. package/dist/chunk-E65Z3MSV.js.map +1 -0
  30. package/dist/chunk-E7VPNINZ.js +70 -0
  31. package/dist/chunk-E7VPNINZ.js.map +1 -0
  32. package/dist/chunk-FHV5IA6N.js +100 -0
  33. package/dist/chunk-FHV5IA6N.js.map +1 -0
  34. package/dist/chunk-FQ6V7BFZ.js +347 -0
  35. package/dist/chunk-FQ6V7BFZ.js.map +1 -0
  36. package/dist/chunk-G7JDXDJN.js +19 -0
  37. package/dist/chunk-G7JDXDJN.js.map +1 -0
  38. package/dist/chunk-GWULZOMW.js +21 -0
  39. package/dist/chunk-GWULZOMW.js.map +1 -0
  40. package/dist/chunk-GYDTTAVD.js +120 -0
  41. package/dist/chunk-GYDTTAVD.js.map +1 -0
  42. package/dist/chunk-HCOBWIM4.js +48 -0
  43. package/dist/chunk-HCOBWIM4.js.map +1 -0
  44. package/dist/chunk-IAHK5MVT.js +2 -0
  45. package/dist/chunk-IAHK5MVT.js.map +1 -0
  46. package/dist/chunk-JGJ6442I.js +235 -0
  47. package/dist/chunk-JGJ6442I.js.map +1 -0
  48. package/dist/chunk-JYFERXZT.js +18 -0
  49. package/dist/chunk-JYFERXZT.js.map +1 -0
  50. package/dist/chunk-LU2UEKFM.js +53 -0
  51. package/dist/chunk-LU2UEKFM.js.map +1 -0
  52. package/dist/chunk-LVYM6C5C.js +2 -0
  53. package/dist/chunk-LVYM6C5C.js.map +1 -0
  54. package/dist/chunk-MIMDP3EU.js +104 -0
  55. package/dist/chunk-MIMDP3EU.js.map +1 -0
  56. package/dist/chunk-NNBURJHY.js +2 -0
  57. package/dist/chunk-NNBURJHY.js.map +1 -0
  58. package/dist/chunk-NPUC3V37.js +2 -0
  59. package/dist/chunk-NPUC3V37.js.map +1 -0
  60. package/dist/chunk-OEVVNPN3.js +201 -0
  61. package/dist/chunk-OEVVNPN3.js.map +1 -0
  62. package/dist/chunk-PF3R2BXM.js +200 -0
  63. package/dist/chunk-PF3R2BXM.js.map +1 -0
  64. package/dist/chunk-Q2I5YODW.js +2 -0
  65. package/dist/chunk-Q2I5YODW.js.map +1 -0
  66. package/dist/chunk-QDWWP3N7.js +247 -0
  67. package/dist/chunk-QDWWP3N7.js.map +1 -0
  68. package/dist/chunk-QU33KPMP.js +2 -0
  69. package/dist/chunk-QU33KPMP.js.map +1 -0
  70. package/dist/chunk-RK4BUMHS.js +2 -0
  71. package/dist/chunk-RK4BUMHS.js.map +1 -0
  72. package/dist/chunk-RNSLG64F.js +26 -0
  73. package/dist/chunk-RNSLG64F.js.map +1 -0
  74. package/dist/chunk-RV673TB4.js +68 -0
  75. package/dist/chunk-RV673TB4.js.map +1 -0
  76. package/dist/chunk-S7N2WMVT.js +180 -0
  77. package/dist/chunk-S7N2WMVT.js.map +1 -0
  78. package/dist/chunk-T3Z6TCDD.js +2 -0
  79. package/dist/chunk-T3Z6TCDD.js.map +1 -0
  80. package/dist/chunk-T4TJTVVA.js +105 -0
  81. package/dist/chunk-T4TJTVVA.js.map +1 -0
  82. package/dist/chunk-UMOYRCDX.js +103 -0
  83. package/dist/chunk-UMOYRCDX.js.map +1 -0
  84. package/dist/chunk-V2REM6UT.js +185 -0
  85. package/dist/chunk-V2REM6UT.js.map +1 -0
  86. package/dist/chunk-VFBRFGID.js +89 -0
  87. package/dist/chunk-VFBRFGID.js.map +1 -0
  88. package/dist/chunk-W76NLFER.js +103 -0
  89. package/dist/chunk-W76NLFER.js.map +1 -0
  90. package/dist/chunk-WNU4IOGL.js +116 -0
  91. package/dist/chunk-WNU4IOGL.js.map +1 -0
  92. package/dist/chunk-WS4Z7ENS.js +494 -0
  93. package/dist/chunk-WS4Z7ENS.js.map +1 -0
  94. package/dist/chunk-XQT7YLJY.js +497 -0
  95. package/dist/chunk-XQT7YLJY.js.map +1 -0
  96. package/dist/chunk-Y44MEKJV.js +21 -0
  97. package/dist/chunk-Y44MEKJV.js.map +1 -0
  98. package/dist/chunk-Z4EPSOB4.js +53 -0
  99. package/dist/chunk-Z4EPSOB4.js.map +1 -0
  100. package/dist/chunk-Z5OPDBDC.js +2 -0
  101. package/dist/chunk-Z5OPDBDC.js.map +1 -0
  102. package/dist/index.d.ts +1241 -46
  103. package/dist/index.js +189 -1456
  104. package/dist/index.js.map +1 -1
  105. package/dist/lib/arc-math.js +8 -0
  106. package/dist/lib/arc-math.js.map +1 -0
  107. package/dist/lib/map-position.js +11 -0
  108. package/dist/lib/map-position.js.map +1 -0
  109. package/dist/lib/merge-hover-paint.js +10 -0
  110. package/dist/lib/merge-hover-paint.js.map +1 -0
  111. package/dist/lib/plan-crs.js +14 -0
  112. package/dist/lib/plan-crs.js.map +1 -0
  113. package/dist/lib/plan-patterns.js +14 -0
  114. package/dist/lib/plan-patterns.js.map +1 -0
  115. package/dist/lib/plan-regions.js +18 -0
  116. package/dist/lib/plan-regions.js.map +1 -0
  117. package/dist/lib/plan-status.js +14 -0
  118. package/dist/lib/plan-status.js.map +1 -0
  119. package/dist/lib/route-arrow.js +12 -0
  120. package/dist/lib/route-arrow.js.map +1 -0
  121. package/dist/lib/use-map-breakpoint.js +33 -0
  122. package/dist/lib/use-map-breakpoint.js.map +1 -0
  123. package/dist/lib/use-plan-projection.js +9 -0
  124. package/dist/lib/use-plan-projection.js.map +1 -0
  125. package/dist/lib/use-token-color.js +10 -0
  126. package/dist/lib/use-token-color.js.map +1 -0
  127. package/dist/lib/warn-once.js +10 -0
  128. package/dist/lib/warn-once.js.map +1 -0
  129. package/dist/map-annotation/anchor.js +14 -0
  130. package/dist/map-annotation/anchor.js.map +1 -0
  131. package/dist/map-annotation/index.js +21 -0
  132. package/dist/map-annotation/index.js.map +1 -0
  133. package/dist/map-annotation/map-annotation.js +18 -0
  134. package/dist/map-annotation/map-annotation.js.map +1 -0
  135. package/dist/map-arc/index.js +16 -0
  136. package/dist/map-arc/index.js.map +1 -0
  137. package/dist/map-arc/map-arc.js +13 -0
  138. package/dist/map-arc/map-arc.js.map +1 -0
  139. package/dist/map-canvas/index.css +11 -0
  140. package/dist/map-canvas/index.css.map +1 -0
  141. package/dist/map-canvas/index.js +20 -0
  142. package/dist/map-canvas/index.js.map +1 -0
  143. package/dist/map-canvas/map-canvas.css +11 -0
  144. package/dist/map-canvas/map-canvas.css.map +1 -0
  145. package/dist/map-canvas/map-canvas.js +17 -0
  146. package/dist/map-canvas/map-canvas.js.map +1 -0
  147. package/dist/map-canvas/map-context.js +11 -0
  148. package/dist/map-canvas/map-context.js.map +1 -0
  149. package/dist/map-canvas/use-resolved-basemap-theme.js +9 -0
  150. package/dist/map-canvas/use-resolved-basemap-theme.js.map +1 -0
  151. package/dist/map-cluster-layer/index.js +12 -0
  152. package/dist/map-cluster-layer/index.js.map +1 -0
  153. package/dist/map-cluster-layer/map-cluster-layer.js +12 -0
  154. package/dist/map-cluster-layer/map-cluster-layer.js.map +1 -0
  155. package/dist/map-controls/index.js +11 -0
  156. package/dist/map-controls/index.js.map +1 -0
  157. package/dist/map-controls/map-controls.js +11 -0
  158. package/dist/map-controls/map-controls.js.map +1 -0
  159. package/dist/map-geojson/index.js +13 -0
  160. package/dist/map-geojson/index.js.map +1 -0
  161. package/dist/map-geojson/map-geojson.js +13 -0
  162. package/dist/map-geojson/map-geojson.js.map +1 -0
  163. package/dist/map-inset/index.css +11 -0
  164. package/dist/map-inset/index.css.map +1 -0
  165. package/dist/map-inset/index.js +20 -0
  166. package/dist/map-inset/index.js.map +1 -0
  167. package/dist/map-inset/map-inset.css +11 -0
  168. package/dist/map-inset/map-inset.css.map +1 -0
  169. package/dist/map-inset/map-inset.js +20 -0
  170. package/dist/map-inset/map-inset.js.map +1 -0
  171. package/dist/map-legend/index.js +14 -0
  172. package/dist/map-legend/index.js.map +1 -0
  173. package/dist/map-legend/map-corner.js +10 -0
  174. package/dist/map-legend/map-corner.js.map +1 -0
  175. package/dist/map-legend/map-legend.js +11 -0
  176. package/dist/map-legend/map-legend.js.map +1 -0
  177. package/dist/map-marker/index.js +22 -0
  178. package/dist/map-marker/index.js.map +1 -0
  179. package/dist/map-marker/map-marker.js +22 -0
  180. package/dist/map-marker/map-marker.js.map +1 -0
  181. package/dist/map-north-arrow/index.js +11 -0
  182. package/dist/map-north-arrow/index.js.map +1 -0
  183. package/dist/map-north-arrow/map-north-arrow.js +11 -0
  184. package/dist/map-north-arrow/map-north-arrow.js.map +1 -0
  185. package/dist/map-plan/index.js +30 -0
  186. package/dist/map-plan/index.js.map +1 -0
  187. package/dist/map-plan/map-plan-legend.js +10 -0
  188. package/dist/map-plan/map-plan-legend.js.map +1 -0
  189. package/dist/map-plan/map-plan-overlay.js +13 -0
  190. package/dist/map-plan/map-plan-overlay.js.map +1 -0
  191. package/dist/map-plan/map-plan-status.js +9 -0
  192. package/dist/map-plan/map-plan-status.js.map +1 -0
  193. package/dist/map-plan/map-plan-table.js +10 -0
  194. package/dist/map-plan/map-plan-table.js.map +1 -0
  195. package/dist/map-plan/use-plan-selection.js +9 -0
  196. package/dist/map-plan/use-plan-selection.js.map +1 -0
  197. package/dist/map-plan-image/index.js +11 -0
  198. package/dist/map-plan-image/index.js.map +1 -0
  199. package/dist/map-plan-image/map-plan-image.js +11 -0
  200. package/dist/map-plan-image/map-plan-image.js.map +1 -0
  201. package/dist/map-popup/index.js +12 -0
  202. package/dist/map-popup/index.js.map +1 -0
  203. package/dist/map-popup/map-popup.js +12 -0
  204. package/dist/map-popup/map-popup.js.map +1 -0
  205. package/dist/map-route/index.js +12 -0
  206. package/dist/map-route/index.js.map +1 -0
  207. package/dist/map-route/map-route.js +12 -0
  208. package/dist/map-route/map-route.js.map +1 -0
  209. package/dist/map-scale-bar/index.js +18 -0
  210. package/dist/map-scale-bar/index.js.map +1 -0
  211. package/dist/map-scale-bar/map-scale-bar.js +18 -0
  212. package/dist/map-scale-bar/map-scale-bar.js.map +1 -0
  213. package/package.json +5 -5
  214. package/src/index.ts +83 -0
  215. package/src/lib/arc-math.test.ts +19 -0
  216. package/src/lib/arc-math.ts +6 -1
  217. package/src/lib/map-position.ts +77 -0
  218. package/src/lib/merge-hover-paint.ts +44 -13
  219. package/src/lib/plan-crs.parity.test.ts +39 -0
  220. package/src/lib/plan-crs.test.ts +248 -0
  221. package/src/lib/plan-crs.ts +252 -0
  222. package/src/lib/plan-patterns.test.ts +255 -0
  223. package/src/lib/plan-patterns.ts +160 -0
  224. package/src/lib/plan-regions.test.ts +186 -0
  225. package/src/lib/plan-regions.ts +209 -0
  226. package/src/lib/plan-status.test.ts +87 -0
  227. package/src/lib/plan-status.ts +147 -0
  228. package/src/lib/route-arrow.test.ts +88 -0
  229. package/src/lib/route-arrow.ts +59 -0
  230. package/src/lib/use-map-breakpoint.test.ts +83 -0
  231. package/src/lib/use-map-breakpoint.ts +238 -0
  232. package/src/lib/use-plan-projection.ts +157 -0
  233. package/src/lib/warn-once.ts +21 -0
  234. package/src/map-annotation/anchor.ts +92 -0
  235. package/src/map-annotation/index.ts +15 -0
  236. package/src/map-annotation/map-annotation.stories.tsx +113 -0
  237. package/src/map-annotation/map-annotation.test.tsx +106 -0
  238. package/src/map-annotation/map-annotation.tsx +356 -0
  239. package/src/map-arc/map-arc.tsx +26 -5
  240. package/src/map-canvas/map-canvas.stories.tsx +211 -0
  241. package/src/map-canvas/map-canvas.test.tsx +421 -1
  242. package/src/map-canvas/map-canvas.tsx +466 -20
  243. package/src/map-canvas/map-context.ts +11 -0
  244. package/src/map-cluster-layer/map-cluster-layer.tsx +14 -1
  245. package/src/map-controls/map-controls.stories.tsx +16 -0
  246. package/src/map-controls/map-controls.test.tsx +145 -1
  247. package/src/map-controls/map-controls.tsx +57 -12
  248. package/src/map-geojson/map-geojson.stories.tsx +153 -3
  249. package/src/map-geojson/map-geojson.test.tsx +453 -0
  250. package/src/map-geojson/map-geojson.tsx +591 -51
  251. package/src/map-inset/index.ts +1 -0
  252. package/src/map-inset/map-inset.stories.tsx +61 -0
  253. package/src/map-inset/map-inset.test.tsx +117 -0
  254. package/src/map-inset/map-inset.tsx +264 -0
  255. package/src/map-legend/index.ts +10 -0
  256. package/src/map-legend/map-corner.ts +23 -0
  257. package/src/map-legend/map-legend.stories.tsx +171 -0
  258. package/src/map-legend/map-legend.test.tsx +136 -0
  259. package/src/map-legend/map-legend.tsx +264 -0
  260. package/src/map-marker/map-marker.stories.tsx +136 -4
  261. package/src/map-marker/map-marker.test.tsx +383 -3
  262. package/src/map-marker/map-marker.tsx +414 -44
  263. package/src/map-north-arrow/index.ts +1 -0
  264. package/src/map-north-arrow/map-north-arrow.stories.tsx +65 -0
  265. package/src/map-north-arrow/map-north-arrow.test.tsx +39 -0
  266. package/src/map-north-arrow/map-north-arrow.tsx +82 -0
  267. package/src/map-plan/index.ts +13 -0
  268. package/src/map-plan/map-plan-legend.test.tsx +61 -0
  269. package/src/map-plan/map-plan-legend.tsx +83 -0
  270. package/src/map-plan/map-plan-overlay.stories.tsx +260 -0
  271. package/src/map-plan/map-plan-overlay.test.tsx +302 -0
  272. package/src/map-plan/map-plan-overlay.tsx +531 -0
  273. package/src/map-plan/map-plan-status.test.tsx +71 -0
  274. package/src/map-plan/map-plan-status.tsx +77 -0
  275. package/src/map-plan/map-plan-table.test.tsx +86 -0
  276. package/src/map-plan/map-plan-table.tsx +153 -0
  277. package/src/map-plan/use-plan-selection.ts +78 -0
  278. package/src/map-plan-image/index.ts +1 -0
  279. package/src/map-plan-image/map-plan-image.stories.tsx +301 -0
  280. package/src/map-plan-image/map-plan-image.test.tsx +176 -0
  281. package/src/map-plan-image/map-plan-image.tsx +279 -0
  282. package/src/map-popup/map-popup.tsx +18 -11
  283. package/src/map-route/index.ts +1 -1
  284. package/src/map-route/map-route.test.tsx +216 -0
  285. package/src/map-route/map-route.tsx +105 -6
  286. package/src/map-scale-bar/index.ts +9 -0
  287. package/src/map-scale-bar/map-scale-bar.stories.tsx +63 -0
  288. package/src/map-scale-bar/map-scale-bar.test.tsx +65 -0
  289. package/src/map-scale-bar/map-scale-bar.tsx +145 -0
  290. package/src/test-utils/locator-fixture.ts +75 -0
  291. package/src/test-utils/maplibre-mock.ts +267 -20
@@ -0,0 +1,252 @@
1
+ /**
2
+ * A coordinate system for a CUSTOM, non-geographic plan — a floor plan, a
3
+ * factory layout, a train carriage, a rack elevation. Kept engine-free so it can
4
+ * be unit-tested (same convention as `arc-math.ts`).
5
+ *
6
+ * MapLibre GL JS renders Web Mercator and nothing else: it has no custom CRS and
7
+ * none is planned, so a plan coordinate system has to be SYNTHESIZED on top of
8
+ * Mercator. The same convention as Leaflet's `CRS.Simple` (origin top-left, one
9
+ * map unit = one pixel) and Qlik Sense's image background layer (corner
10
+ * coordinates in the image's own pixels, projection "undefined").
11
+ *
12
+ * The plan is mapped LINEARLY INTO NORMALIZED MERCATOR, not linearly into
13
+ * degrees. Normalized Mercator is what the screen shows, so one scale factor for
14
+ * both axes keeps the plan's aspect ratio exact at every zoom and every plan
15
+ * size. Mapping linearly into degrees — the naive reading of "undefined degrees"
16
+ * — stretches the plan vertically by Mercator's cos(lat) factor: a 1600 x 900
17
+ * plan comes out at aspect 1.996 instead of 1.778, 12% too wide.
18
+ *
19
+ * What this is NOT: a georeference. Mercator's y is non-linear in latitude, so
20
+ * lengths measured on the Earth are meaningless here. Distances belong to the
21
+ * plan (`distance()` returns plan units); never put a scale bar on a plan map,
22
+ * and never combine a plan with the globe projection.
23
+ */
24
+
25
+ /**
26
+ * How much of normalized Mercator the plan's LONG side spans. Normalizing on the
27
+ * long side caps the footprint whatever the aspect ratio: the tallest possible
28
+ * plan reaches +-66.5 deg latitude, comfortably inside Mercator's +-85.05 limit.
29
+ */
30
+ export const PLAN_MERCATOR_SPAN = 0.5;
31
+
32
+ /** MapLibre's world is this many CSS pixels across at zoom 0. */
33
+ const MERCATOR_TILE_SIZE = 512;
34
+
35
+ /** A point in plan units. */
36
+ export interface PlanPoint {
37
+ x: number;
38
+ y: number;
39
+ }
40
+
41
+ /** The plan's own extent and units — everything the coordinate system needs. */
42
+ export interface PlanExtent {
43
+ /** Plan width in plan units (image pixels, millimetres, metres — your choice). */
44
+ width: number;
45
+ /** Plan height in plan units. */
46
+ height: number;
47
+ /**
48
+ * Where plan (0, 0) sits. `"top-left"` (the default) matches an image and
49
+ * Leaflet's `CRS.Simple`; `"bottom-left"` matches y-up CAD/DXF exports.
50
+ */
51
+ origin?: "top-left" | "bottom-left";
52
+ /** Unit name, for labels and readouts only — the maths never reads it. */
53
+ unit?: string;
54
+ }
55
+
56
+ /** The four image-source corners MapLibre wants: top-left, top-right, bottom-right, bottom-left. */
57
+ export type PlanImageCoordinates = [
58
+ [number, number],
59
+ [number, number],
60
+ [number, number],
61
+ [number, number],
62
+ ];
63
+
64
+ /** A two-way transform between plan units and lng/lat, plus everything derived from it. */
65
+ export interface PlanCrs {
66
+ /** The extent this was built from, with defaults applied. */
67
+ readonly extent: Required<PlanExtent>;
68
+ /** Normalized Mercator units per plan unit. */
69
+ readonly scale: number;
70
+ /** Plan point -> `[lng, lat]`. */
71
+ toLngLat(point: PlanPoint | [number, number]): [number, number];
72
+ /** `[lng, lat]` -> plan point. */
73
+ toPlan(lngLat: [number, number] | { lng: number; lat: number }): PlanPoint;
74
+ /** Deep-convert GeoJSON authored in plan units into lng/lat GeoJSON. */
75
+ toGeoJSON<T>(data: T): T;
76
+ /** The plan's bounds as `[[west, south], [east, north]]`. */
77
+ readonly bounds: [[number, number], [number, number]];
78
+ /** Bounds grown by `padFraction` of the long side — a pan limit that still shows the edges. */
79
+ maxBounds(padFraction?: number): [[number, number], [number, number]];
80
+ /** The plan's four corners, ready for a MapLibre `image` or `canvas` source. */
81
+ readonly imageCoordinates: PlanImageCoordinates;
82
+ /** The zoom at which the plan's long side covers `px` CSS pixels. */
83
+ zoomForLongSidePixels(px: number): number;
84
+ /** Zoom at which the plan's long side is 128 px — small but still findable. */
85
+ readonly minZoom: number;
86
+ /** Zoom at which the plan's long side is 32768 px — a desk, a machine, a seat. */
87
+ readonly maxZoom: number;
88
+ /** Straight-line distance between two plan points, in plan units. */
89
+ distance(a: PlanPoint, b: PlanPoint): number;
90
+ }
91
+
92
+ const RAD_PER_DEG = Math.PI / 180;
93
+ const DEG_PER_RAD = 180 / Math.PI;
94
+
95
+ /**
96
+ * `[lng, lat]` -> normalized Mercator `[x, y]`, both in `[0, 1]`, with `y = 0` at
97
+ * the NORTH-WEST corner. The Web Mercator spec formulas, implemented here so the
98
+ * module stays engine-free; a parity test pins them to MapLibre's own
99
+ * `MercatorCoordinate`.
100
+ */
101
+ export function mercatorFromLngLat(lng: number, lat: number): [number, number] {
102
+ const x = (180 + lng) / 360;
103
+ const y = (180 - DEG_PER_RAD * Math.log(Math.tan(Math.PI / 4 + (lat * RAD_PER_DEG) / 2))) / 360;
104
+ return [x, y];
105
+ }
106
+
107
+ /** Normalized Mercator `[x, y]` -> `[lng, lat]`. The inverse of `mercatorFromLngLat`. */
108
+ export function lngLatFromMercator(x: number, y: number): [number, number] {
109
+ const lng = x * 360 - 180;
110
+ const y2 = 180 - y * 360;
111
+ const lat = (360 / Math.PI) * (Math.atan(Math.exp(y2 * RAD_PER_DEG)) - Math.PI / 4);
112
+ return [lng, lat];
113
+ }
114
+
115
+ function asPlanPoint(point: PlanPoint | [number, number]): PlanPoint {
116
+ return Array.isArray(point) ? { x: point[0], y: point[1] } : point;
117
+ }
118
+
119
+ /**
120
+ * Build a plan coordinate system from the plan's extent.
121
+ *
122
+ * ```tsx
123
+ * const plan = createPlanCrs({ width: 2400, height: 1600, unit: "cm" });
124
+ * <MapCanvas blank plan={plan}>
125
+ * <MapPlanImage src={floorPlanPng} />
126
+ * <MapGeoJSON data={roomsInPlanUnits} promoteId="id" />
127
+ * </MapCanvas>
128
+ * ```
129
+ *
130
+ * Throws when the extent is not a pair of finite, positive numbers — a NaN
131
+ * coordinate system is unfixable three layers downstream.
132
+ */
133
+ export function createPlanCrs(extent: PlanExtent): PlanCrs {
134
+ const { width, height, origin = "top-left", unit = "px" } = extent;
135
+
136
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
137
+ throw new Error(
138
+ `createPlanCrs: width and height must be finite positive numbers (received ${String(width)} x ${String(height)})`,
139
+ );
140
+ }
141
+
142
+ const resolved: Required<PlanExtent> = { width, height, origin, unit };
143
+ const scale = PLAN_MERCATOR_SPAN / Math.max(width, height);
144
+ // Centre the plan on normalized Mercator (0.5, 0.5), i.e. on lng/lat [0, 0].
145
+ const x0 = 0.5 - (width * scale) / 2;
146
+ const y0 = 0.5 - (height * scale) / 2;
147
+
148
+ // Mercator y already increases southward, so a top-left plan origin needs no
149
+ // flip. Only a y-up plan does.
150
+ const planYToMercatorY = (y: number) => y0 + (origin === "top-left" ? y : height - y) * scale;
151
+ const mercatorYToPlanY = (my: number) => {
152
+ const y = (my - y0) / scale;
153
+ return origin === "top-left" ? y : height - y;
154
+ };
155
+
156
+ const toLngLat = (point: PlanPoint | [number, number]): [number, number] => {
157
+ const { x, y } = asPlanPoint(point);
158
+ return lngLatFromMercator(x0 + x * scale, planYToMercatorY(y));
159
+ };
160
+
161
+ const toPlan = (lngLat: [number, number] | { lng: number; lat: number }): PlanPoint => {
162
+ const [lng, lat] = Array.isArray(lngLat) ? lngLat : [lngLat.lng, lngLat.lat];
163
+ const [mx, my] = mercatorFromLngLat(lng, lat);
164
+ return { x: (mx - x0) / scale, y: mercatorYToPlanY(my) };
165
+ };
166
+
167
+ const topLeft = toLngLat({ x: 0, y: 0 });
168
+ const bottomRight = toLngLat({ x: width, y: height });
169
+ // Plan y grows downward, so the plan's y=0 edge is the NORTHERN one.
170
+ const west = topLeft[0];
171
+ const east = bottomRight[0];
172
+ const north = Math.max(topLeft[1], bottomRight[1]);
173
+ const south = Math.min(topLeft[1], bottomRight[1]);
174
+
175
+ const bounds: [[number, number], [number, number]] = [
176
+ [west, south],
177
+ [east, north],
178
+ ];
179
+
180
+ const imageCoordinates: PlanImageCoordinates = [
181
+ toLngLat({ x: 0, y: 0 }),
182
+ toLngLat({ x: width, y: 0 }),
183
+ toLngLat({ x: width, y: height }),
184
+ toLngLat({ x: 0, y: height }),
185
+ ];
186
+
187
+ const zoomForLongSidePixels = (px: number) =>
188
+ Math.log2(px / (PLAN_MERCATOR_SPAN * MERCATOR_TILE_SIZE));
189
+
190
+ return Object.freeze({
191
+ extent: resolved,
192
+ scale,
193
+ toLngLat,
194
+ toPlan,
195
+ toGeoJSON: <T>(data: T): T => convertGeoJSON(data, toLngLat),
196
+ bounds,
197
+ maxBounds(padFraction = 0.1): [[number, number], [number, number]] {
198
+ const pad = Math.max(width, height) * padFraction;
199
+ const outerTopLeft = toLngLat({ x: -pad, y: -pad });
200
+ const outerBottomRight = toLngLat({ x: width + pad, y: height + pad });
201
+ return [
202
+ [outerTopLeft[0], Math.min(outerTopLeft[1], outerBottomRight[1])],
203
+ [outerBottomRight[0], Math.max(outerTopLeft[1], outerBottomRight[1])],
204
+ ];
205
+ },
206
+ imageCoordinates,
207
+ zoomForLongSidePixels,
208
+ minZoom: zoomForLongSidePixels(128),
209
+ maxZoom: zoomForLongSidePixels(32768),
210
+ distance: (a: PlanPoint, b: PlanPoint) => Math.hypot(b.x - a.x, b.y - a.y),
211
+ });
212
+ }
213
+
214
+ type Convert = (point: PlanPoint | [number, number]) => [number, number];
215
+
216
+ function convertPositions(coordinates: unknown, convert: Convert): unknown {
217
+ if (!Array.isArray(coordinates)) return coordinates;
218
+ // A position is a flat [x, y(, z)] — convert it and drop any third element,
219
+ // which would be a plan-unit altitude MapLibre cannot interpret.
220
+ if (typeof coordinates[0] === "number" && typeof coordinates[1] === "number") {
221
+ return convert([coordinates[0], coordinates[1]]);
222
+ }
223
+ return coordinates.map((entry) => convertPositions(entry, convert));
224
+ }
225
+
226
+ /**
227
+ * Deep-convert every position in a GeoJSON value from plan units to lng/lat.
228
+ * `bbox` is STRIPPED rather than converted: MapLibre trusts a declared bbox, and
229
+ * a plan-unit one would put the geometry in the wrong place.
230
+ */
231
+ function convertGeoJSON<T>(data: T, convert: Convert): T {
232
+ if (!data || typeof data !== "object") return data;
233
+
234
+ const input = data as Record<string, unknown>;
235
+ const { bbox: _bbox, ...rest } = input;
236
+ const output: Record<string, unknown> = { ...rest };
237
+
238
+ if (Array.isArray(input.features)) {
239
+ output.features = input.features.map((feature) => convertGeoJSON(feature, convert));
240
+ }
241
+ if (Array.isArray(input.geometries)) {
242
+ output.geometries = input.geometries.map((geometry) => convertGeoJSON(geometry, convert));
243
+ }
244
+ if (input.geometry && typeof input.geometry === "object") {
245
+ output.geometry = convertGeoJSON(input.geometry, convert);
246
+ }
247
+ if (input.coordinates !== undefined) {
248
+ output.coordinates = convertPositions(input.coordinates, convert);
249
+ }
250
+
251
+ return output as T;
252
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * jsdom ships no 2D canvas context, which is exactly the environment
3
+ * `createPlanPatternTile` must survive — so the no-context path is asserted for
4
+ * real, and the drawing itself is asserted against a recording stub.
5
+ */
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { renderHook } from "@testing-library/react";
8
+ import type MapLibreGL from "maplibre-gl";
9
+
10
+ import { MockMap } from "../test-utils/maplibre-mock";
11
+ import { createPlanPatternTile, planPatternImageId, usePlanPatterns } from "./plan-patterns";
12
+ import { PLAN_STATUSES, PLAN_STATUS_ENCODING, type PlanPatternKind } from "./plan-status";
13
+
14
+ interface DrawnLine {
15
+ from: [number, number];
16
+ to: [number, number];
17
+ width: number;
18
+ }
19
+
20
+ interface RecordingContext {
21
+ lines: DrawnLine[];
22
+ strokeStyle: string;
23
+ globalAlpha: number;
24
+ lineWidth: number;
25
+ }
26
+
27
+ /** A 2D context that records what was stroked instead of rasterizing it. */
28
+ function stubCanvasContext() {
29
+ const recorded: RecordingContext[] = [];
30
+
31
+ const spy = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (
32
+ this: HTMLCanvasElement,
33
+ kind: string,
34
+ ) {
35
+ if (kind !== "2d") return null;
36
+
37
+ const state: RecordingContext = {
38
+ lines: [],
39
+ strokeStyle: "",
40
+ globalAlpha: 1,
41
+ lineWidth: 1,
42
+ };
43
+ recorded.push(state);
44
+
45
+ let pen: [number, number] = [0, 0];
46
+ const context = {
47
+ set strokeStyle(value: string) {
48
+ state.strokeStyle = value;
49
+ },
50
+ get strokeStyle() {
51
+ return state.strokeStyle;
52
+ },
53
+ set globalAlpha(value: number) {
54
+ state.globalAlpha = value;
55
+ },
56
+ get globalAlpha() {
57
+ return state.globalAlpha;
58
+ },
59
+ set lineWidth(value: number) {
60
+ state.lineWidth = value;
61
+ },
62
+ get lineWidth() {
63
+ return state.lineWidth;
64
+ },
65
+ lineCap: "butt",
66
+ clearRect: () => {},
67
+ beginPath: () => {},
68
+ moveTo: (x: number, y: number) => {
69
+ pen = [x, y];
70
+ },
71
+ lineTo: (x: number, y: number) => {
72
+ state.lines.push({ from: pen, to: [x, y], width: state.lineWidth });
73
+ },
74
+ stroke: () => {},
75
+ getImageData: (_x: number, _y: number, width: number, height: number) => ({
76
+ data: new Uint8ClampedArray(width * height * 4),
77
+ }),
78
+ };
79
+
80
+ return context as unknown as CanvasRenderingContext2D;
81
+ });
82
+
83
+ return { recorded, spy };
84
+ }
85
+
86
+ afterEach(() => {
87
+ vi.restoreAllMocks();
88
+ MockMap.instances = [];
89
+ });
90
+
91
+ describe("planPatternImageId", () => {
92
+ it("namespaces the id, so two plans on one map cannot collide", () => {
93
+ expect(planPatternImageId("diagonal")).toBe("plan-pattern-diagonal");
94
+ const kinds: PlanPatternKind[] = ["diagonal", "cross", "dense"];
95
+ expect(new Set(kinds.map(planPatternImageId)).size).toBe(kinds.length);
96
+ });
97
+ });
98
+
99
+ describe("createPlanPatternTile", () => {
100
+ it("returns null for the untextured status and for a context-less environment", () => {
101
+ // No stub: jsdom has no 2D context, and the dash channel carries the meaning.
102
+ expect(createPlanPatternTile("none", "#000")).toBeNull();
103
+ expect(createPlanPatternTile("diagonal", "#000")).toBeNull();
104
+ });
105
+
106
+ it("draws a 16px tile in the ink it was handed, below full alpha", () => {
107
+ const { recorded } = stubCanvasContext();
108
+
109
+ const tile = createPlanPatternTile("diagonal", "rgb(1, 2, 3)");
110
+
111
+ expect(tile).not.toBeNull();
112
+ expect(tile!.width).toBe(16);
113
+ expect(tile!.height).toBe(16);
114
+ expect(recorded[0]!.strokeStyle).toBe("rgb(1, 2, 3)");
115
+ expect(recorded[0]!.globalAlpha).toBeLessThan(1);
116
+ });
117
+
118
+ it("gives each kind its own line geometry, and joins across tile edges", () => {
119
+ const { recorded } = stubCanvasContext();
120
+
121
+ createPlanPatternTile("diagonal", "#000");
122
+ createPlanPatternTile("cross", "#000");
123
+ createPlanPatternTile("dense", "#000");
124
+
125
+ const [diagonal, cross, dense] = recorded as [
126
+ RecordingContext,
127
+ RecordingContext,
128
+ RecordingContext,
129
+ ];
130
+
131
+ // `cross` hatches both ways; the other two run one way only.
132
+ const slopeSign = (line: DrawnLine) =>
133
+ Math.sign((line.to[1] - line.from[1]) / (line.to[0] - line.from[0]));
134
+ const slopes = (state: RecordingContext) => new Set(state.lines.map(slopeSign));
135
+ expect(slopes(diagonal).size).toBe(1);
136
+ expect(slopes(cross).size).toBe(2);
137
+ // `dense` is the same direction as `diagonal` at half the spacing, so it
138
+ // must draw more lines or the two would read alike.
139
+ expect(dense.lines.length).toBeGreaterThan(diagonal.lines.length);
140
+ expect(slopes(dense).size).toBe(1);
141
+
142
+ // Every line starts or ends outside the 16px tile: that overshoot is what
143
+ // makes the pattern seamless across tile edges.
144
+ for (const state of recorded) {
145
+ for (const line of state.lines) {
146
+ const coordinates = [...line.from, ...line.to];
147
+ expect(coordinates.some((value) => value < 0 || value > 16)).toBe(true);
148
+ }
149
+ }
150
+
151
+ // …and every line actually crosses the tile's middle third. A hatch line
152
+ // that only clips a corner passes every assertion above while painting
153
+ // nothing: the status then has no texture at all, which is how a
154
+ // corner-only `cross` shipped looking exactly like `dense`.
155
+ const crossesTheMiddle = (line: DrawnLine) => {
156
+ const [x1, y1] = line.from;
157
+ const [x2, y2] = line.to;
158
+ for (let step = 0; step <= 100; step += 1) {
159
+ const t = step / 100;
160
+ const x = x1 + (x2 - x1) * t;
161
+ const y = y1 + (y2 - y1) * t;
162
+ if (x > 16 / 3 && x < (16 * 2) / 3 && y > 16 / 3 && y < (16 * 2) / 3) return true;
163
+ }
164
+ return false;
165
+ };
166
+ for (const state of recorded) {
167
+ expect(state.lines.filter(crossesTheMiddle).length).toBeGreaterThan(0);
168
+ }
169
+ // `cross` crosses the middle in BOTH directions — the crosshatch is the
170
+ // whole point of it being a separate texture.
171
+ expect(new Set(cross.lines.filter(crossesTheMiddle).map(slopeSign)).size).toBe(2);
172
+ });
173
+ });
174
+
175
+ describe("usePlanPatterns", () => {
176
+ const renderWith = (map: MockMap | null, isLoaded = true, ink = "#111") =>
177
+ renderHook(
178
+ ({ ink: currentInk }: { ink: string }) =>
179
+ usePlanPatterns(map as unknown as MapLibreGL.Map | null, isLoaded, currentInk),
180
+ { initialProps: { ink } },
181
+ );
182
+
183
+ it("registers one image per textured status, at pixelRatio 2", () => {
184
+ stubCanvasContext();
185
+ const map = new MockMap({ container: document.createElement("div") });
186
+
187
+ renderWith(map);
188
+
189
+ const textured = new Set(
190
+ PLAN_STATUSES.map((status) => PLAN_STATUS_ENCODING[status].pattern).filter(
191
+ (pattern) => pattern !== "none",
192
+ ),
193
+ );
194
+ expect(map.addImageCalls).toHaveLength(textured.size);
195
+ for (const [id, , options] of map.addImageCalls) {
196
+ expect(id.startsWith("plan-pattern-")).toBe(true);
197
+ expect(options).toEqual({ pixelRatio: 2 });
198
+ }
199
+ expect(map.hasImage(planPatternImageId("diagonal"))).toBe(true);
200
+ });
201
+
202
+ it("re-registers on styledata, because setStyle drops every style image", () => {
203
+ stubCanvasContext();
204
+ const map = new MockMap({ container: document.createElement("div") });
205
+
206
+ renderWith(map);
207
+ const added = map.addImageCalls.length;
208
+ map.updateImageCalls = [];
209
+
210
+ // A theme swap calls setStyle, which fires styledata with the images gone.
211
+ map.emit("styledata");
212
+
213
+ // Second pass updates in place rather than adding twice.
214
+ expect(map.addImageCalls).toHaveLength(added);
215
+ expect(map.updateImageCalls.length).toBeGreaterThan(0);
216
+ });
217
+
218
+ it("stops listening when it unmounts", () => {
219
+ stubCanvasContext();
220
+ const map = new MockMap({ container: document.createElement("div") });
221
+
222
+ const view = renderWith(map);
223
+ view.unmount();
224
+ map.updateImageCalls = [];
225
+ map.emit("styledata");
226
+
227
+ expect(map.updateImageCalls).toHaveLength(0);
228
+ });
229
+
230
+ it("redraws when the ink changes, so a theme swap re-inks the hatch", () => {
231
+ const { recorded } = stubCanvasContext();
232
+ const map = new MockMap({ container: document.createElement("div") });
233
+
234
+ const view = renderWith(map);
235
+ // Only the drawing passes count: the registration reads the tile back
236
+ // through a second `getContext("2d")` that strokes nothing.
237
+ const inks = () =>
238
+ new Set(recorded.filter((state) => state.lines.length > 0).map((state) => state.strokeStyle));
239
+ expect(inks()).toEqual(new Set(["#111"]));
240
+
241
+ view.rerender({ ink: "#eee" });
242
+
243
+ expect(inks()).toEqual(new Set(["#111", "#eee"]));
244
+ });
245
+
246
+ it("does nothing without a map or before the style loads", () => {
247
+ stubCanvasContext();
248
+ const map = new MockMap({ container: document.createElement("div") });
249
+
250
+ renderWith(null);
251
+ renderWith(map, false);
252
+
253
+ expect(map.addImageCalls).toHaveLength(0);
254
+ });
255
+ });
@@ -0,0 +1,160 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import type MapLibreGL from "maplibre-gl";
5
+
6
+ import { PLAN_STATUSES, PLAN_STATUS_ENCODING, type PlanPatternKind } from "./plan-status";
7
+
8
+ /**
9
+ * Seamless hatch tiles for the status channel, generated on a canvas and handed
10
+ * to `map.addImage`. Generated rather than fetched: a blank plan style has no
11
+ * sprite and no glyph endpoint, and a texture that needs the network is a
12
+ * texture that sometimes is not there.
13
+ *
14
+ * The tile geometry mirrors `SeriesPatternKind` in
15
+ * `packages/charts/src/charts/series-pattern.tsx`, so a factory plan and a
16
+ * chart of the same data are textured alike. It is deliberately a copy: the
17
+ * one-way dependency graph forbids maps importing charts.
18
+ */
19
+
20
+ /** Tile edge, in px. Small enough to read at fit zoom, large enough not to moiré. */
21
+ const TILE = 16;
22
+ /** Ink alpha. The pattern is a texture over a tone wash, not a second fill. */
23
+ const INK_ALPHA = 0.55;
24
+
25
+ /** Image ids are stable and namespaced, so two plans on one map cannot collide. */
26
+ export function planPatternImageId(kind: PlanPatternKind): string {
27
+ return `plan-pattern-${kind}`;
28
+ }
29
+
30
+ /**
31
+ * A hatch at `spacing` px, as segments that overshoot the tile on both ends.
32
+ *
33
+ * The lines are laid out so the set repeats under a TILE-sized shift in either
34
+ * axis — that, not the overshoot alone, is what makes the tile seamless. Each
35
+ * line is written as the two points where it meets the tile's own axes, so one
36
+ * of them always crosses the tile's middle rather than clipping a corner: a
37
+ * corner-only line paints nothing and leaves the status untextured.
38
+ *
39
+ * `RISING` runs bottom-left to top-right (canvas y grows downward, so these are
40
+ * the `x + y = c` lines); `FALLING` is its mirror, and the two together are the
41
+ * crosshatch.
42
+ */
43
+ const OVERSHOOT = TILE / 2;
44
+
45
+ function RISING(spacing: number): [number, number, number, number][] {
46
+ const lines: [number, number, number, number][] = [];
47
+ for (let c = 0; c <= TILE * 2; c += spacing) {
48
+ lines.push([-OVERSHOOT, c + OVERSHOOT, TILE + OVERSHOOT, c - TILE - OVERSHOOT]);
49
+ }
50
+ return lines;
51
+ }
52
+
53
+ function FALLING(spacing: number): [number, number, number, number][] {
54
+ return RISING(spacing).map(
55
+ ([x1, y1, x2, y2]) => [x1, TILE - y1, x2, TILE - y2] as [number, number, number, number],
56
+ );
57
+ }
58
+
59
+ function strokeLines(
60
+ context: CanvasRenderingContext2D,
61
+ lines: [number, number, number, number][],
62
+ width: number,
63
+ ) {
64
+ context.lineWidth = width;
65
+ context.lineCap = "square";
66
+ for (const [x1, y1, x2, y2] of lines) {
67
+ context.beginPath();
68
+ context.moveTo(x1, y1);
69
+ context.lineTo(x2, y2);
70
+ context.stroke();
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Draw one seamless tile. Returns `null` for `"none"`, and for any environment
76
+ * with no 2D context (jsdom) — the dash channel then carries the meaning alone,
77
+ * which is why status has two non-colour channels rather than one.
78
+ */
79
+ export function createPlanPatternTile(
80
+ kind: PlanPatternKind,
81
+ ink: string,
82
+ ): HTMLCanvasElement | null {
83
+ if (kind === "none") return null;
84
+ if (typeof document === "undefined") return null;
85
+
86
+ const canvas = document.createElement("canvas");
87
+ canvas.width = TILE;
88
+ canvas.height = TILE;
89
+ const context = canvas.getContext("2d");
90
+ if (!context) return null;
91
+
92
+ context.clearRect(0, 0, TILE, TILE);
93
+ context.globalAlpha = INK_ALPHA;
94
+ context.strokeStyle = ink;
95
+
96
+ if (kind === "diagonal") {
97
+ strokeLines(context, RISING(TILE), 1.5);
98
+ } else if (kind === "cross") {
99
+ strokeLines(context, [...RISING(TILE), ...FALLING(TILE)], 1.25);
100
+ } else {
101
+ // dense: half the spacing of `diagonal`, so the two never read alike.
102
+ strokeLines(context, RISING(TILE / 2), 1.25);
103
+ }
104
+
105
+ return canvas;
106
+ }
107
+
108
+ /** Every pattern kind the status table uses, deduplicated. */
109
+ function usedPatternKinds(): PlanPatternKind[] {
110
+ const kinds = new Set<PlanPatternKind>();
111
+ for (const status of PLAN_STATUSES) kinds.add(PLAN_STATUS_ENCODING[status].pattern);
112
+ kinds.delete("none");
113
+ return [...kinds];
114
+ }
115
+
116
+ /**
117
+ * Register (and re-register) the status textures on a map.
118
+ *
119
+ * Re-registration is not optional: `map.setStyle` — which a theme swap performs —
120
+ * drops every image the style holds, and a `fill-pattern` naming a missing image
121
+ * renders nothing at all. So the images are (re)added on `styledata` as well as
122
+ * whenever the ink changes.
123
+ *
124
+ * @param ink The colour to draw the hatch in, already resolved from a token.
125
+ */
126
+ export function usePlanPatterns(map: MapLibreGL.Map | null, isLoaded: boolean, ink: string) {
127
+ useEffect(() => {
128
+ if (!map || !isLoaded) return;
129
+
130
+ const register = () => {
131
+ for (const kind of usedPatternKinds()) {
132
+ const tile = createPlanPatternTile(kind, ink);
133
+ if (!tile) continue;
134
+ const id = planPatternImageId(kind);
135
+ const image = {
136
+ width: tile.width,
137
+ height: tile.height,
138
+ data: tile.getContext("2d")?.getImageData(0, 0, tile.width, tile.height).data,
139
+ };
140
+ if (!image.data) continue;
141
+ try {
142
+ if (map.hasImage(id)) {
143
+ map.updateImage(id, image as unknown as ImageData);
144
+ } else {
145
+ map.addImage(id, image as unknown as ImageData, { pixelRatio: 2 });
146
+ }
147
+ } catch {
148
+ // style mid-reload; the `styledata` handler below re-runs this
149
+ }
150
+ }
151
+ };
152
+
153
+ register();
154
+ map.on("styledata", register);
155
+
156
+ return () => {
157
+ map.off("styledata", register);
158
+ };
159
+ }, [map, isLoaded, ink]);
160
+ }