panelui-native 0.22.5 → 0.25.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.
- package/README.md +8 -3
- package/lib/module/components/area-chart/index.js +826 -0
- package/lib/module/components/area-chart/index.js.map +1 -0
- package/lib/module/components/avatar/index.js +1 -1
- package/lib/module/components/avatar/index.js.map +1 -1
- package/lib/module/components/bar-chart/index.js +853 -0
- package/lib/module/components/bar-chart/index.js.map +1 -0
- package/lib/module/components/bottom-sheet/index.js +207 -23
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/breadcrumb/index.js +275 -0
- package/lib/module/components/breadcrumb/index.js.map +1 -0
- package/lib/module/components/chip/index.js +2 -2
- package/lib/module/components/direction/index.js +13 -19
- package/lib/module/components/direction/index.js.map +1 -1
- package/lib/module/components/frame/index.js +17 -0
- package/lib/module/components/frame/index.js.map +1 -1
- package/lib/module/components/heatmap-chart/index.js +28 -8
- package/lib/module/components/heatmap-chart/index.js.map +1 -1
- package/lib/module/components/input-group/index.js +2 -2
- package/lib/module/components/input-group/index.js.map +1 -1
- package/lib/module/components/line-chart/index.js +4 -146
- package/lib/module/components/line-chart/index.js.map +1 -1
- package/lib/module/components/loader/index.js +758 -0
- package/lib/module/components/loader/index.js.map +1 -0
- package/lib/module/components/map/basemap.js +265 -0
- package/lib/module/components/map/basemap.js.map +1 -0
- package/lib/module/components/map/index.js +850 -0
- package/lib/module/components/map/index.js.map +1 -0
- package/lib/module/components/map/maplibre.js +87 -0
- package/lib/module/components/map/maplibre.js.map +1 -0
- package/lib/module/components/message/index.js +8 -7
- package/lib/module/components/message/index.js.map +1 -1
- package/lib/module/components/otp-input/index.js +2 -2
- package/lib/module/components/progress/index.js +8 -2
- package/lib/module/components/progress/index.js.map +1 -1
- package/lib/module/components/ring-chart/index.js +423 -0
- package/lib/module/components/ring-chart/index.js.map +1 -0
- package/lib/module/components/section-rail/index.js +2 -2
- package/lib/module/components/separator/index.js +76 -22
- package/lib/module/components/separator/index.js.map +1 -1
- package/lib/module/components/shimmer/index.js +10 -1
- package/lib/module/components/shimmer/index.js.map +1 -1
- package/lib/module/components/slider/index.js +19 -5
- package/lib/module/components/slider/index.js.map +1 -1
- package/lib/module/components/steps/index.js +1 -1
- package/lib/module/components/switch/index.js +6 -1
- package/lib/module/components/switch/index.js.map +1 -1
- package/lib/module/components/typography/index.js +5 -0
- package/lib/module/components/typography/index.js.map +1 -1
- package/lib/module/hooks/use-direction.js +43 -0
- package/lib/module/hooks/use-direction.js.map +1 -0
- package/lib/module/icons/index.js +117 -2
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +8 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/primitives/text.js +30 -10
- package/lib/module/primitives/text.js.map +1 -1
- package/lib/module/utils/chart.js +279 -0
- package/lib/module/utils/chart.js.map +1 -0
- package/lib/typescript/src/components/area-chart/index.d.ts +197 -0
- package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/bar-chart/index.d.ts +228 -0
- package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +75 -4
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/breadcrumb/index.d.ts +100 -0
- package/lib/typescript/src/components/breadcrumb/index.d.ts.map +1 -0
- package/lib/typescript/src/components/direction/index.d.ts +11 -10
- package/lib/typescript/src/components/direction/index.d.ts.map +1 -1
- package/lib/typescript/src/components/frame/index.d.ts +17 -0
- package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
- package/lib/typescript/src/components/heatmap-chart/index.d.ts +22 -3
- package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/line-chart/index.d.ts +1 -0
- package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/loader/index.d.ts +38 -0
- package/lib/typescript/src/components/loader/index.d.ts.map +1 -0
- package/lib/typescript/src/components/map/basemap.d.ts +76 -0
- package/lib/typescript/src/components/map/basemap.d.ts.map +1 -0
- package/lib/typescript/src/components/map/index.d.ts +367 -0
- package/lib/typescript/src/components/map/index.d.ts.map +1 -0
- package/lib/typescript/src/components/map/maplibre.d.ts +193 -0
- package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -0
- package/lib/typescript/src/components/message/index.d.ts.map +1 -1
- package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
- package/lib/typescript/src/components/ring-chart/index.d.ts +156 -0
- package/lib/typescript/src/components/ring-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/separator/index.d.ts +36 -1
- package/lib/typescript/src/components/separator/index.d.ts.map +1 -1
- package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -1
- package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
- package/lib/typescript/src/components/typography/index.d.ts +6 -0
- package/lib/typescript/src/components/typography/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-direction.d.ts +20 -0
- package/lib/typescript/src/hooks/use-direction.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +5 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +9 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/text.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +96 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -0
- package/package.json +9 -1
- package/src/components/area-chart/index.tsx +994 -0
- package/src/components/avatar/index.tsx +1 -1
- package/src/components/bar-chart/index.tsx +1048 -0
- package/src/components/bottom-sheet/index.tsx +280 -20
- package/src/components/breadcrumb/index.tsx +365 -0
- package/src/components/chip/index.tsx +2 -2
- package/src/components/direction/index.tsx +20 -21
- package/src/components/frame/index.tsx +17 -0
- package/src/components/heatmap-chart/index.tsx +58 -11
- package/src/components/input-group/index.tsx +2 -2
- package/src/components/line-chart/index.tsx +13 -181
- package/src/components/loader/index.tsx +812 -0
- package/src/components/map/basemap.ts +304 -0
- package/src/components/map/index.tsx +1118 -0
- package/src/components/map/maplibre.ts +201 -0
- package/src/components/message/index.tsx +8 -7
- package/src/components/otp-input/index.tsx +2 -2
- package/src/components/progress/index.tsx +10 -2
- package/src/components/ring-chart/index.tsx +543 -0
- package/src/components/section-rail/index.tsx +2 -2
- package/src/components/separator/index.tsx +77 -19
- package/src/components/shimmer/index.tsx +10 -1
- package/src/components/slider/index.tsx +20 -5
- package/src/components/steps/index.tsx +1 -1
- package/src/components/switch/index.tsx +6 -1
- package/src/components/typography/index.tsx +5 -0
- package/src/hooks/use-direction.ts +45 -0
- package/src/icons/index.tsx +66 -0
- package/src/index.ts +84 -0
- package/src/primitives/text.tsx +25 -7
- package/src/utils/chart.ts +355 -0
|
@@ -0,0 +1,1118 @@
|
|
|
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 } 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
|
+
{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
|
+
{typeof children === 'string' ? (
|
|
475
|
+
<Text size="xs" weight="medium" numberOfLines={1}>
|
|
476
|
+
{children}
|
|
477
|
+
</Text>
|
|
478
|
+
) : (
|
|
479
|
+
children
|
|
480
|
+
)}
|
|
481
|
+
</View>
|
|
482
|
+
</View>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
MapLabel.displayName = 'Map.Label';
|
|
486
|
+
|
|
487
|
+
export interface MapPopupProps {
|
|
488
|
+
children?: ReactNode;
|
|
489
|
+
className?: string;
|
|
490
|
+
/** Heading above the content. Strings are wrapped for you. */
|
|
491
|
+
title?: string;
|
|
492
|
+
/**
|
|
493
|
+
* Anchor to this coordinate instead of to an enclosing marker. Required when
|
|
494
|
+
* the popup is not inside one.
|
|
495
|
+
*/
|
|
496
|
+
lngLat?: LngLat;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* A card anchored to a point.
|
|
501
|
+
*
|
|
502
|
+
* Inside a `Map.Marker` it takes that marker's coordinate and opens when the
|
|
503
|
+
* marker is pressed. Given a `lngLat` it stands alone at that coordinate — the
|
|
504
|
+
* same component either way, because the difference is where it is anchored
|
|
505
|
+
* rather than what it is.
|
|
506
|
+
*
|
|
507
|
+
* Either way it is its own annotation rather than something drawn inside the
|
|
508
|
+
* marker, which is what keeps a card from dragging the pin off its coordinate.
|
|
509
|
+
* Anchored by its bottom edge, so it floats above the point it describes
|
|
510
|
+
* instead of covering it.
|
|
511
|
+
*/
|
|
512
|
+
function MapPopup({ children, className, title, lngLat }: MapPopupProps) {
|
|
513
|
+
const slots = mapVariants();
|
|
514
|
+
const markerLngLat = useContext(MarkerContext);
|
|
515
|
+
const coordinate = lngLat ?? markerLngLat;
|
|
516
|
+
|
|
517
|
+
if (!MapLibre || !coordinate) return null;
|
|
518
|
+
const { Marker } = MapLibre;
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<Marker lngLat={coordinate} anchor="bottom">
|
|
522
|
+
<View className={cn('mb-2 items-center', slots.popup({ className }))}>
|
|
523
|
+
{title ? (
|
|
524
|
+
<Text size="sm" weight="medium">
|
|
525
|
+
{title}
|
|
526
|
+
</Text>
|
|
527
|
+
) : null}
|
|
528
|
+
{children}
|
|
529
|
+
</View>
|
|
530
|
+
</Marker>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
MapPopup.displayName = 'Map.Popup';
|
|
534
|
+
|
|
535
|
+
export interface MapControlsProps {
|
|
536
|
+
/** Which corner the stack sits in. */
|
|
537
|
+
position?: MapControlsPosition;
|
|
538
|
+
/** Zoom in and out. On by default — it is the one control a map always needs. */
|
|
539
|
+
zoom?: boolean;
|
|
540
|
+
/** Recentre on the device's location. Needs a location permission. */
|
|
541
|
+
locate?: boolean;
|
|
542
|
+
/** Reset bearing and pitch to north and flat. */
|
|
543
|
+
compass?: boolean;
|
|
544
|
+
className?: string;
|
|
545
|
+
/** Called with the located coordinate, so a caller can react to it. */
|
|
546
|
+
onLocate?: (lngLat: LngLat) => void;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function ControlButton({
|
|
550
|
+
label,
|
|
551
|
+
onPress,
|
|
552
|
+
children,
|
|
553
|
+
}: {
|
|
554
|
+
label: string;
|
|
555
|
+
onPress: () => void;
|
|
556
|
+
children: ReactNode;
|
|
557
|
+
}) {
|
|
558
|
+
const slots = mapVariants();
|
|
559
|
+
return (
|
|
560
|
+
<Pressable
|
|
561
|
+
accessibilityRole="button"
|
|
562
|
+
accessibilityLabel={label}
|
|
563
|
+
className={slots.control()}
|
|
564
|
+
onPress={onPress}
|
|
565
|
+
>
|
|
566
|
+
{children}
|
|
567
|
+
</Pressable>
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* The map's own chrome, as themed views.
|
|
573
|
+
*
|
|
574
|
+
* The renderer draws its own zoom and compass ornaments, but in its style
|
|
575
|
+
* rather than the app's, and they cannot be recoloured — so they are turned
|
|
576
|
+
* off in `Map` and replaced here by buttons built from the same tokens as
|
|
577
|
+
* every other control in the library.
|
|
578
|
+
*/
|
|
579
|
+
function MapControls({
|
|
580
|
+
position = 'bottom-right',
|
|
581
|
+
zoom = true,
|
|
582
|
+
locate = false,
|
|
583
|
+
compass = false,
|
|
584
|
+
className,
|
|
585
|
+
onLocate,
|
|
586
|
+
}: MapControlsProps) {
|
|
587
|
+
const slots = mapVariants({ position });
|
|
588
|
+
const { mapRef, cameraRef } = useMap();
|
|
589
|
+
|
|
590
|
+
// Read the live zoom rather than tracking it in state: the map is moved by
|
|
591
|
+
// gestures too, and a counter kept alongside it drifts on the first pinch.
|
|
592
|
+
const nudgeZoom = useCallback(
|
|
593
|
+
async (delta: number) => {
|
|
594
|
+
const map = mapRef.current;
|
|
595
|
+
const camera = cameraRef.current;
|
|
596
|
+
if (!map || !camera) return;
|
|
597
|
+
const [center, current] = await Promise.all([
|
|
598
|
+
map.getCenter(),
|
|
599
|
+
map.getZoom(),
|
|
600
|
+
]);
|
|
601
|
+
camera.easeTo({
|
|
602
|
+
center,
|
|
603
|
+
zoom: Math.min(22, Math.max(0, current + delta)),
|
|
604
|
+
duration: 250,
|
|
605
|
+
});
|
|
606
|
+
},
|
|
607
|
+
[mapRef, cameraRef]
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
const handleLocate = useCallback(async () => {
|
|
611
|
+
let position$: { coords: { longitude: number; latitude: number } } | null =
|
|
612
|
+
null;
|
|
613
|
+
try {
|
|
614
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
615
|
+
const location = require('expo-location');
|
|
616
|
+
const granted = await location.requestForegroundPermissionsAsync();
|
|
617
|
+
if (!granted?.granted) return;
|
|
618
|
+
position$ = await location.getCurrentPositionAsync({});
|
|
619
|
+
} catch {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (!position$) return;
|
|
623
|
+
const lngLat: LngLat = [
|
|
624
|
+
position$.coords.longitude,
|
|
625
|
+
position$.coords.latitude,
|
|
626
|
+
];
|
|
627
|
+
cameraRef.current?.flyTo({ center: lngLat, zoom: 14, duration: 900 });
|
|
628
|
+
onLocate?.(lngLat);
|
|
629
|
+
}, [cameraRef, onLocate]);
|
|
630
|
+
|
|
631
|
+
const resetNorth = useCallback(async () => {
|
|
632
|
+
const center = await mapRef.current?.getCenter();
|
|
633
|
+
if (!center) return;
|
|
634
|
+
cameraRef.current?.easeTo({ center, bearing: 0, pitch: 0, duration: 300 });
|
|
635
|
+
}, [mapRef, cameraRef]);
|
|
636
|
+
|
|
637
|
+
return (
|
|
638
|
+
<View className={slots.controls({ className })}>
|
|
639
|
+
{zoom ? (
|
|
640
|
+
<View className={slots.group()}>
|
|
641
|
+
<ControlButton label="Zoom in" onPress={() => nudgeZoom(1)}>
|
|
642
|
+
<PlusIcon size={16} />
|
|
643
|
+
</ControlButton>
|
|
644
|
+
<View className="h-px bg-border" />
|
|
645
|
+
<ControlButton label="Zoom out" onPress={() => nudgeZoom(-1)}>
|
|
646
|
+
<MinusIcon size={16} />
|
|
647
|
+
</ControlButton>
|
|
648
|
+
</View>
|
|
649
|
+
) : null}
|
|
650
|
+
{compass ? (
|
|
651
|
+
<View className={slots.group()}>
|
|
652
|
+
<ControlButton label="Face north" onPress={resetNorth}>
|
|
653
|
+
<CompassIcon size={16} />
|
|
654
|
+
</ControlButton>
|
|
655
|
+
</View>
|
|
656
|
+
) : null}
|
|
657
|
+
{locate ? (
|
|
658
|
+
<View className={slots.group()}>
|
|
659
|
+
<ControlButton label="Show my location" onPress={handleLocate}>
|
|
660
|
+
<CrosshairIcon size={16} />
|
|
661
|
+
</ControlButton>
|
|
662
|
+
</View>
|
|
663
|
+
) : null}
|
|
664
|
+
</View>
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
MapControls.displayName = 'Map.Controls';
|
|
668
|
+
|
|
669
|
+
/** Turns a token into a colour a style layer can use. */
|
|
670
|
+
function useLineColor(color?: string) {
|
|
671
|
+
const fallback = useToken('--color-primary', '#262626');
|
|
672
|
+
return color ?? fallback;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export interface MapRouteProps {
|
|
676
|
+
/** The path, in order. */
|
|
677
|
+
coordinates: LngLat[];
|
|
678
|
+
/** Defaults to the primary token. */
|
|
679
|
+
color?: string;
|
|
680
|
+
/** Line thickness in points. */
|
|
681
|
+
width?: number;
|
|
682
|
+
/** Draw it dashed — for a leg that is planned rather than travelled. */
|
|
683
|
+
dashed?: boolean;
|
|
684
|
+
/** 0 is invisible, 1 is solid. */
|
|
685
|
+
opacity?: number;
|
|
686
|
+
id?: string;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* A path across the map, drawn as a style layer rather than as views — so its
|
|
691
|
+
* cost does not grow with the number of points in it.
|
|
692
|
+
*/
|
|
693
|
+
function MapRoute({
|
|
694
|
+
coordinates,
|
|
695
|
+
color,
|
|
696
|
+
width = 3,
|
|
697
|
+
dashed = false,
|
|
698
|
+
opacity = 1,
|
|
699
|
+
id = 'route',
|
|
700
|
+
}: MapRouteProps) {
|
|
701
|
+
const stroke = useLineColor(color);
|
|
702
|
+
const data = useMemo(
|
|
703
|
+
() => ({
|
|
704
|
+
type: 'Feature' as const,
|
|
705
|
+
properties: {},
|
|
706
|
+
geometry: { type: 'LineString' as const, coordinates },
|
|
707
|
+
}),
|
|
708
|
+
[coordinates]
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
if (!MapLibre) return null;
|
|
712
|
+
const { GeoJSONSource, Layer } = MapLibre;
|
|
713
|
+
|
|
714
|
+
return (
|
|
715
|
+
<GeoJSONSource id={`${id}-source`} data={data} lineMetrics>
|
|
716
|
+
<Layer
|
|
717
|
+
id={id}
|
|
718
|
+
type="line"
|
|
719
|
+
style={{
|
|
720
|
+
lineColor: stroke,
|
|
721
|
+
lineWidth: width,
|
|
722
|
+
lineOpacity: opacity,
|
|
723
|
+
lineCap: 'round',
|
|
724
|
+
lineJoin: 'round',
|
|
725
|
+
...(dashed ? { lineDasharray: [2, 1.5] } : {}),
|
|
726
|
+
}}
|
|
727
|
+
/>
|
|
728
|
+
</GeoJSONSource>
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
MapRoute.displayName = 'Map.Route';
|
|
732
|
+
|
|
733
|
+
export interface MapArcProps {
|
|
734
|
+
/** Where the arc starts. */
|
|
735
|
+
from: LngLat;
|
|
736
|
+
/** Where it ends. */
|
|
737
|
+
to: LngLat;
|
|
738
|
+
/** How far it bows. 0 is a straight line; 0.2 is the default lift. */
|
|
739
|
+
curvature?: number;
|
|
740
|
+
color?: string;
|
|
741
|
+
width?: number;
|
|
742
|
+
opacity?: number;
|
|
743
|
+
id?: string;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* A curved connection between two points.
|
|
748
|
+
*
|
|
749
|
+
* The curve is not geography — a great circle between two cities is not bowed
|
|
750
|
+
* on a flat projection. It is there so that two arcs sharing an endpoint stay
|
|
751
|
+
* tellable apart, which a bundle of straight lines through one city does not.
|
|
752
|
+
*/
|
|
753
|
+
function MapArc({
|
|
754
|
+
from,
|
|
755
|
+
to,
|
|
756
|
+
curvature = 0.2,
|
|
757
|
+
color,
|
|
758
|
+
width = 2,
|
|
759
|
+
opacity = 0.9,
|
|
760
|
+
id = 'arc',
|
|
761
|
+
}: MapArcProps) {
|
|
762
|
+
const stroke = useLineColor(color);
|
|
763
|
+
|
|
764
|
+
const data = useMemo(() => {
|
|
765
|
+
// A quadratic Bézier whose control point is pushed off the midpoint
|
|
766
|
+
// perpendicular to the line, so the bow scales with the distance rather
|
|
767
|
+
// than being a fixed lift that vanishes on long hops.
|
|
768
|
+
const [x1, y1] = from;
|
|
769
|
+
const [x2, y2] = to;
|
|
770
|
+
const midX = (x1 + x2) / 2;
|
|
771
|
+
const midY = (y1 + y2) / 2;
|
|
772
|
+
const controlX = midX - (y2 - y1) * curvature;
|
|
773
|
+
const controlY = midY + (x2 - x1) * curvature;
|
|
774
|
+
|
|
775
|
+
const SEGMENTS = 64;
|
|
776
|
+
const coordinates: LngLat[] = [];
|
|
777
|
+
for (let i = 0; i <= SEGMENTS; i += 1) {
|
|
778
|
+
const t = i / SEGMENTS;
|
|
779
|
+
const inv = 1 - t;
|
|
780
|
+
coordinates.push([
|
|
781
|
+
inv * inv * x1 + 2 * inv * t * controlX + t * t * x2,
|
|
782
|
+
inv * inv * y1 + 2 * inv * t * controlY + t * t * y2,
|
|
783
|
+
]);
|
|
784
|
+
}
|
|
785
|
+
return {
|
|
786
|
+
type: 'Feature' as const,
|
|
787
|
+
properties: {},
|
|
788
|
+
geometry: { type: 'LineString' as const, coordinates },
|
|
789
|
+
};
|
|
790
|
+
}, [from, to, curvature]);
|
|
791
|
+
|
|
792
|
+
if (!MapLibre) return null;
|
|
793
|
+
const { GeoJSONSource, Layer } = MapLibre;
|
|
794
|
+
|
|
795
|
+
return (
|
|
796
|
+
<GeoJSONSource id={`${id}-source`} data={data} lineMetrics>
|
|
797
|
+
<Layer
|
|
798
|
+
id={id}
|
|
799
|
+
type="line"
|
|
800
|
+
style={{
|
|
801
|
+
lineColor: stroke,
|
|
802
|
+
lineWidth: width,
|
|
803
|
+
lineOpacity: opacity,
|
|
804
|
+
lineCap: 'round',
|
|
805
|
+
}}
|
|
806
|
+
/>
|
|
807
|
+
</GeoJSONSource>
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
MapArc.displayName = 'Map.Arc';
|
|
811
|
+
|
|
812
|
+
export interface MapGeoJSONProps {
|
|
813
|
+
/** A Feature, FeatureCollection, or the URL of one. */
|
|
814
|
+
data: unknown;
|
|
815
|
+
/** Fill colour for polygons. A style expression works here too. */
|
|
816
|
+
fill?: string | unknown[];
|
|
817
|
+
/** Outline colour. Defaults to the border token. */
|
|
818
|
+
stroke?: string | unknown[];
|
|
819
|
+
/** Outline thickness. */
|
|
820
|
+
strokeWidth?: number;
|
|
821
|
+
/** 0 is invisible, 1 is solid. */
|
|
822
|
+
fillOpacity?: number;
|
|
823
|
+
/** Fires with the pressed feature. */
|
|
824
|
+
onPress?: (feature: unknown) => void;
|
|
825
|
+
id?: string;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Arbitrary geography as a themed layer — the component behind a choropleth,
|
|
830
|
+
* a coverage area, or any other shape that comes from data rather than from
|
|
831
|
+
* the basemap.
|
|
832
|
+
*
|
|
833
|
+
* `fill` takes a style expression as well as a colour, which is what makes a
|
|
834
|
+
* choropleth one layer instead of one layer per bucket.
|
|
835
|
+
*/
|
|
836
|
+
function MapGeoJSON({
|
|
837
|
+
data,
|
|
838
|
+
fill,
|
|
839
|
+
stroke,
|
|
840
|
+
strokeWidth = 1,
|
|
841
|
+
fillOpacity = 0.7,
|
|
842
|
+
onPress,
|
|
843
|
+
id = 'geojson',
|
|
844
|
+
}: MapGeoJSONProps) {
|
|
845
|
+
const defaultFill = useToken('--color-primary', '#262626');
|
|
846
|
+
const defaultStroke = useToken('--color-border', 'rgba(0,0,0,0.1)');
|
|
847
|
+
|
|
848
|
+
if (!MapLibre) return null;
|
|
849
|
+
const { GeoJSONSource, Layer } = MapLibre;
|
|
850
|
+
|
|
851
|
+
return (
|
|
852
|
+
<GeoJSONSource
|
|
853
|
+
id={`${id}-source`}
|
|
854
|
+
data={data}
|
|
855
|
+
onPress={
|
|
856
|
+
onPress
|
|
857
|
+
? (event) => onPress(event.nativeEvent.features?.[0])
|
|
858
|
+
: undefined
|
|
859
|
+
}
|
|
860
|
+
>
|
|
861
|
+
<Layer
|
|
862
|
+
id={`${id}-fill`}
|
|
863
|
+
type="fill"
|
|
864
|
+
style={{
|
|
865
|
+
fillColor: fill ?? defaultFill,
|
|
866
|
+
fillOpacity,
|
|
867
|
+
}}
|
|
868
|
+
/>
|
|
869
|
+
<Layer
|
|
870
|
+
id={`${id}-line`}
|
|
871
|
+
type="line"
|
|
872
|
+
style={{
|
|
873
|
+
lineColor: stroke ?? defaultStroke,
|
|
874
|
+
lineWidth: strokeWidth,
|
|
875
|
+
}}
|
|
876
|
+
/>
|
|
877
|
+
</GeoJSONSource>
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
MapGeoJSON.displayName = 'Map.GeoJSON';
|
|
881
|
+
|
|
882
|
+
export interface MapClusterProps {
|
|
883
|
+
/** Point features to cluster. */
|
|
884
|
+
data: unknown;
|
|
885
|
+
/** Defaults to the primary token. */
|
|
886
|
+
color?: string;
|
|
887
|
+
/** Text colour inside a cluster bubble. */
|
|
888
|
+
textColor?: string;
|
|
889
|
+
/** How close two points have to be, in points, to merge. */
|
|
890
|
+
radius?: number;
|
|
891
|
+
/** Above this zoom every point stands alone. */
|
|
892
|
+
maxZoom?: number;
|
|
893
|
+
/** Fires with the pressed cluster or point. */
|
|
894
|
+
onPress?: (feature: unknown) => void;
|
|
895
|
+
id?: string;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Dense points, merged as they get too close to tell apart.
|
|
900
|
+
*
|
|
901
|
+
* This is the layer to reach for past a few dozen points: `Map.Marker` mounts
|
|
902
|
+
* a React view each, which a thousand points cannot afford, and a thousand
|
|
903
|
+
* overlapping pins would be unreadable even if it could.
|
|
904
|
+
*/
|
|
905
|
+
function MapCluster({
|
|
906
|
+
data,
|
|
907
|
+
color,
|
|
908
|
+
textColor,
|
|
909
|
+
radius = 50,
|
|
910
|
+
maxZoom = 14,
|
|
911
|
+
onPress,
|
|
912
|
+
id = 'cluster',
|
|
913
|
+
}: MapClusterProps) {
|
|
914
|
+
const fill = useToken('--color-primary', '#262626');
|
|
915
|
+
const onFill = useToken('--color-primary-foreground', '#fafafa');
|
|
916
|
+
const bubble = color ?? fill;
|
|
917
|
+
const ink = textColor ?? onFill;
|
|
918
|
+
|
|
919
|
+
if (!MapLibre) return null;
|
|
920
|
+
const { GeoJSONSource, Layer } = MapLibre;
|
|
921
|
+
|
|
922
|
+
return (
|
|
923
|
+
<GeoJSONSource
|
|
924
|
+
id={`${id}-source`}
|
|
925
|
+
data={data}
|
|
926
|
+
cluster
|
|
927
|
+
clusterRadius={radius}
|
|
928
|
+
clusterMaxZoom={maxZoom}
|
|
929
|
+
onPress={
|
|
930
|
+
onPress
|
|
931
|
+
? (event) => onPress(event.nativeEvent.features?.[0])
|
|
932
|
+
: undefined
|
|
933
|
+
}
|
|
934
|
+
>
|
|
935
|
+
{/* Bubbles grow in steps rather than continuously: the point is to read
|
|
936
|
+
"more than that one", not to estimate a count from an area. */}
|
|
937
|
+
<Layer
|
|
938
|
+
id={`${id}-bubble`}
|
|
939
|
+
type="circle"
|
|
940
|
+
filter={['has', 'point_count']}
|
|
941
|
+
style={{
|
|
942
|
+
circleColor: bubble,
|
|
943
|
+
circleRadius: ['step', ['get', 'point_count'], 16, 25, 22, 100, 28],
|
|
944
|
+
circleStrokeWidth: 2,
|
|
945
|
+
circleStrokeColor: ink,
|
|
946
|
+
circleStrokeOpacity: 0.25,
|
|
947
|
+
}}
|
|
948
|
+
/>
|
|
949
|
+
<Layer
|
|
950
|
+
id={`${id}-count`}
|
|
951
|
+
type="symbol"
|
|
952
|
+
filter={['has', 'point_count']}
|
|
953
|
+
style={{
|
|
954
|
+
textField: ['get', 'point_count_abbreviated'],
|
|
955
|
+
textSize: 12,
|
|
956
|
+
textColor: ink,
|
|
957
|
+
}}
|
|
958
|
+
/>
|
|
959
|
+
<Layer
|
|
960
|
+
id={`${id}-point`}
|
|
961
|
+
type="circle"
|
|
962
|
+
filter={['!', ['has', 'point_count']]}
|
|
963
|
+
style={{
|
|
964
|
+
circleColor: bubble,
|
|
965
|
+
circleRadius: 6,
|
|
966
|
+
circleStrokeWidth: 2,
|
|
967
|
+
circleStrokeColor: ink,
|
|
968
|
+
circleStrokeOpacity: 0.4,
|
|
969
|
+
}}
|
|
970
|
+
/>
|
|
971
|
+
</GeoJSONSource>
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
MapCluster.displayName = 'Map.Cluster';
|
|
975
|
+
|
|
976
|
+
export interface MapHeatmapProps {
|
|
977
|
+
/** Point features to spread. */
|
|
978
|
+
data: unknown;
|
|
979
|
+
/** Feature property to weight each point by. Unweighted when omitted. */
|
|
980
|
+
weight?: string;
|
|
981
|
+
/**
|
|
982
|
+
* Spread of a single point, in points, at street zoom. Larger blurs more.
|
|
983
|
+
* The drawn radius shrinks as the map zooms out, so a point keeps covering
|
|
984
|
+
* roughly the same ground rather than the same screen area.
|
|
985
|
+
*/
|
|
986
|
+
radius?: number;
|
|
987
|
+
/** Overall strength. Raise it when the data is sparse. */
|
|
988
|
+
intensity?: number;
|
|
989
|
+
/** 0 is invisible, 1 is solid. */
|
|
990
|
+
opacity?: number;
|
|
991
|
+
/** Above this zoom the layer fades out — see the note on the component. */
|
|
992
|
+
maxZoom?: number;
|
|
993
|
+
id?: string;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Point density as a continuous field.
|
|
998
|
+
*
|
|
999
|
+
* The opposite trade to `Map.Cluster`: a cluster keeps every point countable
|
|
1000
|
+
* and tells you nothing about the space between them, while a heatmap shows
|
|
1001
|
+
* the shape of the distribution and no longer lets you count anything. Reach
|
|
1002
|
+
* for it when the question is *where* rather than *how many*.
|
|
1003
|
+
*
|
|
1004
|
+
* It fades out past `maxZoom` on purpose. Zoomed far enough in, every point is
|
|
1005
|
+
* its own island and the blur says less than the points would — so the layer
|
|
1006
|
+
* gets out of the way rather than smearing five records across a street.
|
|
1007
|
+
*
|
|
1008
|
+
* The radius is tied to zoom for the mirror-image reason. Left as a fixed
|
|
1009
|
+
* number of screen points it would mean a different distance at every zoom:
|
|
1010
|
+
* a blur that reads as a city at street level covers half a continent once
|
|
1011
|
+
* the map is pulled out, and a field measured over land ends up sitting in
|
|
1012
|
+
* the sea. Scaling it with the projection keeps the claim the same one.
|
|
1013
|
+
*/
|
|
1014
|
+
function MapHeatmap({
|
|
1015
|
+
data,
|
|
1016
|
+
weight,
|
|
1017
|
+
radius = 24,
|
|
1018
|
+
intensity = 1,
|
|
1019
|
+
opacity = 0.85,
|
|
1020
|
+
maxZoom = 15,
|
|
1021
|
+
id = 'heatmap',
|
|
1022
|
+
}: MapHeatmapProps) {
|
|
1023
|
+
const cool = useToken('--color-muted', 'rgba(0,0,0,0.06)');
|
|
1024
|
+
const warm = useToken('--color-chart-1', '#262626');
|
|
1025
|
+
const hot = useToken('--color-chart-2', '#666666');
|
|
1026
|
+
|
|
1027
|
+
if (!MapLibre) return null;
|
|
1028
|
+
const { GeoJSONSource, Layer } = MapLibre;
|
|
1029
|
+
|
|
1030
|
+
return (
|
|
1031
|
+
<GeoJSONSource id={`${id}-source`} data={data}>
|
|
1032
|
+
<Layer
|
|
1033
|
+
id={id}
|
|
1034
|
+
type="heatmap"
|
|
1035
|
+
maxzoom={maxZoom}
|
|
1036
|
+
style={{
|
|
1037
|
+
heatmapWeight: weight
|
|
1038
|
+
? ['interpolate', ['linear'], ['get', weight], 0, 0, 1, 1]
|
|
1039
|
+
: 1,
|
|
1040
|
+
heatmapIntensity: intensity,
|
|
1041
|
+
// `radius` is the spread at street zoom; below that it is scaled
|
|
1042
|
+
// down so it keeps covering the same ground rather than the same
|
|
1043
|
+
// number of pixels. The stops are the projection: each zoom level
|
|
1044
|
+
// halves the ground a pixel covers, so the radius halves with it,
|
|
1045
|
+
// floored so the field stays visible at world zoom.
|
|
1046
|
+
heatmapRadius: [
|
|
1047
|
+
'interpolate',
|
|
1048
|
+
['exponential', 2],
|
|
1049
|
+
['zoom'],
|
|
1050
|
+
0,
|
|
1051
|
+
Math.max(2, radius / 10),
|
|
1052
|
+
8,
|
|
1053
|
+
Math.max(6, radius / 1.8),
|
|
1054
|
+
14,
|
|
1055
|
+
radius,
|
|
1056
|
+
],
|
|
1057
|
+
// Transparent at zero rather than the coolest colour, so the map
|
|
1058
|
+
// shows through everywhere nothing was measured instead of the whole
|
|
1059
|
+
// viewport being tinted by the absence of data.
|
|
1060
|
+
heatmapColor: [
|
|
1061
|
+
'interpolate',
|
|
1062
|
+
['linear'],
|
|
1063
|
+
['heatmap-density'],
|
|
1064
|
+
0,
|
|
1065
|
+
'rgba(0,0,0,0)',
|
|
1066
|
+
0.2,
|
|
1067
|
+
cool,
|
|
1068
|
+
0.6,
|
|
1069
|
+
warm,
|
|
1070
|
+
1,
|
|
1071
|
+
hot,
|
|
1072
|
+
],
|
|
1073
|
+
heatmapOpacity: [
|
|
1074
|
+
'interpolate',
|
|
1075
|
+
['linear'],
|
|
1076
|
+
['zoom'],
|
|
1077
|
+
maxZoom - 2,
|
|
1078
|
+
opacity,
|
|
1079
|
+
maxZoom,
|
|
1080
|
+
0,
|
|
1081
|
+
],
|
|
1082
|
+
}}
|
|
1083
|
+
/>
|
|
1084
|
+
</GeoJSONSource>
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
MapHeatmap.displayName = 'Map.Heatmap';
|
|
1088
|
+
|
|
1089
|
+
export interface MapUserLocationProps {
|
|
1090
|
+
/** Show which way the device is facing, not just where it is. */
|
|
1091
|
+
heading?: boolean;
|
|
1092
|
+
/** Draw the ring showing how confident the fix is. */
|
|
1093
|
+
accuracy?: boolean;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** The device's own position, drawn by the renderer. */
|
|
1097
|
+
function MapUserLocation({
|
|
1098
|
+
heading = false,
|
|
1099
|
+
accuracy = false,
|
|
1100
|
+
}: MapUserLocationProps) {
|
|
1101
|
+
if (!MapLibre) return null;
|
|
1102
|
+
const { UserLocation } = MapLibre;
|
|
1103
|
+
return <UserLocation heading={heading} accuracy={accuracy} />;
|
|
1104
|
+
}
|
|
1105
|
+
MapUserLocation.displayName = 'Map.UserLocation';
|
|
1106
|
+
|
|
1107
|
+
export const Map = Object.assign(MapRoot, {
|
|
1108
|
+
Marker: MapMarker,
|
|
1109
|
+
Label: MapLabel,
|
|
1110
|
+
Popup: MapPopup,
|
|
1111
|
+
Controls: MapControls,
|
|
1112
|
+
Route: MapRoute,
|
|
1113
|
+
Arc: MapArc,
|
|
1114
|
+
GeoJSON: MapGeoJSON,
|
|
1115
|
+
Cluster: MapCluster,
|
|
1116
|
+
Heatmap: MapHeatmap,
|
|
1117
|
+
UserLocation: MapUserLocation,
|
|
1118
|
+
});
|