panelui-native 0.22.6 → 0.28.1

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 (258) hide show
  1. package/README.md +1 -0
  2. package/lib/module/components/accordion/index.js +8 -8
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/alert/index.js +2 -2
  5. package/lib/module/components/alert/index.js.map +1 -1
  6. package/lib/module/components/area-chart/index.js +958 -0
  7. package/lib/module/components/area-chart/index.js.map +1 -0
  8. package/lib/module/components/attachment/index.js +2 -1
  9. package/lib/module/components/attachment/index.js.map +1 -1
  10. package/lib/module/components/avatar/index.js +4 -4
  11. package/lib/module/components/avatar/index.js.map +1 -1
  12. package/lib/module/components/badge/index.js +4 -4
  13. package/lib/module/components/badge/index.js.map +1 -1
  14. package/lib/module/components/bar-chart/index.js +994 -0
  15. package/lib/module/components/bar-chart/index.js.map +1 -0
  16. package/lib/module/components/bottom-sheet/index.js +213 -24
  17. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  18. package/lib/module/components/breadcrumb/index.js +275 -0
  19. package/lib/module/components/breadcrumb/index.js.map +1 -0
  20. package/lib/module/components/button/index.js +11 -6
  21. package/lib/module/components/button/index.js.map +1 -1
  22. package/lib/module/components/calendar/index.js +557 -0
  23. package/lib/module/components/calendar/index.js.map +1 -0
  24. package/lib/module/components/carousel/index.js +680 -0
  25. package/lib/module/components/carousel/index.js.map +1 -0
  26. package/lib/module/components/chip/index.js +6 -6
  27. package/lib/module/components/chip/index.js.map +1 -1
  28. package/lib/module/components/date-picker/index.js +158 -0
  29. package/lib/module/components/date-picker/index.js.map +1 -0
  30. package/lib/module/components/dialog/index.js +7 -2
  31. package/lib/module/components/dialog/index.js.map +1 -1
  32. package/lib/module/components/direction/index.js +15 -20
  33. package/lib/module/components/direction/index.js.map +1 -1
  34. package/lib/module/components/empty-state/index.js +2 -2
  35. package/lib/module/components/empty-state/index.js.map +1 -1
  36. package/lib/module/components/field/index.js +5 -5
  37. package/lib/module/components/field/index.js.map +1 -1
  38. package/lib/module/components/flow/index.js +3 -3
  39. package/lib/module/components/flow/index.js.map +1 -1
  40. package/lib/module/components/frame/index.js +22 -5
  41. package/lib/module/components/frame/index.js.map +1 -1
  42. package/lib/module/components/heatmap-chart/index.js +28 -8
  43. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  44. package/lib/module/components/input-group/index.js +5 -4
  45. package/lib/module/components/input-group/index.js.map +1 -1
  46. package/lib/module/components/item/index.js +9 -9
  47. package/lib/module/components/item/index.js.map +1 -1
  48. package/lib/module/components/label/index.js +4 -4
  49. package/lib/module/components/label/index.js.map +1 -1
  50. package/lib/module/components/line-chart/index.js +165 -184
  51. package/lib/module/components/line-chart/index.js.map +1 -1
  52. package/lib/module/components/loader/index.js +758 -0
  53. package/lib/module/components/loader/index.js.map +1 -0
  54. package/lib/module/components/map/basemap.js +265 -0
  55. package/lib/module/components/map/basemap.js.map +1 -0
  56. package/lib/module/components/map/index.js +850 -0
  57. package/lib/module/components/map/index.js.map +1 -0
  58. package/lib/module/components/map/maplibre.js +87 -0
  59. package/lib/module/components/map/maplibre.js.map +1 -0
  60. package/lib/module/components/marker/index.js +3 -3
  61. package/lib/module/components/marker/index.js.map +1 -1
  62. package/lib/module/components/message/index.js +15 -14
  63. package/lib/module/components/message/index.js.map +1 -1
  64. package/lib/module/components/message-scroller/index.js +5 -4
  65. package/lib/module/components/message-scroller/index.js.map +1 -1
  66. package/lib/module/components/otp-input/index.js +2 -2
  67. package/lib/module/components/popover/index.js +39 -7
  68. package/lib/module/components/popover/index.js.map +1 -1
  69. package/lib/module/components/progress/index.js +8 -2
  70. package/lib/module/components/progress/index.js.map +1 -1
  71. package/lib/module/components/radio-group/index.js +2 -2
  72. package/lib/module/components/radio-group/index.js.map +1 -1
  73. package/lib/module/components/ring-chart/index.js +423 -0
  74. package/lib/module/components/ring-chart/index.js.map +1 -0
  75. package/lib/module/components/section-rail/index.js +6 -6
  76. package/lib/module/components/section-rail/index.js.map +1 -1
  77. package/lib/module/components/select/index.js +10 -4
  78. package/lib/module/components/select/index.js.map +1 -1
  79. package/lib/module/components/separator/index.js +76 -22
  80. package/lib/module/components/separator/index.js.map +1 -1
  81. package/lib/module/components/shimmer/index.js +10 -1
  82. package/lib/module/components/shimmer/index.js.map +1 -1
  83. package/lib/module/components/slider/index.js +19 -5
  84. package/lib/module/components/slider/index.js.map +1 -1
  85. package/lib/module/components/steps/index.js +5 -5
  86. package/lib/module/components/steps/index.js.map +1 -1
  87. package/lib/module/components/switch/index.js +6 -1
  88. package/lib/module/components/switch/index.js.map +1 -1
  89. package/lib/module/components/tabs/index.js +7 -7
  90. package/lib/module/components/tabs/index.js.map +1 -1
  91. package/lib/module/components/timeline/index.js +4 -4
  92. package/lib/module/components/timeline/index.js.map +1 -1
  93. package/lib/module/components/toast/index.js +2 -2
  94. package/lib/module/components/toast/index.js.map +1 -1
  95. package/lib/module/components/toggle-button/index.js +5 -5
  96. package/lib/module/components/toggle-button/index.js.map +1 -1
  97. package/lib/module/components/tooltip/index.js +492 -0
  98. package/lib/module/components/tooltip/index.js.map +1 -0
  99. package/lib/module/components/typography/index.js +7 -2
  100. package/lib/module/components/typography/index.js.map +1 -1
  101. package/lib/module/hooks/index.js +1 -0
  102. package/lib/module/hooks/index.js.map +1 -1
  103. package/lib/module/hooks/use-back-handler.js +37 -0
  104. package/lib/module/hooks/use-back-handler.js.map +1 -0
  105. package/lib/module/hooks/use-direction.js +43 -0
  106. package/lib/module/hooks/use-direction.js.map +1 -0
  107. package/lib/module/icons/index.js +180 -2
  108. package/lib/module/icons/index.js.map +1 -1
  109. package/lib/module/index.js +12 -2
  110. package/lib/module/index.js.map +1 -1
  111. package/lib/module/primitives/keyboard-avoider.js +2 -1
  112. package/lib/module/primitives/keyboard-avoider.js.map +1 -1
  113. package/lib/module/primitives/text.js +55 -11
  114. package/lib/module/primitives/text.js.map +1 -1
  115. package/lib/module/utils/chart.js +279 -0
  116. package/lib/module/utils/chart.js.map +1 -0
  117. package/lib/module/utils/date.js +386 -0
  118. package/lib/module/utils/date.js.map +1 -0
  119. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  120. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  121. package/lib/typescript/src/components/area-chart/index.d.ts +237 -0
  122. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -0
  123. package/lib/typescript/src/components/attachment/index.d.ts.map +1 -1
  124. package/lib/typescript/src/components/bar-chart/index.d.ts +270 -0
  125. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -0
  126. package/lib/typescript/src/components/bottom-sheet/index.d.ts +75 -4
  127. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  128. package/lib/typescript/src/components/breadcrumb/index.d.ts +100 -0
  129. package/lib/typescript/src/components/breadcrumb/index.d.ts.map +1 -0
  130. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  131. package/lib/typescript/src/components/calendar/index.d.ts +108 -0
  132. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -0
  133. package/lib/typescript/src/components/carousel/index.d.ts +179 -0
  134. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -0
  135. package/lib/typescript/src/components/chip/index.d.ts.map +1 -1
  136. package/lib/typescript/src/components/date-picker/index.d.ts +91 -0
  137. package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -0
  138. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  139. package/lib/typescript/src/components/direction/index.d.ts +11 -10
  140. package/lib/typescript/src/components/direction/index.d.ts.map +1 -1
  141. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  142. package/lib/typescript/src/components/field/index.d.ts.map +1 -1
  143. package/lib/typescript/src/components/frame/index.d.ts +17 -0
  144. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  145. package/lib/typescript/src/components/heatmap-chart/index.d.ts +22 -3
  146. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  147. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  148. package/lib/typescript/src/components/item/index.d.ts.map +1 -1
  149. package/lib/typescript/src/components/label/index.d.ts.map +1 -1
  150. package/lib/typescript/src/components/line-chart/index.d.ts +42 -2
  151. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  152. package/lib/typescript/src/components/loader/index.d.ts +38 -0
  153. package/lib/typescript/src/components/loader/index.d.ts.map +1 -0
  154. package/lib/typescript/src/components/map/basemap.d.ts +76 -0
  155. package/lib/typescript/src/components/map/basemap.d.ts.map +1 -0
  156. package/lib/typescript/src/components/map/index.d.ts +367 -0
  157. package/lib/typescript/src/components/map/index.d.ts.map +1 -0
  158. package/lib/typescript/src/components/map/maplibre.d.ts +193 -0
  159. package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -0
  160. package/lib/typescript/src/components/marker/index.d.ts.map +1 -1
  161. package/lib/typescript/src/components/message/index.d.ts.map +1 -1
  162. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
  163. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  164. package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
  165. package/lib/typescript/src/components/ring-chart/index.d.ts +156 -0
  166. package/lib/typescript/src/components/ring-chart/index.d.ts.map +1 -0
  167. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  168. package/lib/typescript/src/components/separator/index.d.ts +36 -1
  169. package/lib/typescript/src/components/separator/index.d.ts.map +1 -1
  170. package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -1
  171. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  172. package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
  173. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  174. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  175. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  176. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  177. package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -1
  178. package/lib/typescript/src/components/tooltip/index.d.ts +125 -0
  179. package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -0
  180. package/lib/typescript/src/components/typography/index.d.ts +6 -0
  181. package/lib/typescript/src/components/typography/index.d.ts.map +1 -1
  182. package/lib/typescript/src/hooks/index.d.ts +1 -0
  183. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  184. package/lib/typescript/src/hooks/use-back-handler.d.ts +19 -0
  185. package/lib/typescript/src/hooks/use-back-handler.d.ts.map +1 -0
  186. package/lib/typescript/src/hooks/use-direction.d.ts +20 -0
  187. package/lib/typescript/src/hooks/use-direction.d.ts.map +1 -0
  188. package/lib/typescript/src/icons/index.d.ts +8 -0
  189. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  190. package/lib/typescript/src/index.d.ts +14 -4
  191. package/lib/typescript/src/index.d.ts.map +1 -1
  192. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
  193. package/lib/typescript/src/primitives/text.d.ts +20 -0
  194. package/lib/typescript/src/primitives/text.d.ts.map +1 -1
  195. package/lib/typescript/src/utils/chart.d.ts +96 -0
  196. package/lib/typescript/src/utils/chart.d.ts.map +1 -0
  197. package/lib/typescript/src/utils/date.d.ts +116 -0
  198. package/lib/typescript/src/utils/date.d.ts.map +1 -0
  199. package/package.json +9 -1
  200. package/src/components/accordion/index.tsx +8 -12
  201. package/src/components/alert/index.tsx +2 -2
  202. package/src/components/area-chart/index.tsx +1147 -0
  203. package/src/components/attachment/index.tsx +2 -2
  204. package/src/components/avatar/index.tsx +4 -4
  205. package/src/components/badge/index.tsx +6 -6
  206. package/src/components/bar-chart/index.tsx +1208 -0
  207. package/src/components/bottom-sheet/index.tsx +282 -21
  208. package/src/components/breadcrumb/index.tsx +363 -0
  209. package/src/components/button/index.tsx +12 -8
  210. package/src/components/calendar/index.tsx +703 -0
  211. package/src/components/carousel/index.tsx +788 -0
  212. package/src/components/chip/index.tsx +6 -8
  213. package/src/components/date-picker/index.tsx +237 -0
  214. package/src/components/dialog/index.tsx +7 -2
  215. package/src/components/direction/index.tsx +22 -22
  216. package/src/components/empty-state/index.tsx +2 -2
  217. package/src/components/field/index.tsx +3 -5
  218. package/src/components/flow/index.tsx +3 -3
  219. package/src/components/frame/index.tsx +22 -7
  220. package/src/components/heatmap-chart/index.tsx +58 -11
  221. package/src/components/input-group/index.tsx +5 -4
  222. package/src/components/item/index.tsx +9 -9
  223. package/src/components/label/index.tsx +2 -2
  224. package/src/components/line-chart/index.tsx +194 -223
  225. package/src/components/loader/index.tsx +812 -0
  226. package/src/components/map/basemap.ts +304 -0
  227. package/src/components/map/index.tsx +1116 -0
  228. package/src/components/map/maplibre.ts +201 -0
  229. package/src/components/marker/index.tsx +3 -3
  230. package/src/components/message/index.tsx +15 -14
  231. package/src/components/message-scroller/index.tsx +5 -4
  232. package/src/components/otp-input/index.tsx +2 -2
  233. package/src/components/popover/index.tsx +43 -7
  234. package/src/components/progress/index.tsx +10 -2
  235. package/src/components/radio-group/index.tsx +2 -2
  236. package/src/components/ring-chart/index.tsx +543 -0
  237. package/src/components/section-rail/index.tsx +6 -6
  238. package/src/components/select/index.tsx +10 -4
  239. package/src/components/separator/index.tsx +77 -19
  240. package/src/components/shimmer/index.tsx +10 -1
  241. package/src/components/slider/index.tsx +20 -5
  242. package/src/components/steps/index.tsx +5 -5
  243. package/src/components/switch/index.tsx +6 -1
  244. package/src/components/tabs/index.tsx +7 -9
  245. package/src/components/timeline/index.tsx +4 -4
  246. package/src/components/toast/index.tsx +2 -2
  247. package/src/components/toggle-button/index.tsx +5 -7
  248. package/src/components/tooltip/index.tsx +610 -0
  249. package/src/components/typography/index.tsx +7 -2
  250. package/src/hooks/index.ts +1 -0
  251. package/src/hooks/use-back-handler.ts +37 -0
  252. package/src/hooks/use-direction.ts +45 -0
  253. package/src/icons/index.tsx +111 -0
  254. package/src/index.ts +129 -0
  255. package/src/primitives/keyboard-avoider.tsx +2 -1
  256. package/src/primitives/text.tsx +53 -8
  257. package/src/utils/chart.ts +355 -0
  258. package/src/utils/date.ts +446 -0
