@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,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";
|