@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.
@@ -0,0 +1,153 @@
1
+ import type { LngLat, LngLatBounds } from "./map.types";
2
+
3
+ /**
4
+ * A tile's edge in points. MapLibre lays the world out on 512-point tiles, so
5
+ * at zoom `z` the world is `512 × 2^z` points across — the same scale as every
6
+ * web map, which is what lets a zoom copied from one mean the same here.
7
+ */
8
+ export const MAP_TILE_SIZE = 512;
9
+
10
+ /** How far in a box of no size — one point — is framed. A street, not a doorstep. */
11
+ export const MAP_FIT_MAX_ZOOM = 16;
12
+
13
+ /** Where Web Mercator stops: the latitude at which the world is square. */
14
+ const MAX_LATITUDE = 85.0511287798;
15
+
16
+ const DEG = Math.PI / 180;
17
+
18
+ /** A longitude in `[-180, 180)`, however many turns it took to get there. */
19
+ export function normalizeLongitude(longitude: number): number {
20
+ if (longitude >= -180 && longitude < 180) return longitude;
21
+ return ((((longitude + 180) % 360) + 360) % 360) - 180;
22
+ }
23
+
24
+ function mercatorY(latitude: number): number {
25
+ const clamped = Math.max(-MAX_LATITUDE, Math.min(MAX_LATITUDE, latitude));
26
+ return Math.log(Math.tan(Math.PI / 4 + (clamped * DEG) / 2));
27
+ }
28
+
29
+ function latitudeOfMercatorY(y: number): number {
30
+ return (2 * Math.atan(Math.exp(y)) - Math.PI / 2) / DEG;
31
+ }
32
+
33
+ /**
34
+ * The centre and zoom that frame a box in a view of `size`, clearing `padding`
35
+ * points on every side.
36
+ *
37
+ * Worked out here rather than handed to the native fit, for two reasons. A box
38
+ * with `west > east` crosses the antimeridian, and a fit that takes corners
39
+ * frames it the long way round the world. And the opening view has to be a
40
+ * centre and zoom before the map exists, so the same sum serves both.
41
+ *
42
+ * The latitude is measured in Web Mercator, so the centre sits where the box's
43
+ * middle is drawn — poleward of the mean latitude — and a tall box at high
44
+ * latitude is not cropped. Padding that would leave no room is ignored, and an
45
+ * unmeasured view is read as one tile square.
46
+ */
47
+ export function fitBoundsCamera(
48
+ [west, south, east, north]: LngLatBounds,
49
+ size: { width: number; height: number },
50
+ padding = 0
51
+ ): { center: LngLat; zoom: number } {
52
+ const longitudeSpan = west <= east ? east - west : east + 360 - west;
53
+ const longitude = normalizeLongitude(west + longitudeSpan / 2);
54
+ const ySouth = mercatorY(south);
55
+ const yNorth = mercatorY(north);
56
+ const latitude = south === north ? south : latitudeOfMercatorY((ySouth + yNorth) / 2);
57
+
58
+ const width = size.width > 0 ? size.width : MAP_TILE_SIZE;
59
+ const height = size.height > 0 ? size.height : MAP_TILE_SIZE;
60
+ const fits = padding > 0 && width - 2 * padding > 0 && height - 2 * padding > 0;
61
+ const innerWidth = fits ? width - 2 * padding : width;
62
+ const innerHeight = fits ? height - 2 * padding : height;
63
+
64
+ // The fraction of the world's width and height the box takes, and the zoom
65
+ // at which that fraction of a `512 × 2^z` world fills the inner view.
66
+ const xFraction = longitudeSpan / 360;
67
+ const yFraction = Math.abs(yNorth - ySouth) / (2 * Math.PI);
68
+ const zoomX = xFraction > 0 ? Math.log2(innerWidth / (MAP_TILE_SIZE * xFraction)) : Number.POSITIVE_INFINITY;
69
+ const zoomY = yFraction > 0 ? Math.log2(innerHeight / (MAP_TILE_SIZE * yFraction)) : Number.POSITIVE_INFINITY;
70
+ const zoom = Math.max(0, Math.min(MAP_FIT_MAX_ZOOM, zoomX, zoomY));
71
+
72
+ return { center: [longitude, latitude], zoom };
73
+ }
74
+
75
+ /**
76
+ * The smallest box holding every point, `undefined` for none.
77
+ *
78
+ * Antimeridian-safe: the box is the one that leaves out the widest stretch of
79
+ * longitude no point touches. Three stops across Fiji either side of 180° are a
80
+ * box a few degrees wide, not one spanning the whole world the long way round.
81
+ */
82
+ export function boundsOf(coordinates: readonly LngLat[]): LngLatBounds | undefined {
83
+ if (coordinates.length === 0) return undefined;
84
+
85
+ let south = Number.POSITIVE_INFINITY;
86
+ let north = Number.NEGATIVE_INFINITY;
87
+ for (const [, latitude] of coordinates) {
88
+ south = Math.min(south, latitude);
89
+ north = Math.max(north, latitude);
90
+ }
91
+
92
+ const longitudes = [...new Set(coordinates.map(([longitude]) => normalizeLongitude(longitude)))].sort(
93
+ (a, b) => a - b
94
+ );
95
+ const first = longitudes[0] as number;
96
+ const last = longitudes.at(-1) as number;
97
+
98
+ // The gap across the antimeridian, from the easternmost back round to the
99
+ // westernmost. If no gap between neighbours beats it, the box is the plain
100
+ // one; otherwise it starts just east of the widest gap.
101
+ let widestGap = first + 360 - last;
102
+ let west = first;
103
+ let east = last;
104
+ for (let index = 0; index < longitudes.length - 1; index++) {
105
+ const here = longitudes[index] as number;
106
+ const next = longitudes[index + 1] as number;
107
+ if (next - here > widestGap) {
108
+ widestGap = next - here;
109
+ west = next;
110
+ east = here;
111
+ }
112
+ }
113
+
114
+ return [west, south, east, north];
115
+ }
116
+
117
+ /**
118
+ * A curve from one point to another, sampled into a polyline.
119
+ *
120
+ * A quadratic Bézier in plain longitude and latitude, bowed to the left of the
121
+ * direction of travel by `curvature` times the chord's length at its apex —
122
+ * negative bows right. It is deliberately not a great circle: it reads as
123
+ * "from here to there" at city and country scale, where a geodesic is a
124
+ * straight line anyway. The endpoints are the inputs themselves, not
125
+ * recomputed, so a marker on either end sits exactly on the line. It does not
126
+ * take the short way across the antimeridian; an arc from Auckland to Los
127
+ * Angeles goes round by Europe.
128
+ */
129
+ export function arcCoordinates(from: LngLat, to: LngLat, curvature = 0.2, samples = 64): LngLat[] {
130
+ const [x0, y0] = from;
131
+ const [x1, y1] = to;
132
+ const dx = x1 - x0;
133
+ const dy = y1 - y0;
134
+ const length = Math.hypot(dx, dy);
135
+ if (length === 0) return [from, to];
136
+
137
+ const count = Math.max(2, Math.floor(samples));
138
+ // The control point sits off the chord's midpoint along its left normal.
139
+ // A quadratic Bézier reaches half way to its control point, so twice the
140
+ // apex height puts the apex at `curvature × length`.
141
+ const controlX = (x0 + x1) / 2 - (dy / length) * 2 * curvature * length;
142
+ const controlY = (y0 + y1) / 2 + (dx / length) * 2 * curvature * length;
143
+
144
+ const points: LngLat[] = [from];
145
+ for (let index = 1; index < count - 1; index++) {
146
+ const t = index / (count - 1);
147
+ const u = 1 - t;
148
+ points.push([u * u * x0 + 2 * u * t * controlX + t * t * x1, u * u * y0 + 2 * u * t * controlY + t * t * y1]);
149
+ }
150
+ points.push(to);
151
+
152
+ return points;
153
+ }
@@ -0,0 +1,45 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { ViewProps } from "react-native";
3
+ import { Surface } from "../surface";
4
+ import { Text } from "../text";
5
+ import { type MapLabelSide, type MapLabelSize, type MapLabelTone, mapVariants } from "./map.variants";
6
+
7
+ export type MapLabelProps = Omit<ViewProps, "children"> & {
8
+ /**
9
+ * Which side of the pin it sits on — the side it keeps its gap from. Write
10
+ * it after the pin for `bottom` and before it for `top`; a marker is a column
11
+ * and draws its children in order.
12
+ */
13
+ side?: MapLabelSide;
14
+ size?: MapLabelSize;
15
+ /** The text's colour. The pill is the card either way. */
16
+ tone?: MapLabelTone;
17
+ className?: string;
18
+ children: ReactNode;
19
+ };
20
+
21
+ /**
22
+ * A pill of text beside a pin — the place's name, a price, an arrival time.
23
+ *
24
+ * A card-coloured `Surface` with a hairline, so it reads over satellite-dark
25
+ * water and pale streets alike, on one line that truncates rather than wraps.
26
+ */
27
+ export function MapLabel({
28
+ side = "bottom",
29
+ size = "sm",
30
+ tone = "default",
31
+ className,
32
+ children,
33
+ ...props
34
+ }: MapLabelProps): ReactElement {
35
+ const slots = mapVariants({ labelSide: side, labelSize: size, labelTone: tone });
36
+
37
+ return (
38
+ <Surface className={slots.label({ className })} padding="none" variant="default" {...props}>
39
+ <Text className={slots.labelText()} numberOfLines={1}>
40
+ {children}
41
+ </Text>
42
+ </Surface>
43
+ );
44
+ }
45
+ MapLabel.displayName = "DelacourUI.Map.Label";
@@ -0,0 +1,106 @@
1
+ import { Marker } from "@maplibre/maplibre-react-native";
2
+ import { type ReactElement, type ReactNode, useMemo, useState } from "react";
3
+ import { type LayoutChangeEvent, View, type ViewProps } from "react-native";
4
+ import { type MapMarkerContextValue, type MapMarkerFrame, MapMarkerProvider, useMapPart } from "./map.context";
5
+ import type { LngLat } from "./map.types";
6
+ import {
7
+ type MapMarkerAnchor,
8
+ mapVariants,
9
+ resolveMarkerAnchor,
10
+ resolveMarkerOffset,
11
+ resolvePinTipAnchor,
12
+ } from "./map.variants";
13
+
14
+ export type MapMarkerProps = Omit<ViewProps, "children"> & {
15
+ /** Where it sits. */
16
+ lngLat: LngLat;
17
+ /**
18
+ * Which point of the marker sits on `lngLat`. Omitted, a marker holding a
19
+ * `Map.Pin` anchors on the pin's tip and any other marker on its centre.
20
+ */
21
+ anchor?: MapMarkerAnchor;
22
+ /** Makes the marker a button. */
23
+ onPress?: () => void;
24
+ /** What a screen reader announces. Name the place, not the pin. */
25
+ accessibilityLabel?: string;
26
+ className?: string;
27
+ children?: ReactNode;
28
+ };
29
+
30
+ /**
31
+ * A React view pinned to a coordinate.
32
+ *
33
+ * Anchors on the tip of the `Map.Pin` inside it, wherever the pin sits — so a
34
+ * marker can hold a popup over its pin, or a label under it, and the tip still
35
+ * lands on the coordinate with no offset worked out at the call site. The pin
36
+ * has to be a direct child for its position to be measured.
37
+ *
38
+ * A marker with `onPress` is a button, announced by its `accessibilityLabel`,
39
+ * and its tap does not reach the map's `onPress`.
40
+ *
41
+ * `testID` goes on the native marker and, suffixed `-content`, on the view
42
+ * inside it, which is the one an e2e selector can always find and tap.
43
+ */
44
+ export function MapMarker({
45
+ lngLat,
46
+ anchor,
47
+ onPress,
48
+ accessibilityLabel,
49
+ className,
50
+ children,
51
+ testID,
52
+ ...props
53
+ }: MapMarkerProps): ReactElement {
54
+ useMapPart("Map.Marker");
55
+ const [size, setSize] = useState<{ width: number; height: number } | null>(null);
56
+ const [pin, setPin] = useState<MapMarkerFrame | null>(null);
57
+
58
+ const anchorPoint =
59
+ anchor !== undefined
60
+ ? resolveMarkerAnchor(anchor)
61
+ : ((size !== null && pin !== null ? resolvePinTipAnchor({ marker: size, pin }) : undefined) ??
62
+ resolveMarkerAnchor("center"));
63
+ const offset = size === null ? undefined : resolveMarkerOffset(anchorPoint, size);
64
+
65
+ const markerContext = useMemo<MapMarkerContextValue>(() => ({ reportPin: setPin }), []);
66
+
67
+ const onLayout = (event: LayoutChangeEvent) => {
68
+ const { width, height } = event.nativeEvent.layout;
69
+ setSize((current) => (current?.width === width && current.height === height ? current : { height, width }));
70
+ };
71
+
72
+ // MapLibre places a marker by a named anchor plus a pixel offset, so the
73
+ // marker is centred on its coordinate and moved until the anchor point —
74
+ // a pin's tip, wherever it sits — lands there instead.
75
+ return (
76
+ <Marker
77
+ accessibilityLabel={accessibilityLabel}
78
+ accessibilityRole={onPress === undefined ? undefined : "button"}
79
+ anchor="center"
80
+ lngLat={[lngLat[0], lngLat[1]]}
81
+ offset={offset}
82
+ onPress={
83
+ onPress === undefined
84
+ ? undefined
85
+ : (event) => {
86
+ event.stopPropagation();
87
+ onPress();
88
+ }
89
+ }
90
+ testID={testID}
91
+ {...props}
92
+ >
93
+ <MapMarkerProvider value={markerContext}>
94
+ <View
95
+ className={mapVariants().marker({ className })}
96
+ collapsable={false}
97
+ onLayout={onLayout}
98
+ testID={testID === undefined ? undefined : `${testID}-content`}
99
+ >
100
+ {children}
101
+ </View>
102
+ </MapMarkerProvider>
103
+ </Marker>
104
+ );
105
+ }
106
+ MapMarker.displayName = "DelacourUI.Map.Marker";
@@ -0,0 +1,55 @@
1
+ import { type ReactElement, useEffect } from "react";
2
+ import { type LayoutChangeEvent, View, type ViewProps } from "react-native";
3
+ import Svg, { Circle, Path } from "react-native-svg";
4
+ import { useThemeColor } from "../../hooks/use-theme-color";
5
+ import { useMapMarkerContext } from "./map.context";
6
+ import { MAP_PIN_DIMENSIONS, MAP_PIN_TONE_TOKENS, type MapPinSize, type MapPinTone, mapPinPath } from "./map.variants";
7
+
8
+ export type MapPinProps = Omit<ViewProps, "children"> & {
9
+ /** `primary` by default; `default` is the theme's foreground; `destructive` for a problem. */
10
+ tone?: MapPinTone;
11
+ size?: MapPinSize;
12
+ className?: string;
13
+ };
14
+
15
+ /**
16
+ * A teardrop pin in a theme colour, a dot of the colour's own foreground
17
+ * punched in its head.
18
+ *
19
+ * Inside a `Map.Marker` it reports where it sits, and the marker anchors on its
20
+ * tip. It is drawn rather than an icon because a pin's tip is a point on the
21
+ * map: a glyph centred in a square would put the place a few points off.
22
+ * Hidden from assistive technology — the marker carries the name.
23
+ */
24
+ export function MapPin({ tone = "primary", size = "md", className, onLayout, ...props }: MapPinProps): ReactElement {
25
+ const marker = useMapMarkerContext();
26
+ const { fill, dot } = MAP_PIN_TONE_TOKENS[tone];
27
+ const fillColor = useThemeColor(fill);
28
+ const dotColor = useThemeColor(dot);
29
+ const { width, height } = MAP_PIN_DIMENSIONS[size];
30
+ const reportPin = marker?.reportPin;
31
+
32
+ useEffect(() => (reportPin === undefined ? undefined : () => reportPin(null)), [reportPin]);
33
+
34
+ const onPinLayout = (event: LayoutChangeEvent) => {
35
+ reportPin?.(event.nativeEvent.layout);
36
+ onLayout?.(event);
37
+ };
38
+
39
+ return (
40
+ <View
41
+ accessibilityElementsHidden
42
+ className={className}
43
+ importantForAccessibility="no-hide-descendants"
44
+ onLayout={onPinLayout}
45
+ style={{ height, width }}
46
+ {...props}
47
+ >
48
+ <Svg height={height} viewBox={`0 0 ${width} ${height}`} width={width}>
49
+ <Path d={mapPinPath(width, height)} fill={fillColor} />
50
+ <Circle cx={width / 2} cy={width / 2} fill={dotColor} r={width / 6} />
51
+ </Svg>
52
+ </View>
53
+ );
54
+ }
55
+ MapPin.displayName = "DelacourUI.Map.Pin";
@@ -0,0 +1,83 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import Svg, { Path } from "react-native-svg";
4
+ import { useThemeColor } from "../../hooks/use-theme-color";
5
+ import { Surface } from "../surface";
6
+ import { Text } from "../text";
7
+ import { useMapMarkerContext, useMapPart } from "./map.context";
8
+ import type { LngLat } from "./map.types";
9
+ import { mapVariants } from "./map.variants";
10
+ import { MapMarker } from "./map-marker";
11
+
12
+ type MapPopupBaseProps = Omit<ViewProps, "children"> & {
13
+ /** A heading over the content. */
14
+ title?: string;
15
+ className?: string;
16
+ children?: ReactNode;
17
+ };
18
+
19
+ /**
20
+ * Inside a `Map.Marker` a popup sits over the marker's pin and needs no
21
+ * coordinate; on its own it needs one. The union says so, so a popup that
22
+ * would have nowhere to go is a type error rather than an empty map.
23
+ */
24
+ export type MapPopupProps = MapPopupBaseProps & ({ lngLat: LngLat } | { lngLat?: undefined });
25
+
26
+ /** The caret's box in points: wide and short, pointing down at the place. */
27
+ const CARET = { width: 14, height: 8 } as const;
28
+
29
+ /**
30
+ * A card over a place — a name, a line of detail, an action.
31
+ *
32
+ * Inside a `Map.Marker`, write it before the `Map.Pin`: the marker is a column,
33
+ * so the card lands over the pin and the marker still anchors on the pin's tip.
34
+ * Outside one, give it a `lngLat` and it becomes a marker of its own, its caret
35
+ * on the coordinate.
36
+ *
37
+ * It is a React view rather than the platform's native callout, so it takes
38
+ * the theme — a card-coloured `Surface` with a caret in the same fill and
39
+ * hairline. Show and hide it by rendering it or not; it holds no open state of
40
+ * its own, because the caller is the one who knows which place is selected.
41
+ */
42
+ export function MapPopup({ lngLat, title, className, children, ...props }: MapPopupProps): ReactElement | null {
43
+ useMapPart("Map.Popup");
44
+ const marker = useMapMarkerContext();
45
+ const card = useThemeColor("card");
46
+ const border = useThemeColor("border");
47
+ const slots = mapVariants();
48
+
49
+ const body = (
50
+ <View accessibilityRole="summary" {...props}>
51
+ <Surface className={slots.popup({ className })} padding="sm" variant="default">
52
+ {title === undefined ? null : <Text className={slots.popupTitle()}>{title}</Text>}
53
+ {children}
54
+ </Surface>
55
+ <View className={slots.popupCaret()}>
56
+ <Svg height={CARET.height} width={CARET.width}>
57
+ <Path
58
+ d={`M0 0 L${CARET.width / 2} ${CARET.height} L${CARET.width} 0`}
59
+ fill={card}
60
+ stroke={border}
61
+ strokeWidth={1}
62
+ />
63
+ </Svg>
64
+ </View>
65
+ </View>
66
+ );
67
+
68
+ if (marker !== null) return body;
69
+
70
+ if (lngLat === undefined) {
71
+ if (__DEV__) {
72
+ console.warn("[DelacourUI.Map.Popup] A popup outside a Map.Marker needs a `lngLat`; it was not drawn.");
73
+ }
74
+ return null;
75
+ }
76
+
77
+ return (
78
+ <MapMarker anchor="bottom" lngLat={lngLat}>
79
+ {body}
80
+ </MapMarker>
81
+ );
82
+ }
83
+ MapPopup.displayName = "DelacourUI.Map.Popup";
@@ -0,0 +1,87 @@
1
+ import { GeoJSONSource, Layer } from "@maplibre/maplibre-react-native";
2
+ import { type ReactElement, useId, useMemo } from "react";
3
+ import { useThemeColor } from "../../hooks/use-theme-color";
4
+ import { useMapPart } from "./map.context";
5
+ import type { LngLat } from "./map.types";
6
+ import { MAP_ROUTE_TOKEN, resolveRouteDashArray, toStyleColor } from "./map.variants";
7
+
8
+ export type MapRouteProps = {
9
+ /** The points the line runs through, in order. */
10
+ coordinates: readonly LngLat[];
11
+ /** A theme token or a literal colour. Defaults to `primary`. */
12
+ color?: string;
13
+ /** Stroke width in points. Defaults to `3`. */
14
+ width?: number;
15
+ /** Dashes scaled to the stroke — for a planned or estimated leg. */
16
+ isDashed?: boolean;
17
+ /** `0`–`1`. Defaults to `1`. */
18
+ opacity?: number;
19
+ /** A tap on the line itself. */
20
+ onPress?: () => void;
21
+ };
22
+
23
+ /** Points of slack round a line a tap still lands on — a 3-point line is a hard target. */
24
+ const ROUTE_HITBOX = 12;
25
+
26
+ /**
27
+ * A line through a list of coordinates — a route, a run, a boundary.
28
+ *
29
+ * Round caps and joins, in the theme's `primary` unless given a colour, which
30
+ * may be a token as well as a literal. Drawn by the map itself — a GeoJSON
31
+ * source and a line layer — so it sits over the basemap and under every
32
+ * marker, and `opacity` is the layer's own rather than folded into the colour.
33
+ */
34
+ export function MapRoute({
35
+ coordinates,
36
+ color = MAP_ROUTE_TOKEN,
37
+ width = 3,
38
+ isDashed = false,
39
+ opacity = 1,
40
+ onPress,
41
+ }: MapRouteProps): ReactElement {
42
+ useMapPart("Map.Route");
43
+ const stroke = toStyleColor(useThemeColor(color));
44
+ const id = `delacour-route-${useId()}`;
45
+ const data = useMemo<GeoJSON.Feature<GeoJSON.LineString>>(
46
+ () => ({
47
+ geometry: { coordinates: coordinates.map(([longitude, latitude]) => [longitude, latitude]), type: "LineString" },
48
+ properties: {},
49
+ type: "Feature",
50
+ }),
51
+ [coordinates]
52
+ );
53
+ const dashArray = resolveRouteDashArray(isDashed);
54
+
55
+ return (
56
+ <GeoJSONSource
57
+ data={data}
58
+ hitbox={
59
+ onPress === undefined
60
+ ? undefined
61
+ : { bottom: ROUTE_HITBOX, left: ROUTE_HITBOX, right: ROUTE_HITBOX, top: ROUTE_HITBOX }
62
+ }
63
+ id={id}
64
+ onPress={
65
+ onPress === undefined
66
+ ? undefined
67
+ : (event) => {
68
+ event.stopPropagation();
69
+ onPress();
70
+ }
71
+ }
72
+ >
73
+ <Layer
74
+ id={`${id}-line`}
75
+ layout={{ "line-cap": "round", "line-join": "round" }}
76
+ paint={{
77
+ ...(stroke === undefined ? {} : { "line-color": stroke }),
78
+ ...(dashArray === undefined ? {} : { "line-dasharray": dashArray }),
79
+ "line-opacity": Math.min(1, Math.max(0, opacity)),
80
+ "line-width": width,
81
+ }}
82
+ type="line"
83
+ />
84
+ </GeoJSONSource>
85
+ );
86
+ }
87
+ MapRoute.displayName = "DelacourUI.Map.Route";
@@ -0,0 +1,100 @@
1
+ import { GeoJSONSource, Layer, Marker } from "@maplibre/maplibre-react-native";
2
+ import { type ReactElement, useEffect, useMemo } from "react";
3
+ import { View } from "react-native";
4
+ import Svg, { Path } from "react-native-svg";
5
+ import { useThemeColor } from "../../hooks/use-theme-color";
6
+ import { useMapPart, useMapUserLocation } from "./map.context";
7
+ import { MAP_ROUTE_TOKEN, mapVariants, toStyleColor } from "./map.variants";
8
+
9
+ export type MapUserLocationProps = {
10
+ /** Draws a cone over the dot in the direction the device faces, when it knows. */
11
+ showsHeading?: boolean;
12
+ };
13
+
14
+ /** The heading cone's box, in points — wider than the dot, so it shows round it. */
15
+ const CONE = { width: 44, height: 44 } as const;
16
+
17
+ /** The dot's radius and the ring of page colour round it, in points. */
18
+ const DOT_RADIUS = 6;
19
+ const RING_WIDTH = 3;
20
+
21
+ const SOURCE_ID = "delacour-user-location";
22
+
23
+ /**
24
+ * The device's own position, as a dot in the theme's primary ringed in the
25
+ * page colour.
26
+ *
27
+ * Mounting it turns the map's location updates on, which asks for foreground
28
+ * location permission the first time; the app's config has to declare why.
29
+ * The position arrives through the map rather than a location library, so the
30
+ * map adds no dependency for it.
31
+ *
32
+ * The dot is drawn by the map — two circle layers — so it takes the theme
33
+ * where the platform's own puck would not. `showsHeading` adds a cone in the
34
+ * same primary, turned to the device's heading; while the heading is unknown
35
+ * it is not drawn at all rather than pointing north by default.
36
+ */
37
+ export function MapUserLocation({ showsHeading = false }: MapUserLocationProps): ReactElement | null {
38
+ const { requestUserLocation } = useMapPart("Map.UserLocation");
39
+ const { location, heading } = useMapUserLocation();
40
+ const primary = useThemeColor(MAP_ROUTE_TOKEN);
41
+ const background = useThemeColor("background");
42
+ const dotColor = toStyleColor(primary);
43
+ const ringColor = toStyleColor(background);
44
+
45
+ useEffect(() => requestUserLocation(), [requestUserLocation]);
46
+
47
+ const longitude = location?.[0];
48
+ const latitude = location?.[1];
49
+ const data = useMemo<GeoJSON.Point | null>(
50
+ () =>
51
+ longitude === undefined || latitude === undefined ? null : { coordinates: [longitude, latitude], type: "Point" },
52
+ [longitude, latitude]
53
+ );
54
+
55
+ if (data === null || longitude === undefined || latitude === undefined) return null;
56
+
57
+ const { width, height } = CONE;
58
+ const cx = width / 2;
59
+ const cy = height / 2;
60
+
61
+ return (
62
+ <>
63
+ <GeoJSONSource data={data} id={SOURCE_ID}>
64
+ <Layer
65
+ id={`${SOURCE_ID}-ring`}
66
+ paint={{
67
+ ...(ringColor === undefined ? {} : { "circle-color": ringColor }),
68
+ "circle-pitch-alignment": "map",
69
+ "circle-radius": DOT_RADIUS + RING_WIDTH,
70
+ }}
71
+ type="circle"
72
+ />
73
+ <Layer
74
+ id={`${SOURCE_ID}-dot`}
75
+ paint={{
76
+ ...(dotColor === undefined ? {} : { "circle-color": dotColor }),
77
+ "circle-pitch-alignment": "map",
78
+ "circle-radius": DOT_RADIUS,
79
+ }}
80
+ type="circle"
81
+ />
82
+ </GeoJSONSource>
83
+ {!showsHeading || heading === undefined ? null : (
84
+ <Marker anchor="center" lngLat={[longitude, latitude]} pointerEvents="none">
85
+ <View
86
+ accessibilityElementsHidden
87
+ className={mapVariants().userHeading()}
88
+ importantForAccessibility="no-hide-descendants"
89
+ style={{ height, transform: [{ rotate: `${heading}deg` }], width }}
90
+ >
91
+ <Svg height={height} width={width}>
92
+ <Path d={`M${cx} ${cy} L${cx - 9} 2 Q${cx} -1 ${cx + 9} 2 Z`} fill={primary} opacity={0.35} />
93
+ </Svg>
94
+ </View>
95
+ </Marker>
96
+ )}
97
+ </>
98
+ );
99
+ }
100
+ MapUserLocation.displayName = "DelacourUI.Map.UserLocation";