@@ -0,0 +1,1116 @@
1
+ /**
2
+ * Map — a themed vector map.
3
+ *
4
+ * Everything visible is built from theme tokens rather than shipped as a
5
+ * finished style, so the map belongs to whatever theme is active instead of
6
+ * being the one rectangle on the screen that stayed grey. See `basemap.ts` for
7
+ * how the style is assembled; the short version is that the tiles are bought
8
+ * and the colours are ours.
9
+ *
10
+ * ```tsx
11
+ * <Map center={[-0.12, 51.5]} zoom={11}>
12
+ * <Map.Marker lngLat={[-0.12, 51.5]}>
13
+ * <Map.Popup>
14
+ * <Text weight="medium">Charing Cross</Text>
15
+ * </Map.Popup>
16
+ * </Map.Marker>
17
+ * <Map.Controls locate />
18
+ * </Map>
19
+ * ```
20
+ *
21
+ * The renderer is native and optional, so `Map` has a state no other component
22
+ * here does: it may be unable to draw at all. It says so in place rather than
23
+ * throwing, because the usual way to reach that state is running in a client
24
+ * that cannot load native modules, and a screen explaining the build you need
25
+ * is more use than a stack trace.
26
+ *
27
+ * `Map.Popup` reads its position from the marker it sits inside, the way a
28
+ * frame's title reads its weight from its slot. Given a `lngLat` of its own it
29
+ * anchors to that coordinate instead, so the same name covers both the popup
30
+ * attached to a pin and the one floating over a place with no pin at all.
31
+ */
32
+ import {
33
+ Children,
34
+ createContext,
35
+ forwardRef,
36
+ isValidElement,
37
+ useCallback,
38
+ useContext,
39
+ useImperativeHandle,
40
+ useMemo,
41
+ useRef,
42
+ useState,
43
+ type ReactNode,
44
+ } from 'react';
45
+ import { Pressable, View, type ViewProps } from 'react-native';
46
+ import { useCSSVariable } from 'uniwind';
47
+ import { tv } from 'tailwind-variants';
48
+ import { CompassIcon, CrosshairIcon, MinusIcon, PlusIcon } from '../../icons';
49
+ import { Text, textChildren } from '../../primitives/text';
50
+ import { cn } from '../../utils/cn';
51
+ import {
52
+ buildBasemapStyle,
53
+ CARTO_SOURCE,
54
+ type BasemapSource,
55
+ type BasemapTokens,
56
+ } from './basemap';
57
+ import {
58
+ hasMapLibre,
59
+ MapLibre,
60
+ type CameraRef,
61
+ type LngLat,
62
+ type LngLatBounds,
63
+ type MapRef,
64
+ type StyleSpecification,
65
+ type ViewState,
66
+ } from './maplibre';
67
+
68
+ export { hasMapLibre, CARTO_SOURCE };
69
+ export type { BasemapSource, BasemapTokens, LngLat, LngLatBounds, ViewState };
70
+
71
+ const mapVariants = tv({
72
+ slots: {
73
+ root: 'flex-1 overflow-hidden',
74
+ controls: 'absolute gap-2',
75
+ // A control group is one surface split by hairlines rather than separate
76
+ // buttons: at 36pt a gap between them reads as damage, not as spacing.
77
+ group: 'overflow-hidden rounded-xl border border-border bg-card shadow-sm',
78
+ control: 'h-9 w-9 items-center justify-center active:bg-muted',
79
+ popup: 'rounded-xl border border-border bg-popover px-3 py-2 shadow-lg',
80
+ pin: 'h-3.5 w-3.5 rounded-full border-2 border-background bg-primary shadow-md',
81
+ },
82
+ variants: {
83
+ position: {
84
+ 'top-left': { controls: 'left-3 top-3' },
85
+ 'top-right': { controls: 'right-3 top-3' },
86
+ 'bottom-left': { controls: 'bottom-3 left-3' },
87
+ 'bottom-right': { controls: 'bottom-3 right-3' },
88
+ },
89
+ },
90
+ defaultVariants: { position: 'bottom-right' },
91
+ });
92
+
93
+ export type MapControlsPosition =
94
+ | 'top-left'
95
+ | 'top-right'
96
+ | 'bottom-left'
97
+ | 'bottom-right';
98
+
99
+ /** Reads a theme token, falling back when it resolves to something unusable. */
100
+ function useToken(name: string, fallback: string): string {
101
+ const value = useCSSVariable(name);
102
+ return typeof value === 'string' && value.length > 0 ? value : fallback;
103
+ }
104
+
105
+ interface MapContextValue {
106
+ mapRef: React.RefObject<MapRef | null>;
107
+ cameraRef: React.RefObject<CameraRef | null>;
108
+ /** The style has loaded and the map is drawing. */
109
+ ready: boolean;
110
+ }
111
+
112
+ const MapContext = createContext<MapContextValue | null>(null);
113
+
114
+ /**
115
+ * The map's camera and view state, from inside it.
116
+ *
117
+ * For a control, an overlay, or anything that has to move the map in response
118
+ * to something that is not a gesture on it.
119
+ */
120
+ export function useMap(): MapContextValue {
121
+ const context = useContext(MapContext);
122
+ if (!context) throw new Error('useMap must be used inside a <Map>.');
123
+ return context;
124
+ }
125
+
126
+ /** Set by `Map.Marker`, so a popup inside one knows where it is. */
127
+ const MarkerContext = createContext<LngLat | null>(null);
128
+
129
+ export interface MapHandle {
130
+ /** Move the camera, animating unless `duration` is 0. */
131
+ flyTo(options: { center: LngLat; zoom?: number; duration?: number }): void;
132
+ /** Frame a bounding box, leaving `padding` points around it. */
133
+ fitBounds(bounds: LngLatBounds, padding?: number): void;
134
+ /** Where the map is looking right now. */
135
+ getViewState(): Promise<ViewState | null>;
136
+ }
137
+
138
+ export interface MapProps extends Omit<ViewProps, 'children'> {
139
+ children?: ReactNode;
140
+ /** Initial centre, `[longitude, latitude]`. */
141
+ center?: LngLat;
142
+ /** Initial zoom. 0 is the whole world; 18 is a building. */
143
+ zoom?: number;
144
+ /** Initial bearing in degrees, clockwise from north. */
145
+ bearing?: number;
146
+ /** Initial tilt in degrees. 0 looks straight down. */
147
+ pitch?: number;
148
+ /**
149
+ * Frame these bounds instead of centring — `[west, south, east, north]`.
150
+ * Wins over `center` and `zoom` when both are given.
151
+ */
152
+ bounds?: LngLatBounds;
153
+ /**
154
+ * Drop the basemap and keep only the ground colour. For data that carries
155
+ * its own geography — a choropleth, an arc diagram — where streets
156
+ * underneath are noise rather than context.
157
+ */
158
+ blank?: boolean;
159
+ /**
160
+ * Where the vector tiles come from. Defaults to CARTO, which is free for
161
+ * non-commercial use and licensed for everything else.
162
+ */
163
+ source?: BasemapSource;
164
+ /**
165
+ * Use this style wholesale instead of building one from tokens. The escape
166
+ * hatch for a map that has to match something outside the app.
167
+ */
168
+ mapStyle?: string | StyleSpecification;
169
+ /** Let the map rotate and tilt. Off by default — most maps only pan and zoom. */
170
+ rotatable?: boolean;
171
+ /** Turn off panning and zooming, for a map that is an illustration. */
172
+ interactive?: boolean;
173
+ /** Fires continuously while the map moves. */
174
+ onViewStateChange?: (state: ViewState) => void;
175
+ /** Fires when the map is pressed somewhere that is not a feature. */
176
+ onPress?: (lngLat: LngLat) => void;
177
+ /** Fires once the style has loaded and the first frame is drawn. */
178
+ onReady?: () => void;
179
+ }
180
+
181
+ /**
182
+ * Shown when the renderer is missing. Deliberately explicit about the cause:
183
+ * "map unavailable" sends people looking at their network, and the actual
184
+ * problem is almost always a build that never included the native module.
185
+ */
186
+ function MapUnavailable({ className, ...props }: ViewProps) {
187
+ return (
188
+ <View
189
+ className={cn(
190
+ 'flex-1 items-center justify-center gap-1 rounded-2xl border border-dashed border-border bg-muted/25 p-6',
191
+ className
192
+ )}
193
+ accessibilityRole="alert"
194
+ {...props}
195
+ >
196
+ <Text size="sm" weight="medium">
197
+ Map renderer not installed
198
+ </Text>
199
+ <Text size="xs" muted className="text-center">
200
+ Map needs a development build. Install
201
+ @maplibre/maplibre-react-native, add its config plugin, and rebuild —
202
+ it cannot run in a client that loads no native modules.
203
+ </Text>
204
+ </View>
205
+ );
206
+ }
207
+
208
+ const MapRoot = forwardRef<MapHandle, MapProps>(function MapRoot(
209
+ {
210
+ children,
211
+ className,
212
+ center,
213
+ zoom = 2,
214
+ bearing,
215
+ pitch,
216
+ bounds,
217
+ blank = false,
218
+ source = CARTO_SOURCE,
219
+ mapStyle,
220
+ rotatable = false,
221
+ interactive = true,
222
+ onViewStateChange,
223
+ onPress,
224
+ onReady,
225
+ ...props
226
+ },
227
+ ref
228
+ ) {
229
+ const slots = mapVariants();
230
+ const mapRef = useRef<MapRef | null>(null);
231
+ const cameraRef = useRef<CameraRef | null>(null);
232
+ const [ready, setReady] = useState(false);
233
+
234
+ // Every colour the style needs, resolved from the active theme. Hooks run
235
+ // unconditionally, so they sit above the renderer check rather than inside it.
236
+ const tokens: BasemapTokens = {
237
+ background: useToken('--color-background', '#ffffff'),
238
+ water: useToken('--color-muted', 'rgba(0,0,0,0.06)'),
239
+ land: useToken('--color-surface-secondary', 'rgba(0,0,0,0.04)'),
240
+ building: useToken('--color-surface-tertiary', 'rgba(0,0,0,0.08)'),
241
+ line: useToken('--color-border', 'rgba(0,0,0,0.1)'),
242
+ label: useToken('--color-muted-foreground', '#686868'),
243
+ labelHalo: useToken('--color-background', '#ffffff'),
244
+ };
245
+
246
+ const style = useMemo(
247
+ () => mapStyle ?? buildBasemapStyle(tokens, { blank, source }),
248
+ // The token object is rebuilt every render, so depend on its values.
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ [
251
+ mapStyle,
252
+ blank,
253
+ source,
254
+ tokens.background,
255
+ tokens.water,
256
+ tokens.land,
257
+ tokens.building,
258
+ tokens.line,
259
+ tokens.label,
260
+ tokens.labelHalo,
261
+ ]
262
+ );
263
+
264
+ useImperativeHandle(
265
+ ref,
266
+ () => ({
267
+ flyTo: ({ center: to, zoom: z, duration = 900 }) =>
268
+ cameraRef.current?.flyTo({ center: to, zoom: z, duration }),
269
+ fitBounds: (box, padding = 48) =>
270
+ cameraRef.current?.fitBounds(box, {
271
+ padding: {
272
+ top: padding,
273
+ right: padding,
274
+ bottom: padding,
275
+ left: padding,
276
+ },
277
+ duration: 900,
278
+ }),
279
+ getViewState: async () => (await mapRef.current?.getViewState()) ?? null,
280
+ }),
281
+ []
282
+ );
283
+
284
+ const context = useMemo(() => ({ mapRef, cameraRef, ready }), [ready]);
285
+
286
+ if (!hasMapLibre || !MapLibre) {
287
+ return <MapUnavailable className={className} {...props} />;
288
+ }
289
+
290
+ const { Map: MapLibreMap, Camera } = MapLibre;
291
+
292
+ return (
293
+ <View className={slots.root({ className })} {...props}>
294
+ <MapContext.Provider value={context}>
295
+ <MapLibreMap
296
+ ref={mapRef}
297
+ mapStyle={style}
298
+ style={{ flex: 1 }}
299
+ // The library's own ornaments are turned off across the board:
300
+ // they are drawn by the renderer in its own style and cannot be
301
+ // themed, so `Map.Controls` replaces them with views that can.
302
+ logo={false}
303
+ compass={false}
304
+ attribution={false}
305
+ scaleBar={false}
306
+ dragPan={interactive}
307
+ touchZoom={interactive}
308
+ touchRotate={rotatable}
309
+ touchPitch={rotatable}
310
+ onDidFinishLoadingMap={() => {
311
+ setReady(true);
312
+ onReady?.();
313
+ }}
314
+ onRegionDidChange={
315
+ onViewStateChange
316
+ ? (event) => onViewStateChange(event.nativeEvent)
317
+ : undefined
318
+ }
319
+ onPress={
320
+ onPress
321
+ ? (event) => onPress(event.nativeEvent.coordinates)
322
+ : undefined
323
+ }
324
+ >
325
+ <Camera
326
+ ref={cameraRef}
327
+ // The opening position, and only that: this is applied on the
328
+ // first frame and never again, so a re-render cannot pull the map
329
+ // back to its starting point mid-gesture. Everything afterwards
330
+ // goes through the ref.
331
+ initialViewState={
332
+ bounds
333
+ ? { bounds }
334
+ : { center: center ?? [0, 20], zoom, bearing, pitch }
335
+ }
336
+ />
337
+ {textChildren(children)}
338
+ </MapLibreMap>
339
+ </MapContext.Provider>
340
+ </View>
341
+ );
342
+ });
343
+ MapRoot.displayName = 'Map';
344
+
345
+ export interface MapMarkerProps {
346
+ /** Where the marker sits, `[longitude, latitude]`. */
347
+ lngLat: LngLat;
348
+ /**
349
+ * Which part of the marker sits on the coordinate. A pin drawn above its
350
+ * point wants `bottom`; a dot centred on it wants the default.
351
+ */
352
+ anchor?: 'center' | 'top' | 'bottom' | 'left' | 'right';
353
+ /** Pressing the marker. Adds a button role when given. */
354
+ onPress?: () => void;
355
+ className?: string;
356
+ /** Defaults to a dot. Anything else replaces it. */
357
+ children?: ReactNode;
358
+ }
359
+
360
+ /**
361
+ * A point on the map, drawn as React views rather than as a style layer — so
362
+ * it can be anything the rest of the library can draw. For hundreds of points
363
+ * use `Map.Cluster` instead: a view per marker stops being affordable long
364
+ * before the map stops being readable.
365
+ */
366
+ function MapMarker({
367
+ lngLat,
368
+ anchor = 'center',
369
+ onPress,
370
+ className,
371
+ children,
372
+ }: MapMarkerProps) {
373
+ const slots = mapVariants();
374
+ const [open, setOpen] = useState(false);
375
+
376
+ /*
377
+ * Popups are pulled out of the marker's own children rather than drawn
378
+ * inside it. Left in place a popup would be laid out under the pin, which
379
+ * costs twice: it would be permanently visible instead of opening on press,
380
+ * and — because the marker is anchored by the centre of its box — a card
381
+ * below the pin would drag that centre downward and leave the pin sitting
382
+ * well above the coordinate it is meant to mark.
383
+ */
384
+ const popups: ReactNode[] = [];
385
+ const labels: ReactNode[] = [];
386
+ const content: ReactNode[] = [];
387
+ Children.forEach(children, (child) => {
388
+ if (!isValidElement(child)) {
389
+ content.push(child);
390
+ } else if (child.type === MapPopup) {
391
+ popups.push(child);
392
+ } else if (child.type === MapLabel) {
393
+ labels.push(child);
394
+ } else {
395
+ content.push(child);
396
+ }
397
+ });
398
+
399
+ if (!MapLibre) return null;
400
+ const { Marker } = MapLibre;
401
+
402
+ /*
403
+ * Labels are counted separately from the rest of the children because they
404
+ * are absolutely positioned, and so contribute nothing to the marker's box.
405
+ * A marker whose only child is a label would otherwise measure 0×0 — and the
406
+ * renderer cannot place a zero-sized annotation, so it parks it in the
407
+ * top-left corner of the map instead of on its coordinate. Falling back to
408
+ * the default pin keeps the box real, which is also the right drawing: a
409
+ * label with nothing to label is a caption floating on its own.
410
+ */
411
+ const body = (
412
+ <View className={cn('items-center', className)}>
413
+ {content.length > 0 ? content : <View className={slots.pin()} />}
414
+ {labels}
415
+ </View>
416
+ );
417
+
418
+ const pressable = onPress || popups.length > 0;
419
+
420
+ return (
421
+ <MarkerContext.Provider value={lngLat}>
422
+ <Marker lngLat={lngLat} anchor={anchor}>
423
+ {pressable ? (
424
+ <Pressable
425
+ accessibilityRole="button"
426
+ onPress={() => {
427
+ if (popups.length > 0) setOpen((was) => !was);
428
+ onPress?.();
429
+ }}
430
+ >
431
+ {body}
432
+ </Pressable>
433
+ ) : (
434
+ body
435
+ )}
436
+ </Marker>
437
+ {open ? popups : null}
438
+ </MarkerContext.Provider>
439
+ );
440
+ }
441
+ MapMarker.displayName = 'Map.Marker';
442
+
443
+ export interface MapLabelProps {
444
+ children?: ReactNode;
445
+ className?: string;
446
+ /** Which side of the marker the label sits on. */
447
+ side?: 'top' | 'bottom';
448
+ }
449
+
450
+ /**
451
+ * A caption pinned to a marker. Always visible, unlike a popup — for the
452
+ * handful of places whose names are the point of the map.
453
+ *
454
+ * It is taken out of the marker's layout flow, which matters more than it
455
+ * sounds: a marker sits on its coordinate by the centre of its box, so a label
456
+ * in flow underneath the pin would pull that centre down and lift every pin
457
+ * off the place it marks. Absolute keeps the box the size of the pin.
458
+ *
459
+ * The overhang on each side is what lets a long name stay centred on the pin
460
+ * without widening the marker — a name is usually far wider than the dot it
461
+ * belongs to, and a box sized to the name would be anchored by the name.
462
+ */
463
+ function MapLabel({ children, className, side = 'bottom' }: MapLabelProps) {
464
+ return (
465
+ <View
466
+ pointerEvents="none"
467
+ className={cn(
468
+ 'absolute items-center',
469
+ side === 'top' ? 'bottom-full mb-1' : 'top-full mt-1'
470
+ )}
471
+ style={{ left: -120, right: -120 }}
472
+ >
473
+ <View className={cn('rounded-md bg-background/85 px-1.5 py-0.5', className)}>
474
+ {textChildren(children, (text) => (
475
+ <Text size="xs" weight="medium" numberOfLines={1}>
476
+ {text}
477
+ </Text>
478
+ ))}
479
+ </View>
480
+ </View>
481
+ );
482
+ }
483
+ MapLabel.displayName = 'Map.Label';
484
+
485
+ export interface MapPopupProps {
486
+ children?: ReactNode;
487
+ className?: string;
488
+ /** Heading above the content. Strings are wrapped for you. */
489
+ title?: string;
490
+ /**
491
+ * Anchor to this coordinate instead of to an enclosing marker. Required when
492
+ * the popup is not inside one.
493
+ */
494
+ lngLat?: LngLat;
495
+ }
496
+
497
+ /**
498
+ * A card anchored to a point.
499
+ *
500
+ * Inside a `Map.Marker` it takes that marker's coordinate and opens when the
501
+ * marker is pressed. Given a `lngLat` it stands alone at that coordinate — the
502
+ * same component either way, because the difference is where it is anchored
503
+ * rather than what it is.
504
+ *
505
+ * Either way it is its own annotation rather than something drawn inside the
506
+ * marker, which is what keeps a card from dragging the pin off its coordinate.
507
+ * Anchored by its bottom edge, so it floats above the point it describes
508
+ * instead of covering it.
509
+ */
510
+ function MapPopup({ children, className, title, lngLat }: MapPopupProps) {
511
+ const slots = mapVariants();
512
+ const markerLngLat = useContext(MarkerContext);
513
+ const coordinate = lngLat ?? markerLngLat;
514
+
515
+ if (!MapLibre || !coordinate) return null;
516
+ const { Marker } = MapLibre;
517
+
518
+ return (
519
+ <Marker lngLat={coordinate} anchor="bottom">
520
+ <View className={cn('mb-2 items-center', slots.popup({ className }))}>
521
+ {title ? (
522
+ <Text size="sm" weight="medium">
523
+ {title}
524
+ </Text>
525
+ ) : null}
526
+ {textChildren(children)}
527
+ </View>
528
+ </Marker>
529
+ );
530
+ }
531
+ MapPopup.displayName = 'Map.Popup';
532
+
533
+ export interface MapControlsProps {
534
+ /** Which corner the stack sits in. */
535
+ position?: MapControlsPosition;
536
+ /** Zoom in and out. On by default — it is the one control a map always needs. */
537
+ zoom?: boolean;
538
+ /** Recentre on the device's location. Needs a location permission. */
539
+ locate?: boolean;
540
+ /** Reset bearing and pitch to north and flat. */
541
+ compass?: boolean;
542
+ className?: string;
543
+ /** Called with the located coordinate, so a caller can react to it. */
544
+ onLocate?: (lngLat: LngLat) => void;
545
+ }
546
+
547
+ function ControlButton({
548
+ label,
549
+ onPress,
550
+ children,
551
+ }: {
552
+ label: string;
553
+ onPress: () => void;
554
+ children: ReactNode;
555
+ }) {
556
+ const slots = mapVariants();
557
+ return (
558
+ <Pressable
559
+ accessibilityRole="button"
560
+ accessibilityLabel={label}
561
+ className={slots.control()}
562
+ onPress={onPress}
563
+ >
564
+ {textChildren(children)}
565
+ </Pressable>
566
+ );
567
+ }
568
+
569
+ /**
570
+ * The map's own chrome, as themed views.
571
+ *
572
+ * The renderer draws its own zoom and compass ornaments, but in its style
573
+ * rather than the app's, and they cannot be recoloured — so they are turned
574
+ * off in `Map` and replaced here by buttons built from the same tokens as
575
+ * every other control in the library.
576
+ */
577
+ function MapControls({
578
+ position = 'bottom-right',
579
+ zoom = true,
580
+ locate = false,
581
+ compass = false,
582
+ className,
583
+ onLocate,
584
+ }: MapControlsProps) {
585
+ const slots = mapVariants({ position });
586
+ const { mapRef, cameraRef } = useMap();
587
+
588
+ // Read the live zoom rather than tracking it in state: the map is moved by
589
+ // gestures too, and a counter kept alongside it drifts on the first pinch.
590
+ const nudgeZoom = useCallback(
591
+ async (delta: number) => {
592
+ const map = mapRef.current;
593
+ const camera = cameraRef.current;
594
+ if (!map || !camera) return;
595
+ const [center, current] = await Promise.all([
596
+ map.getCenter(),
597
+ map.getZoom(),
598
+ ]);
599
+ camera.easeTo({
600
+ center,
601
+ zoom: Math.min(22, Math.max(0, current + delta)),
602
+ duration: 250,
603
+ });
604
+ },
605
+ [mapRef, cameraRef]
606
+ );
607
+
608
+ const handleLocate = useCallback(async () => {
609
+ let position$: { coords: { longitude: number; latitude: number } } | null =
610
+ null;
611
+ try {
612
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
613
+ const location = require('expo-location');
614
+ const granted = await location.requestForegroundPermissionsAsync();
615
+ if (!granted?.granted) return;
616
+ position$ = await location.getCurrentPositionAsync({});
617
+ } catch {
618
+ return;
619
+ }
620
+ if (!position$) return;
621
+ const lngLat: LngLat = [
622
+ position$.coords.longitude,
623
+ position$.coords.latitude,
624
+ ];
625
+ cameraRef.current?.flyTo({ center: lngLat, zoom: 14, duration: 900 });
626
+ onLocate?.(lngLat);
627
+ }, [cameraRef, onLocate]);
628
+
629
+ const resetNorth = useCallback(async () => {
630
+ const center = await mapRef.current?.getCenter();
631
+ if (!center) return;
632
+ cameraRef.current?.easeTo({ center, bearing: 0, pitch: 0, duration: 300 });
633
+ }, [mapRef, cameraRef]);
634
+
635
+ return (
636
+ <View className={slots.controls({ className })}>
637
+ {zoom ? (
638
+ <View className={slots.group()}>
639
+ <ControlButton label="Zoom in" onPress={() => nudgeZoom(1)}>
640
+ <PlusIcon size={16} />
641
+ </ControlButton>
642
+ <View className="h-px bg-border" />
643
+ <ControlButton label="Zoom out" onPress={() => nudgeZoom(-1)}>
644
+ <MinusIcon size={16} />
645
+ </ControlButton>
646
+ </View>
647
+ ) : null}
648
+ {compass ? (
649
+ <View className={slots.group()}>
650
+ <ControlButton label="Face north" onPress={resetNorth}>
651
+ <CompassIcon size={16} />
652
+ </ControlButton>
653
+ </View>
654
+ ) : null}
655
+ {locate ? (
656
+ <View className={slots.group()}>
657
+ <ControlButton label="Show my location" onPress={handleLocate}>
658
+ <CrosshairIcon size={16} />
659
+ </ControlButton>
660
+ </View>
661
+ ) : null}
662
+ </View>
663
+ );
664
+ }
665
+ MapControls.displayName = 'Map.Controls';
666
+
667
+ /** Turns a token into a colour a style layer can use. */
668
+ function useLineColor(color?: string) {
669
+ const fallback = useToken('--color-primary', '#262626');
670
+ return color ?? fallback;
671
+ }
672
+
673
+ export interface MapRouteProps {
674
+ /** The path, in order. */
675
+ coordinates: LngLat[];
676
+ /** Defaults to the primary token. */
677
+ color?: string;
678
+ /** Line thickness in points. */
679
+ width?: number;
680
+ /** Draw it dashed — for a leg that is planned rather than travelled. */
681
+ dashed?: boolean;
682
+ /** 0 is invisible, 1 is solid. */
683
+ opacity?: number;
684
+ id?: string;
685
+ }
686
+
687
+ /**
688
+ * A path across the map, drawn as a style layer rather than as views — so its
689
+ * cost does not grow with the number of points in it.
690
+ */
691
+ function MapRoute({
692
+ coordinates,
693
+ color,
694
+ width = 3,
695
+ dashed = false,
696
+ opacity = 1,
697
+ id = 'route',
698
+ }: MapRouteProps) {
699
+ const stroke = useLineColor(color);
700
+ const data = useMemo(
701
+ () => ({
702
+ type: 'Feature' as const,
703
+ properties: {},
704
+ geometry: { type: 'LineString' as const, coordinates },
705
+ }),
706
+ [coordinates]
707
+ );
708
+
709
+ if (!MapLibre) return null;
710
+ const { GeoJSONSource, Layer } = MapLibre;
711
+
712
+ return (
713
+ <GeoJSONSource id={`${id}-source`} data={data} lineMetrics>
714
+ <Layer
715
+ id={id}
716
+ type="line"
717
+ style={{
718
+ lineColor: stroke,
719
+ lineWidth: width,
720
+ lineOpacity: opacity,
721
+ lineCap: 'round',
722
+ lineJoin: 'round',
723
+ ...(dashed ? { lineDasharray: [2, 1.5] } : {}),
724
+ }}
725
+ />
726
+ </GeoJSONSource>
727
+ );
728
+ }
729
+ MapRoute.displayName = 'Map.Route';
730
+
731
+ export interface MapArcProps {
732
+ /** Where the arc starts. */
733
+ from: LngLat;
734
+ /** Where it ends. */
735
+ to: LngLat;
736
+ /** How far it bows. 0 is a straight line; 0.2 is the default lift. */
737
+ curvature?: number;
738
+ color?: string;
739
+ width?: number;
740
+ opacity?: number;
741
+ id?: string;
742
+ }
743
+
744
+ /**
745
+ * A curved connection between two points.
746
+ *
747
+ * The curve is not geography — a great circle between two cities is not bowed
748
+ * on a flat projection. It is there so that two arcs sharing an endpoint stay
749
+ * tellable apart, which a bundle of straight lines through one city does not.
750
+ */
751
+ function MapArc({
752
+ from,
753
+ to,
754
+ curvature = 0.2,
755
+ color,
756
+ width = 2,
757
+ opacity = 0.9,
758
+ id = 'arc',
759
+ }: MapArcProps) {
760
+ const stroke = useLineColor(color);
761
+
762
+ const data = useMemo(() => {
763
+ // A quadratic Bézier whose control point is pushed off the midpoint
764
+ // perpendicular to the line, so the bow scales with the distance rather
765
+ // than being a fixed lift that vanishes on long hops.
766
+ const [x1, y1] = from;
767
+ const [x2, y2] = to;
768
+ const midX = (x1 + x2) / 2;
769
+ const midY = (y1 + y2) / 2;
770
+ const controlX = midX - (y2 - y1) * curvature;
771
+ const controlY = midY + (x2 - x1) * curvature;
772
+
773
+ const SEGMENTS = 64;
774
+ const coordinates: LngLat[] = [];
775
+ for (let i = 0; i <= SEGMENTS; i += 1) {
776
+ const t = i / SEGMENTS;
777
+ const inv = 1 - t;
778
+ coordinates.push([
779
+ inv * inv * x1 + 2 * inv * t * controlX + t * t * x2,
780
+ inv * inv * y1 + 2 * inv * t * controlY + t * t * y2,
781
+ ]);
782
+ }
783
+ return {
784
+ type: 'Feature' as const,
785
+ properties: {},
786
+ geometry: { type: 'LineString' as const, coordinates },
787
+ };
788
+ }, [from, to, curvature]);
789
+
790
+ if (!MapLibre) return null;
791
+ const { GeoJSONSource, Layer } = MapLibre;
792
+
793
+ return (
794
+ <GeoJSONSource id={`${id}-source`} data={data} lineMetrics>
795
+ <Layer
796
+ id={id}
797
+ type="line"
798
+ style={{
799
+ lineColor: stroke,
800
+ lineWidth: width,
801
+ lineOpacity: opacity,
802
+ lineCap: 'round',
803
+ }}
804
+ />
805
+ </GeoJSONSource>
806
+ );
807
+ }
808
+ MapArc.displayName = 'Map.Arc';
809
+
810
+ export interface MapGeoJSONProps {
811
+ /** A Feature, FeatureCollection, or the URL of one. */
812
+ data: unknown;
813
+ /** Fill colour for polygons. A style expression works here too. */
814
+ fill?: string | unknown[];
815
+ /** Outline colour. Defaults to the border token. */
816
+ stroke?: string | unknown[];
817
+ /** Outline thickness. */
818
+ strokeWidth?: number;
819
+ /** 0 is invisible, 1 is solid. */
820
+ fillOpacity?: number;
821
+ /** Fires with the pressed feature. */
822
+ onPress?: (feature: unknown) => void;
823
+ id?: string;
824
+ }
825
+
826
+ /**
827
+ * Arbitrary geography as a themed layer — the component behind a choropleth,
828
+ * a coverage area, or any other shape that comes from data rather than from
829
+ * the basemap.
830
+ *
831
+ * `fill` takes a style expression as well as a colour, which is what makes a
832
+ * choropleth one layer instead of one layer per bucket.
833
+ */
834
+ function MapGeoJSON({
835
+ data,
836
+ fill,
837
+ stroke,
838
+ strokeWidth = 1,
839
+ fillOpacity = 0.7,
840
+ onPress,
841
+ id = 'geojson',
842
+ }: MapGeoJSONProps) {
843
+ const defaultFill = useToken('--color-primary', '#262626');
844
+ const defaultStroke = useToken('--color-border', 'rgba(0,0,0,0.1)');
845
+
846
+ if (!MapLibre) return null;
847
+ const { GeoJSONSource, Layer } = MapLibre;
848
+
849
+ return (
850
+ <GeoJSONSource
851
+ id={`${id}-source`}
852
+ data={data}
853
+ onPress={
854
+ onPress
855
+ ? (event) => onPress(event.nativeEvent.features?.[0])
856
+ : undefined
857
+ }
858
+ >
859
+ <Layer
860
+ id={`${id}-fill`}
861
+ type="fill"
862
+ style={{
863
+ fillColor: fill ?? defaultFill,
864
+ fillOpacity,
865
+ }}
866
+ />
867
+ <Layer
868
+ id={`${id}-line`}
869
+ type="line"
870
+ style={{
871
+ lineColor: stroke ?? defaultStroke,
872
+ lineWidth: strokeWidth,
873
+ }}
874
+ />
875
+ </GeoJSONSource>
876
+ );
877
+ }
878
+ MapGeoJSON.displayName = 'Map.GeoJSON';
879
+
880
+ export interface MapClusterProps {
881
+ /** Point features to cluster. */
882
+ data: unknown;
883
+ /** Defaults to the primary token. */
884
+ color?: string;
885
+ /** Text colour inside a cluster bubble. */
886
+ textColor?: string;
887
+ /** How close two points have to be, in points, to merge. */
888
+ radius?: number;
889
+ /** Above this zoom every point stands alone. */
890
+ maxZoom?: number;
891
+ /** Fires with the pressed cluster or point. */
892
+ onPress?: (feature: unknown) => void;
893
+ id?: string;
894
+ }
895
+
896
+ /**
897
+ * Dense points, merged as they get too close to tell apart.
898
+ *
899
+ * This is the layer to reach for past a few dozen points: `Map.Marker` mounts
900
+ * a React view each, which a thousand points cannot afford, and a thousand
901
+ * overlapping pins would be unreadable even if it could.
902
+ */
903
+ function MapCluster({
904
+ data,
905
+ color,
906
+ textColor,
907
+ radius = 50,
908
+ maxZoom = 14,
909
+ onPress,
910
+ id = 'cluster',
911
+ }: MapClusterProps) {
912
+ const fill = useToken('--color-primary', '#262626');
913
+ const onFill = useToken('--color-primary-foreground', '#fafafa');
914
+ const bubble = color ?? fill;
915
+ const ink = textColor ?? onFill;
916
+
917
+ if (!MapLibre) return null;
918
+ const { GeoJSONSource, Layer } = MapLibre;
919
+
920
+ return (
921
+ <GeoJSONSource
922
+ id={`${id}-source`}
923
+ data={data}
924
+ cluster
925
+ clusterRadius={radius}
926
+ clusterMaxZoom={maxZoom}
927
+ onPress={
928
+ onPress
929
+ ? (event) => onPress(event.nativeEvent.features?.[0])
930
+ : undefined
931
+ }
932
+ >
933
+ {/* Bubbles grow in steps rather than continuously: the point is to read
934
+ "more than that one", not to estimate a count from an area. */}
935
+ <Layer
936
+ id={`${id}-bubble`}
937
+ type="circle"
938
+ filter={['has', 'point_count']}
939
+ style={{
940
+ circleColor: bubble,
941
+ circleRadius: ['step', ['get', 'point_count'], 16, 25, 22, 100, 28],
942
+ circleStrokeWidth: 2,
943
+ circleStrokeColor: ink,
944
+ circleStrokeOpacity: 0.25,
945
+ }}
946
+ />
947
+ <Layer
948
+ id={`${id}-count`}
949
+ type="symbol"
950
+ filter={['has', 'point_count']}
951
+ style={{
952
+ textField: ['get', 'point_count_abbreviated'],
953
+ textSize: 12,
954
+ textColor: ink,
955
+ }}
956
+ />
957
+ <Layer
958
+ id={`${id}-point`}
959
+ type="circle"
960
+ filter={['!', ['has', 'point_count']]}
961
+ style={{
962
+ circleColor: bubble,
963
+ circleRadius: 6,
964
+ circleStrokeWidth: 2,
965
+ circleStrokeColor: ink,
966
+ circleStrokeOpacity: 0.4,
967
+ }}
968
+ />
969
+ </GeoJSONSource>
970
+ );
971
+ }
972
+ MapCluster.displayName = 'Map.Cluster';
973
+
974
+ export interface MapHeatmapProps {
975
+ /** Point features to spread. */
976
+ data: unknown;
977
+ /** Feature property to weight each point by. Unweighted when omitted. */
978
+ weight?: string;
979
+ /**
980
+ * Spread of a single point, in points, at street zoom. Larger blurs more.
981
+ * The drawn radius shrinks as the map zooms out, so a point keeps covering
982
+ * roughly the same ground rather than the same screen area.
983
+ */
984
+ radius?: number;
985
+ /** Overall strength. Raise it when the data is sparse. */
986
+ intensity?: number;
987
+ /** 0 is invisible, 1 is solid. */
988
+ opacity?: number;
989
+ /** Above this zoom the layer fades out — see the note on the component. */
990
+ maxZoom?: number;
991
+ id?: string;
992
+ }
993
+
994
+ /**
995
+ * Point density as a continuous field.
996
+ *
997
+ * The opposite trade to `Map.Cluster`: a cluster keeps every point countable
998
+ * and tells you nothing about the space between them, while a heatmap shows
999
+ * the shape of the distribution and no longer lets you count anything. Reach
1000
+ * for it when the question is *where* rather than *how many*.
1001
+ *
1002
+ * It fades out past `maxZoom` on purpose. Zoomed far enough in, every point is
1003
+ * its own island and the blur says less than the points would — so the layer
1004
+ * gets out of the way rather than smearing five records across a street.
1005
+ *
1006
+ * The radius is tied to zoom for the mirror-image reason. Left as a fixed
1007
+ * number of screen points it would mean a different distance at every zoom:
1008
+ * a blur that reads as a city at street level covers half a continent once
1009
+ * the map is pulled out, and a field measured over land ends up sitting in
1010
+ * the sea. Scaling it with the projection keeps the claim the same one.
1011
+ */
1012
+ function MapHeatmap({
1013
+ data,
1014
+ weight,
1015
+ radius = 24,
1016
+ intensity = 1,
1017
+ opacity = 0.85,
1018
+ maxZoom = 15,
1019
+ id = 'heatmap',
1020
+ }: MapHeatmapProps) {
1021
+ const cool = useToken('--color-muted', 'rgba(0,0,0,0.06)');
1022
+ const warm = useToken('--color-chart-1', '#262626');
1023
+ const hot = useToken('--color-chart-2', '#666666');
1024
+
1025
+ if (!MapLibre) return null;
1026
+ const { GeoJSONSource, Layer } = MapLibre;
1027
+
1028
+ return (
1029
+ <GeoJSONSource id={`${id}-source`} data={data}>
1030
+ <Layer
1031
+ id={id}
1032
+ type="heatmap"
1033
+ maxzoom={maxZoom}
1034
+ style={{
1035
+ heatmapWeight: weight
1036
+ ? ['interpolate', ['linear'], ['get', weight], 0, 0, 1, 1]
1037
+ : 1,
1038
+ heatmapIntensity: intensity,
1039
+ // `radius` is the spread at street zoom; below that it is scaled
1040
+ // down so it keeps covering the same ground rather than the same
1041
+ // number of pixels. The stops are the projection: each zoom level
1042
+ // halves the ground a pixel covers, so the radius halves with it,
1043
+ // floored so the field stays visible at world zoom.
1044
+ heatmapRadius: [
1045
+ 'interpolate',
1046
+ ['exponential', 2],
1047
+ ['zoom'],
1048
+ 0,
1049
+ Math.max(2, radius / 10),
1050
+ 8,
1051
+ Math.max(6, radius / 1.8),
1052
+ 14,
1053
+ radius,
1054
+ ],
1055
+ // Transparent at zero rather than the coolest colour, so the map
1056
+ // shows through everywhere nothing was measured instead of the whole
1057
+ // viewport being tinted by the absence of data.
1058
+ heatmapColor: [
1059
+ 'interpolate',
1060
+ ['linear'],
1061
+ ['heatmap-density'],
1062
+ 0,
1063
+ 'rgba(0,0,0,0)',
1064
+ 0.2,
1065
+ cool,
1066
+ 0.6,
1067
+ warm,
1068
+ 1,
1069
+ hot,
1070
+ ],
1071
+ heatmapOpacity: [
1072
+ 'interpolate',
1073
+ ['linear'],
1074
+ ['zoom'],
1075
+ maxZoom - 2,
1076
+ opacity,
1077
+ maxZoom,
1078
+ 0,
1079
+ ],
1080
+ }}
1081
+ />
1082
+ </GeoJSONSource>
1083
+ );
1084
+ }
1085
+ MapHeatmap.displayName = 'Map.Heatmap';
1086
+
1087
+ export interface MapUserLocationProps {
1088
+ /** Show which way the device is facing, not just where it is. */
1089
+ heading?: boolean;
1090
+ /** Draw the ring showing how confident the fix is. */
1091
+ accuracy?: boolean;
1092
+ }
1093
+
1094
+ /** The device's own position, drawn by the renderer. */
1095
+ function MapUserLocation({
1096
+ heading = false,
1097
+ accuracy = false,
1098
+ }: MapUserLocationProps) {
1099
+ if (!MapLibre) return null;
1100
+ const { UserLocation } = MapLibre;
1101
+ return <UserLocation heading={heading} accuracy={accuracy} />;
1102
+ }
1103
+ MapUserLocation.displayName = 'Map.UserLocation';
1104
+
1105
+ export const Map = Object.assign(MapRoot, {
1106
+ Marker: MapMarker,
1107
+ Label: MapLabel,
1108
+ Popup: MapPopup,
1109
+ Controls: MapControls,
1110
+ Route: MapRoute,
1111
+ Arc: MapArc,
1112
+ GeoJSON: MapGeoJSON,
1113
+ Cluster: MapCluster,
1114
+ Heatmap: MapHeatmap,
1115
+ UserLocation: MapUserLocation,
1116
+ });