@delacour/react-native-ui 0.2.0-alpha.20261015134956 → 0.2.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/package.json +7 -1
- package/src/components/map/AGENTS.md +172 -0
- package/src/components/map/index.ts +68 -0
- package/src/components/map/map-arc.tsx +79 -0
- package/src/components/map/map-controls.tsx +133 -0
- package/src/components/map/map-geometry.test.ts +159 -0
- package/src/components/map/map-geometry.ts +153 -0
- package/src/components/map/map-label.tsx +45 -0
- package/src/components/map/map-marker.tsx +106 -0
- package/src/components/map/map-pin.tsx +55 -0
- package/src/components/map/map-popup.tsx +83 -0
- package/src/components/map/map-route.tsx +87 -0
- package/src/components/map/map-user-location.tsx +100 -0
- package/src/components/map/map.context.tsx +130 -0
- package/src/components/map/map.tsx +425 -0
- package/src/components/map/map.types.ts +48 -0
- package/src/components/map/map.variants.test.ts +353 -0
- package/src/components/map/map.variants.ts +467 -0
- package/src/no-maplibre.test.ts +74 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createContext, type ReactElement, type ReactNode, use } from "react";
|
|
2
|
+
import type { LngLat, MapHandle } from "./map.types";
|
|
3
|
+
|
|
4
|
+
export type MapContextValue = {
|
|
5
|
+
/** Moves the camera — the same handle a `ref` on `<Map>` receives. */
|
|
6
|
+
handle: MapHandle;
|
|
7
|
+
/** Zooms by whole steps of the tile pyramid; negative zooms out. */
|
|
8
|
+
zoomBy(delta: number): void;
|
|
9
|
+
/** Turns the map back to north-up and flat. */
|
|
10
|
+
resetBearing(): void;
|
|
11
|
+
/**
|
|
12
|
+
* Turns the native user-location layer on while a part needs it. Returns
|
|
13
|
+
* the release; the layer goes off when the last part lets go.
|
|
14
|
+
*/
|
|
15
|
+
requestUserLocation(): () => void;
|
|
16
|
+
/**
|
|
17
|
+
* Flies to the device's location, then calls back with it. Before the first
|
|
18
|
+
* fix arrives the flight waits for it rather than going nowhere.
|
|
19
|
+
*/
|
|
20
|
+
locate(onLocate?: (lngLat: LngLat) => void): void;
|
|
21
|
+
/**
|
|
22
|
+
* Puts a floating part on the layer above the map, keyed by `id`; `null`
|
|
23
|
+
* takes it off. The native map renders only map features as children, so a
|
|
24
|
+
* part drawn in screen space — the controls — has to live beside it.
|
|
25
|
+
*/
|
|
26
|
+
setOverlay(id: string, node: ReactNode | null): void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const MapContext = createContext<MapContextValue | null>(null);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Supplies the enclosing map's camera and location controls to its subtree.
|
|
33
|
+
*
|
|
34
|
+
* Lives in its own module, importing nothing but types, so a part the map
|
|
35
|
+
* renders can read it without importing `./map` and closing a cycle through
|
|
36
|
+
* `map.tsx`. See AGENTS.md rule 3.
|
|
37
|
+
*/
|
|
38
|
+
export function MapProvider({ value, children }: { value: MapContextValue; children: ReactNode }): ReactElement {
|
|
39
|
+
return <MapContext value={value}>{children}</MapContext>;
|
|
40
|
+
}
|
|
41
|
+
MapProvider.displayName = "DelacourUI.Map.Provider";
|
|
42
|
+
|
|
43
|
+
/** The enclosing map's context, or null outside a `<Map>`. */
|
|
44
|
+
export function useMapContext(): MapContextValue | null {
|
|
45
|
+
return use(MapContext);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The enclosing map's camera handle — `flyTo`, `fitBounds`, `recenter`,
|
|
50
|
+
* `getViewState`. What a custom control inside `<Map>` uses to move it. Throws
|
|
51
|
+
* outside a `<Map>`.
|
|
52
|
+
*/
|
|
53
|
+
export function useMap(): MapHandle {
|
|
54
|
+
const context = useMapContext();
|
|
55
|
+
if (!context) {
|
|
56
|
+
throw new Error("useMap must be called inside a <Map>.");
|
|
57
|
+
}
|
|
58
|
+
return context.handle;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The enclosing map's context, for a compound part that cannot work without one.
|
|
63
|
+
*
|
|
64
|
+
* Internal: deliberately not re-exported from `index.ts`. A caller outside the
|
|
65
|
+
* library wants {@link useMap}, whose error message names the hook rather than
|
|
66
|
+
* a part.
|
|
67
|
+
*/
|
|
68
|
+
export function useMapPart(component: string): MapContextValue {
|
|
69
|
+
const context = useMapContext();
|
|
70
|
+
if (!context) {
|
|
71
|
+
throw new Error(`${component} must be rendered inside a <Map>.`);
|
|
72
|
+
}
|
|
73
|
+
return context;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type MapUserLocationValue = {
|
|
77
|
+
/** The device's last reported position, or null before the first fix. */
|
|
78
|
+
location: LngLat | null;
|
|
79
|
+
/** Degrees clockwise from north, or undefined when the device does not know. */
|
|
80
|
+
heading: number | undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const MapUserLocationContext = createContext<MapUserLocationValue>({ heading: undefined, location: null });
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Supplies the device's position. Separate from the map's own context so a
|
|
87
|
+
* location tick re-renders the parts that draw it and nothing else.
|
|
88
|
+
*/
|
|
89
|
+
export function MapUserLocationProvider({
|
|
90
|
+
value,
|
|
91
|
+
children,
|
|
92
|
+
}: {
|
|
93
|
+
value: MapUserLocationValue;
|
|
94
|
+
children: ReactNode;
|
|
95
|
+
}): ReactElement {
|
|
96
|
+
return <MapUserLocationContext value={value}>{children}</MapUserLocationContext>;
|
|
97
|
+
}
|
|
98
|
+
MapUserLocationProvider.displayName = "DelacourUI.Map.UserLocation.Provider";
|
|
99
|
+
|
|
100
|
+
/** The device's last reported position and heading, while a part has the location layer on. */
|
|
101
|
+
export function useMapUserLocation(): MapUserLocationValue {
|
|
102
|
+
return use(MapUserLocationContext);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Where a part sits inside its marker, in the marker's own points. */
|
|
106
|
+
export type MapMarkerFrame = { x: number; y: number; width: number; height: number };
|
|
107
|
+
|
|
108
|
+
export type MapMarkerContextValue = {
|
|
109
|
+
/** A pin reports its frame so the marker can anchor on the pin's tip. `null` on unmount. */
|
|
110
|
+
reportPin(frame: MapMarkerFrame | null): void;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const MapMarkerContext = createContext<MapMarkerContextValue | null>(null);
|
|
114
|
+
|
|
115
|
+
/** Tells the parts inside a marker that they are inside one, and lets a pin report where it sits. */
|
|
116
|
+
export function MapMarkerProvider({
|
|
117
|
+
value,
|
|
118
|
+
children,
|
|
119
|
+
}: {
|
|
120
|
+
value: MapMarkerContextValue;
|
|
121
|
+
children: ReactNode;
|
|
122
|
+
}): ReactElement {
|
|
123
|
+
return <MapMarkerContext value={value}>{children}</MapMarkerContext>;
|
|
124
|
+
}
|
|
125
|
+
MapMarkerProvider.displayName = "DelacourUI.Map.Marker.Provider";
|
|
126
|
+
|
|
127
|
+
/** The enclosing marker, or null outside one. */
|
|
128
|
+
export function useMapMarkerContext(): MapMarkerContextValue | null {
|
|
129
|
+
return use(MapMarkerContext);
|
|
130
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Camera,
|
|
3
|
+
type CameraRef,
|
|
4
|
+
type GeolocationPosition,
|
|
5
|
+
LocationManager,
|
|
6
|
+
Map as MapLibreMap,
|
|
7
|
+
type MapRef,
|
|
8
|
+
type PressEvent,
|
|
9
|
+
type StyleSpecification,
|
|
10
|
+
type ViewStateChangeEvent,
|
|
11
|
+
} from "@maplibre/maplibre-react-native";
|
|
12
|
+
import {
|
|
13
|
+
Fragment,
|
|
14
|
+
type ReactElement,
|
|
15
|
+
type ReactNode,
|
|
16
|
+
type Ref,
|
|
17
|
+
useCallback,
|
|
18
|
+
useEffect,
|
|
19
|
+
useImperativeHandle,
|
|
20
|
+
useMemo,
|
|
21
|
+
useRef,
|
|
22
|
+
useState,
|
|
23
|
+
} from "react";
|
|
24
|
+
import { type LayoutChangeEvent, type NativeSyntheticEvent, StyleSheet, View, type ViewProps } from "react-native";
|
|
25
|
+
import { useReducedMotion } from "react-native-reanimated";
|
|
26
|
+
import { useThemeColor } from "../../hooks/use-theme-color";
|
|
27
|
+
import { type MapContextValue, MapProvider, MapUserLocationProvider, type MapUserLocationValue } from "./map.context";
|
|
28
|
+
import type { LngLat, LngLatBounds, MapHandle, ViewState } from "./map.types";
|
|
29
|
+
import {
|
|
30
|
+
buildMapStyle,
|
|
31
|
+
hasInitialViewChanged,
|
|
32
|
+
MAP_ACCESSIBILITY_LABEL,
|
|
33
|
+
MAP_STYLE_TOKENS,
|
|
34
|
+
type MapInitialView,
|
|
35
|
+
mapVariants,
|
|
36
|
+
resolveCameraDuration,
|
|
37
|
+
resolveHeadingRotation,
|
|
38
|
+
resolveInitialCamera,
|
|
39
|
+
resolveMapGestures,
|
|
40
|
+
toStyleColor,
|
|
41
|
+
} from "./map.variants";
|
|
42
|
+
import { MapArc } from "./map-arc";
|
|
43
|
+
import { MapControls } from "./map-controls";
|
|
44
|
+
import { fitBoundsCamera } from "./map-geometry";
|
|
45
|
+
import { MapLabel } from "./map-label";
|
|
46
|
+
import { MapMarker } from "./map-marker";
|
|
47
|
+
import { MapPin } from "./map-pin";
|
|
48
|
+
import { MapPopup } from "./map-popup";
|
|
49
|
+
import { MapRoute } from "./map-route";
|
|
50
|
+
import { MapUserLocation } from "./map-user-location";
|
|
51
|
+
|
|
52
|
+
export type MapProps = Omit<ViewProps, "children"> & {
|
|
53
|
+
/** Where the map opens. Initial only — move it afterwards through the `ref`. */
|
|
54
|
+
center?: LngLat;
|
|
55
|
+
/** How far in it opens, on the web-map scale: 0 is the world, ~12 a city, ~16 a street. Defaults to `2`. */
|
|
56
|
+
zoom?: number;
|
|
57
|
+
/** Degrees clockwise from north it opens turned to. */
|
|
58
|
+
bearing?: number;
|
|
59
|
+
/** Degrees of tilt it opens at. */
|
|
60
|
+
pitch?: number;
|
|
61
|
+
/** A box to open framed on. Beats `center` and `zoom`. */
|
|
62
|
+
bounds?: LngLatBounds;
|
|
63
|
+
/** Whether two fingers turn and tilt the map. Defaults to `false`. */
|
|
64
|
+
isRotatable?: boolean;
|
|
65
|
+
/** Whether the map pans and zooms at all. Defaults to `true`. */
|
|
66
|
+
isInteractive?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Replaces the theme-painted basemap — a style URL or a style object. Build
|
|
69
|
+
* one from the theme on your own tiles with `buildMapStyle`.
|
|
70
|
+
*/
|
|
71
|
+
mapStyle?: string | StyleSpecification;
|
|
72
|
+
/** Called when the camera comes to rest. */
|
|
73
|
+
onViewStateChange?: (viewState: ViewState) => void;
|
|
74
|
+
/** Called with the coordinate under a tap on the map — not on a marker. */
|
|
75
|
+
onPress?: (lngLat: LngLat) => void;
|
|
76
|
+
/** Called once the native map has loaded. */
|
|
77
|
+
onReady?: () => void;
|
|
78
|
+
/** Moves the camera after mount. */
|
|
79
|
+
ref?: Ref<MapHandle>;
|
|
80
|
+
/** Sizes the map. Defaults to the full width and `h-72`. */
|
|
81
|
+
className?: string;
|
|
82
|
+
children?: ReactNode;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type Size = { width: number; height: number };
|
|
86
|
+
type CameraTarget = { center: LngLat; zoom: number };
|
|
87
|
+
|
|
88
|
+
/** How long a zoom control's step takes — quicker than a flight, because it is a nudge. */
|
|
89
|
+
const ZOOM_STEP_DURATION = 250;
|
|
90
|
+
|
|
91
|
+
/** The zoom a locate press flies in to at least — a street, not a country. */
|
|
92
|
+
const LOCATE_MIN_ZOOM = 14;
|
|
93
|
+
|
|
94
|
+
/** The inset `fitBounds` keeps clear when the caller names none, in points. */
|
|
95
|
+
const FIT_PADDING = 40;
|
|
96
|
+
|
|
97
|
+
/** Where the attribution button sits — the corner the controls leave free by default. */
|
|
98
|
+
const ATTRIBUTION_POSITION = { bottom: 8, left: 8 } as const;
|
|
99
|
+
|
|
100
|
+
function toViewState({ center, zoom, bearing, pitch }: ViewState): ViewState {
|
|
101
|
+
return { bearing, center: [center[0], center[1]], pitch, zoom };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function MapRoot({
|
|
105
|
+
center,
|
|
106
|
+
zoom,
|
|
107
|
+
bearing = 0,
|
|
108
|
+
pitch = 0,
|
|
109
|
+
bounds,
|
|
110
|
+
isRotatable = false,
|
|
111
|
+
isInteractive = true,
|
|
112
|
+
mapStyle,
|
|
113
|
+
onViewStateChange,
|
|
114
|
+
onPress,
|
|
115
|
+
onReady,
|
|
116
|
+
ref,
|
|
117
|
+
className,
|
|
118
|
+
children,
|
|
119
|
+
accessibilityLabel = MAP_ACCESSIBILITY_LABEL,
|
|
120
|
+
...props
|
|
121
|
+
}: MapProps): ReactElement {
|
|
122
|
+
const mapRef = useRef<MapRef>(null);
|
|
123
|
+
const cameraRef = useRef<CameraRef>(null);
|
|
124
|
+
const isReducedMotion = useReducedMotion();
|
|
125
|
+
|
|
126
|
+
// The view the map mounted with, kept so a later prop change can be told
|
|
127
|
+
// apart from a re-render and so `recenter` has somewhere to go back to.
|
|
128
|
+
const [initialView] = useState<MapInitialView>(() => ({ bounds, center, zoom }));
|
|
129
|
+
const [layout, setLayout] = useState<{ size: Size; initialCamera: CameraTarget } | null>(null);
|
|
130
|
+
const sizeRef = useRef<Size>({ height: 0, width: 0 });
|
|
131
|
+
const viewStateRef = useRef<ViewState | null>(null);
|
|
132
|
+
const isReadyRef = useRef(false);
|
|
133
|
+
|
|
134
|
+
const [locationRequests, setLocationRequests] = useState(0);
|
|
135
|
+
const [userLocation, setUserLocation] = useState<MapUserLocationValue>({ heading: undefined, location: null });
|
|
136
|
+
const userLocationRef = useRef<LngLat | null>(null);
|
|
137
|
+
const pendingLocateRef = useRef<{ onLocate?: (lngLat: LngLat) => void } | null>(null);
|
|
138
|
+
const [overlays, setOverlays] = useState<Readonly<Record<string, ReactNode>>>({});
|
|
139
|
+
const onViewStateChangeRef = useRef(onViewStateChange);
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
onViewStateChangeRef.current = onViewStateChange;
|
|
142
|
+
}, [onViewStateChange]);
|
|
143
|
+
|
|
144
|
+
const background = useThemeColor(MAP_STYLE_TOKENS.background);
|
|
145
|
+
const border = useThemeColor(MAP_STYLE_TOKENS.border);
|
|
146
|
+
const muted = useThemeColor(MAP_STYLE_TOKENS.muted);
|
|
147
|
+
const mutedForeground = useThemeColor(MAP_STYLE_TOKENS.mutedForeground);
|
|
148
|
+
const themedStyle = useMemo(
|
|
149
|
+
() => buildMapStyle({ background, border, muted, mutedForeground }),
|
|
150
|
+
[background, border, muted, mutedForeground]
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (__DEV__ && hasInitialViewChanged(initialView, { bounds, center, zoom })) {
|
|
155
|
+
console.warn(
|
|
156
|
+
"[DelacourUI.Map] `center`, `zoom` and `bounds` set the initial view only; changing them does not move the camera. Use the map's ref — flyTo, fitBounds — instead."
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}, [initialView, bounds, center, zoom]);
|
|
160
|
+
|
|
161
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
162
|
+
const { width, height } = event.nativeEvent.layout;
|
|
163
|
+
sizeRef.current = { height, width };
|
|
164
|
+
if (width <= 0 || height <= 0) return;
|
|
165
|
+
setLayout(
|
|
166
|
+
(current) =>
|
|
167
|
+
current ?? {
|
|
168
|
+
initialCamera: resolveInitialCamera({ ...initialView, size: { height, width } }),
|
|
169
|
+
size: { height, width },
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
props.onLayout?.(event);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const currentZoom = useCallback(
|
|
176
|
+
(): number => viewStateRef.current?.zoom ?? layout?.initialCamera.zoom ?? 0,
|
|
177
|
+
[layout]
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Every move goes through here so reduced motion is honoured once. A jump
|
|
181
|
+
// is a jump; anything else flies, which zooms out and back in over a long
|
|
182
|
+
// distance instead of smearing the tiles in between.
|
|
183
|
+
const moveCamera = useCallback(
|
|
184
|
+
(target: CameraTarget & { bearing?: number; pitch?: number }, duration: number | undefined) => {
|
|
185
|
+
const camera = cameraRef.current;
|
|
186
|
+
if (camera === null) return;
|
|
187
|
+
const resolved = resolveCameraDuration(duration, isReducedMotion);
|
|
188
|
+
const center: [number, number] = [target.center[0], target.center[1]];
|
|
189
|
+
const { zoom: nextZoom, bearing: nextBearing, pitch: nextPitch } = target;
|
|
190
|
+
if (resolved === 0) {
|
|
191
|
+
camera.jumpTo({ bearing: nextBearing, center, pitch: nextPitch, zoom: nextZoom });
|
|
192
|
+
} else {
|
|
193
|
+
camera.flyTo({ bearing: nextBearing, center, duration: resolved, pitch: nextPitch, zoom: nextZoom });
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
[isReducedMotion]
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const handle = useMemo<MapHandle>(
|
|
200
|
+
() => ({
|
|
201
|
+
flyTo({ center: target, zoom: targetZoom, duration }) {
|
|
202
|
+
moveCamera({ center: target, zoom: targetZoom ?? currentZoom() }, duration);
|
|
203
|
+
},
|
|
204
|
+
fitBounds(box, options) {
|
|
205
|
+
moveCamera(fitBoundsCamera(box, sizeRef.current, options?.padding ?? FIT_PADDING), options?.duration);
|
|
206
|
+
},
|
|
207
|
+
recenter(duration) {
|
|
208
|
+
if (layout !== null) moveCamera({ ...layout.initialCamera, bearing, pitch }, duration);
|
|
209
|
+
},
|
|
210
|
+
async getViewState() {
|
|
211
|
+
const viewState = await mapRef.current?.getViewState();
|
|
212
|
+
if (viewState !== undefined) return toViewState(viewState);
|
|
213
|
+
const fallback = layout?.initialCamera ?? { center: [0, 0], zoom: 0 };
|
|
214
|
+
return { bearing, center: fallback.center, pitch, zoom: fallback.zoom };
|
|
215
|
+
},
|
|
216
|
+
}),
|
|
217
|
+
[bearing, currentZoom, layout, moveCamera, pitch]
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
useImperativeHandle(ref, () => handle, [handle]);
|
|
221
|
+
|
|
222
|
+
const flyToUser = useCallback(
|
|
223
|
+
(location: LngLat, onLocate?: (lngLat: LngLat) => void) => {
|
|
224
|
+
handle.flyTo({ center: location, zoom: Math.max(currentZoom(), LOCATE_MIN_ZOOM) });
|
|
225
|
+
onLocate?.(location);
|
|
226
|
+
},
|
|
227
|
+
[currentZoom, handle]
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// The location layer is on while any part asks for it. Positions come from
|
|
231
|
+
// MapLibre's own location manager, so the map adds no location library.
|
|
232
|
+
const isLocating = locationRequests > 0;
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (!isLocating) return;
|
|
235
|
+
let isCurrent = true;
|
|
236
|
+
const onPosition = ({ coords }: GeolocationPosition) => {
|
|
237
|
+
const location: LngLat = [coords.longitude, coords.latitude];
|
|
238
|
+
userLocationRef.current = location;
|
|
239
|
+
setUserLocation({ heading: resolveHeadingRotation(coords.heading ?? undefined), location });
|
|
240
|
+
|
|
241
|
+
const pending = pendingLocateRef.current;
|
|
242
|
+
if (pending !== null) {
|
|
243
|
+
pendingLocateRef.current = null;
|
|
244
|
+
flyToUser(location, pending.onLocate);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
LocationManager.requestPermissions()
|
|
248
|
+
.then((isGranted) => {
|
|
249
|
+
if (isGranted && isCurrent) LocationManager.addListener(onPosition);
|
|
250
|
+
})
|
|
251
|
+
.catch(() => undefined);
|
|
252
|
+
return () => {
|
|
253
|
+
isCurrent = false;
|
|
254
|
+
LocationManager.removeListener(onPosition);
|
|
255
|
+
};
|
|
256
|
+
}, [flyToUser, isLocating]);
|
|
257
|
+
|
|
258
|
+
const context = useMemo<MapContextValue>(
|
|
259
|
+
() => ({
|
|
260
|
+
handle,
|
|
261
|
+
zoomBy(delta) {
|
|
262
|
+
const target = currentZoom() + delta;
|
|
263
|
+
// Stepping again before the last step lands should step from where it
|
|
264
|
+
// is going, not from where it was.
|
|
265
|
+
const viewState = viewStateRef.current;
|
|
266
|
+
if (viewState !== null) viewStateRef.current = { ...viewState, zoom: target };
|
|
267
|
+
cameraRef.current?.zoomTo(target, { duration: resolveCameraDuration(ZOOM_STEP_DURATION, isReducedMotion) });
|
|
268
|
+
},
|
|
269
|
+
resetBearing() {
|
|
270
|
+
cameraRef.current
|
|
271
|
+
?.setStop({
|
|
272
|
+
bearing: 0,
|
|
273
|
+
duration: resolveCameraDuration(undefined, isReducedMotion),
|
|
274
|
+
easing: "ease",
|
|
275
|
+
pitch: 0,
|
|
276
|
+
})
|
|
277
|
+
.catch(() => undefined);
|
|
278
|
+
},
|
|
279
|
+
requestUserLocation() {
|
|
280
|
+
setLocationRequests((count) => count + 1);
|
|
281
|
+
return () => setLocationRequests((count) => count - 1);
|
|
282
|
+
},
|
|
283
|
+
locate(onLocate) {
|
|
284
|
+
const location = userLocationRef.current;
|
|
285
|
+
if (location === null) {
|
|
286
|
+
pendingLocateRef.current = { onLocate };
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
flyToUser(location, onLocate);
|
|
290
|
+
},
|
|
291
|
+
setOverlay(id, node) {
|
|
292
|
+
setOverlays((current) => {
|
|
293
|
+
if (node === null) {
|
|
294
|
+
const { [id]: _removed, ...rest } = current;
|
|
295
|
+
return rest;
|
|
296
|
+
}
|
|
297
|
+
return { ...current, [id]: node };
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
[currentZoom, flyToUser, handle, isReducedMotion]
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// A style reload — the theme changed — finishes loading the map again; the
|
|
305
|
+
// caller hears about the first load only.
|
|
306
|
+
const onDidFinishLoadingMap = () => {
|
|
307
|
+
if (isReadyRef.current) return;
|
|
308
|
+
isReadyRef.current = true;
|
|
309
|
+
onReady?.();
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const onRegionDidChange = (event: NativeSyntheticEvent<ViewStateChangeEvent>) => {
|
|
313
|
+
const viewState = toViewState(event.nativeEvent);
|
|
314
|
+
viewStateRef.current = viewState;
|
|
315
|
+
onViewStateChangeRef.current?.(viewState);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const onMapPress = (event: NativeSyntheticEvent<PressEvent>) => {
|
|
319
|
+
const [longitude, latitude] = event.nativeEvent.lngLat;
|
|
320
|
+
onPress?.([longitude, latitude]);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const slots = mapVariants();
|
|
324
|
+
|
|
325
|
+
// The native view takes no className, so the root is a plain view the map
|
|
326
|
+
// fills edge to edge. The map mounts once the root has been measured: a
|
|
327
|
+
// `bounds` opening is a centre and zoom worked out for the view's size, and
|
|
328
|
+
// one guessed before layout would open at the wrong zoom. Parts drawn in
|
|
329
|
+
// screen space render on the overlay layer above it, whose empty space
|
|
330
|
+
// passes touches through.
|
|
331
|
+
return (
|
|
332
|
+
<View className={slots.root({ className })} {...props} onLayout={onLayout}>
|
|
333
|
+
{layout === null ? null : (
|
|
334
|
+
<MapProvider value={context}>
|
|
335
|
+
<MapUserLocationProvider value={userLocation}>
|
|
336
|
+
<MapLibreMap
|
|
337
|
+
accessibilityLabel={accessibilityLabel}
|
|
338
|
+
attribution
|
|
339
|
+
attributionPosition={ATTRIBUTION_POSITION}
|
|
340
|
+
compass={false}
|
|
341
|
+
logo={false}
|
|
342
|
+
mapStyle={mapStyle ?? themedStyle}
|
|
343
|
+
onDidFinishLoadingMap={onDidFinishLoadingMap}
|
|
344
|
+
onPress={onPress === undefined ? undefined : onMapPress}
|
|
345
|
+
onRegionDidChange={onRegionDidChange}
|
|
346
|
+
ref={mapRef}
|
|
347
|
+
style={StyleSheet.absoluteFill}
|
|
348
|
+
tintColor={toStyleColor(mutedForeground)}
|
|
349
|
+
{...resolveMapGestures({ isInteractive, isRotatable })}
|
|
350
|
+
>
|
|
351
|
+
<Camera
|
|
352
|
+
initialViewState={{
|
|
353
|
+
bearing,
|
|
354
|
+
center: [layout.initialCamera.center[0], layout.initialCamera.center[1]],
|
|
355
|
+
pitch,
|
|
356
|
+
zoom: layout.initialCamera.zoom,
|
|
357
|
+
}}
|
|
358
|
+
ref={cameraRef}
|
|
359
|
+
/>
|
|
360
|
+
{children}
|
|
361
|
+
</MapLibreMap>
|
|
362
|
+
<View className={slots.overlay()} pointerEvents="box-none">
|
|
363
|
+
{Object.entries(overlays).map(([id, node]) => (
|
|
364
|
+
<Fragment key={id}>{node}</Fragment>
|
|
365
|
+
))}
|
|
366
|
+
</View>
|
|
367
|
+
</MapUserLocationProvider>
|
|
368
|
+
</MapProvider>
|
|
369
|
+
)}
|
|
370
|
+
</View>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* A themed map for showing places and paths — a store finder, a delivery
|
|
376
|
+
* route, a run.
|
|
377
|
+
*
|
|
378
|
+
* MapLibre on both platforms, drawing OpenStreetMap vector tiles in a basemap
|
|
379
|
+
* painted from the theme's tokens — retune the theme and the map follows, light
|
|
380
|
+
* and dark included, and never the system's appearance over the app's. Markers
|
|
381
|
+
* are React views; routes and arcs are lines in the theme's primary.
|
|
382
|
+
*
|
|
383
|
+
* `center`, `zoom` and `bounds` set the **initial** view only. Moving the camera
|
|
384
|
+
* afterwards is the `ref`'s job — `flyTo`, `fitBounds`, `recenter` — and
|
|
385
|
+
* `useMap()` hands the same handle to anything inside.
|
|
386
|
+
*
|
|
387
|
+
* A display component: no search, no geocoding, no directions.
|
|
388
|
+
*
|
|
389
|
+
* @example
|
|
390
|
+
* const ref = useRef<MapHandle>(null);
|
|
391
|
+
*
|
|
392
|
+
* <Map center={[174.776, -41.286]} ref={ref} zoom={13}>
|
|
393
|
+
* <Map.Marker accessibilityLabel="Te Papa" lngLat={[174.782, -41.29]}>
|
|
394
|
+
* <Map.Pin />
|
|
395
|
+
* <Map.Label>Te Papa</Map.Label>
|
|
396
|
+
* </Map.Marker>
|
|
397
|
+
* <Map.Route coordinates={walk} isDashed />
|
|
398
|
+
* <Map.Controls showRecenter />
|
|
399
|
+
* </Map>
|
|
400
|
+
*
|
|
401
|
+
* @example
|
|
402
|
+
* <Map bounds={[166, -47.5, 179, -34]} isInteractive={false}>
|
|
403
|
+
* <Map.Arc from={auckland} to={wellington} />
|
|
404
|
+
* </Map>
|
|
405
|
+
*/
|
|
406
|
+
// biome-ignore lint/suspicious/noShadowRestrictedNames: the component is a map; consumers import it by name from its own subpath
|
|
407
|
+
export const Map = Object.assign(MapRoot, {
|
|
408
|
+
/** A React view pinned to a coordinate. Anchors on its `Map.Pin`'s tip when it has one. */
|
|
409
|
+
Marker: MapMarker,
|
|
410
|
+
/** A teardrop pin in a theme colour, its tip on the marker's coordinate. */
|
|
411
|
+
Pin: MapPin,
|
|
412
|
+
/** A pill of text beside a pin. */
|
|
413
|
+
Label: MapLabel,
|
|
414
|
+
/** A card over a marker, or over a coordinate of its own. */
|
|
415
|
+
Popup: MapPopup,
|
|
416
|
+
/** Zoom, locate, compass and recenter buttons, floated in a corner. */
|
|
417
|
+
Controls: MapControls,
|
|
418
|
+
/** A line through a list of coordinates — a route, a run, a boundary. */
|
|
419
|
+
Route: MapRoute,
|
|
420
|
+
/** A curve from one point to another, for a connection rather than a path. */
|
|
421
|
+
Arc: MapArc,
|
|
422
|
+
/** The device's own position, as the platform's location dot. */
|
|
423
|
+
UserLocation: MapUserLocation,
|
|
424
|
+
displayName: "DelacourUI.Map",
|
|
425
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A point on the map, longitude first — the order GeoJSON and every web map
|
|
3
|
+
* library use, so a coordinate pasted from one lands in the right hemisphere.
|
|
4
|
+
* It is MapLibre's order too, so nothing is converted on the way in.
|
|
5
|
+
*/
|
|
6
|
+
export type LngLat = readonly [longitude: number, latitude: number];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A box on the map. `west > east` is a box that crosses the antimeridian —
|
|
10
|
+
* Fiji, the Chukchi Sea — rather than an inverted one.
|
|
11
|
+
*/
|
|
12
|
+
export type LngLatBounds = readonly [west: number, south: number, east: number, north: number];
|
|
13
|
+
|
|
14
|
+
/** Where the camera is: its centre, zoom, rotation from north and tilt, in degrees. */
|
|
15
|
+
export type ViewState = {
|
|
16
|
+
center: LngLat;
|
|
17
|
+
zoom: number;
|
|
18
|
+
bearing: number;
|
|
19
|
+
pitch: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** What a camera move takes. `duration` in milliseconds; `0` jumps. */
|
|
23
|
+
export type MapFlyToOptions = {
|
|
24
|
+
center: LngLat;
|
|
25
|
+
zoom?: number;
|
|
26
|
+
duration?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** `padding` is the inset kept clear on every side, in points. */
|
|
30
|
+
export type MapFitBoundsOptions = {
|
|
31
|
+
padding?: number;
|
|
32
|
+
duration?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Moves the camera after mount. Props set the initial view only; this is the
|
|
37
|
+
* one way to change it afterwards, from a `ref` on `<Map>` or `useMap()` inside.
|
|
38
|
+
*/
|
|
39
|
+
export type MapHandle = {
|
|
40
|
+
/** Moves the camera to a centre, keeping the zoom unless one is given. */
|
|
41
|
+
flyTo(options: MapFlyToOptions): void;
|
|
42
|
+
/** Frames a box, leaving `padding` points clear on every side. */
|
|
43
|
+
fitBounds(bounds: LngLatBounds, options?: MapFitBoundsOptions): void;
|
|
44
|
+
/** Returns to the view the map mounted with. */
|
|
45
|
+
recenter(duration?: number): void;
|
|
46
|
+
/** Reads where the camera is now. */
|
|
47
|
+
getViewState(): Promise<ViewState>;
|
|
48
|
+
};
|