@delacour/react-native-ui 0.2.0-alpha.20261015134956 → 0.2.0-alpha.20261016011950

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.2.0-alpha.20261015134956",
3
+ "version": "0.2.0-alpha.20261016011950",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,6 +58,7 @@
58
58
  "./kpi": "./src/components/kpi/index.ts",
59
59
  "./label": "./src/components/label/index.ts",
60
60
  "./list-group": "./src/components/list-group/index.ts",
61
+ "./map": "./src/components/map/index.ts",
61
62
  "./menu": "./src/components/menu/index.ts",
62
63
  "./meter": "./src/components/meter/index.ts",
63
64
  "./overlay": "./src/components/overlay/index.ts",
@@ -115,6 +116,7 @@
115
116
  "tailwind-variants": "^3.3.1"
116
117
  },
117
118
  "peerDependencies": {
119
+ "@maplibre/maplibre-react-native": "^11.5.0",
118
120
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
119
121
  "@delacour/react-native-bottom-sheet": ">=0.1.0",
120
122
  "@delacour/react-native-charts": ">=0.1.0",
@@ -136,6 +138,9 @@
136
138
  "uniwind": "^1.11.0"
137
139
  },
138
140
  "peerDependenciesMeta": {
141
+ "@maplibre/maplibre-react-native": {
142
+ "optional": true
143
+ },
139
144
  "@delacour/react-native-bottom-sheet": {
140
145
  "optional": true
141
146
  },
@@ -156,6 +161,7 @@
156
161
  }
157
162
  },
158
163
  "devDependencies": {
164
+ "@maplibre/maplibre-react-native": "^11.5.0",
159
165
  "@delacour/biome-config": "workspace:*",
160
166
  "@delacour/react-native-bottom-sheet": "workspace:*",
161
167
  "@delacour/react-native-charts": "workspace:*",
@@ -0,0 +1,172 @@
1
+ # Map
2
+
3
+ A themed map for showing places and paths — a store finder, a delivery route, a
4
+ run. MapLibre on both platforms, through `@maplibre/maplibre-react-native`, on a
5
+ basemap painted from the theme's tokens.
6
+ Compound root plus eight parts: `Marker`, `Pin`, `Label`, `Popup`, `Controls`,
7
+ `Route`, `Arc`, `UserLocation`. A display component: no search, no geocoding,
8
+ no directions.
9
+
10
+ `import { Map, type MapHandle } from "@delacour/react-native-ui/map";`
11
+
12
+ ## Files
13
+
14
+ | File | What it holds |
15
+ | --- | --- |
16
+ | `index.ts` | → `@delacour/react-native-ui/map` |
17
+ | `map.tsx` | Root, the camera handle, the overlay layer, and the `Object.assign` compound surface |
18
+ | `map-marker.tsx` | `Map.Marker` — a React view on a coordinate, anchored on its pin's tip |
19
+ | `map-pin.tsx` | `Map.Pin` — the drawn teardrop, which reports its frame to the marker |
20
+ | `map-label.tsx` | `Map.Label` — a pill of text on `Surface` |
21
+ | `map-popup.tsx` | `Map.Popup` — a card with a caret, inside a marker or on its own coordinate |
22
+ | `map-controls.tsx` | `Map.Controls` — zoom, locate, compass and recenter, on the overlay layer |
23
+ | `map-route.tsx` | `Map.Route` — a GeoJSON source and a line layer |
24
+ | `map-arc.tsx` | `Map.Arc` — a sampled quadratic curve, drawn the same way |
25
+ | `map-user-location.tsx` | `Map.UserLocation` — a themed dot drawn as circle layers, plus a heading cone |
26
+ | `map.context.tsx` | `MapProvider`, `useMap()`, `useMapContext()`, `useMapPart()`, the user-location and marker contexts |
27
+ | `map.types.ts` | `LngLat`, `LngLatBounds`, `ViewState`, `MapHandle` — no RN imports |
28
+ | `map-geometry.ts` | `fitBoundsCamera`, `boundsOf`, `arcCoordinates`, `normalizeLongitude` — pure |
29
+ | `map-geometry.test.ts` | |
30
+ | `map.variants.ts` | The slotted `tv()`, the axes, the pin outline, anchors, the themed basemap style — type imports only |
31
+ | `map.variants.test.ts` | |
32
+
33
+ `src/no-maplibre.test.ts`, one level up, holds the import-graph rule below.
34
+
35
+ ## Design
36
+
37
+ - **`@maplibre/maplibre-react-native` is an optional peer, and only this folder may
38
+ import it.** It is the one native module the library adds for a single component.
39
+ Expo does not bundle it, so it is not in the root catalog; each workspace that
40
+ needs it repeats the range. An app that never imports `/map` never installs it —
41
+ but only while nothing else reaches it, so `src/no-maplibre.test.ts` reads the
42
+ whole tree as text and fails by file name for any import of an `@maplibre/`
43
+ package, or of this folder, from outside it. Types come from the RN package's
44
+ own re-exports, never `@maplibre/maplibre-gl-style-spec`, which is only a
45
+ transitive dependency.
46
+ - **MapLibre over the platform maps.** Apple Maps takes no style beyond light and
47
+ dark, and Google Maps needs a key and styles Android alone. MapLibre draws the
48
+ same vector basemap on both, from a style this folder writes, so a retuned theme
49
+ retunes the map on both platforms.
50
+ - **Coordinates are `[lng, lat]`, everywhere a caller sees one.** That is GeoJSON's
51
+ order, every web map library's, and MapLibre's, so nothing is converted. The
52
+ public types are `readonly`; MapLibre's are mutable, so a coordinate is copied
53
+ into a fresh tuple at the boundary.
54
+ - **Props set the initial view only; the ref moves the camera.** A map that
55
+ re-centred on every `center` change would fight a user mid-pan the first time a
56
+ parent re-rendered with a new array. The view is captured on mount; a change to
57
+ `center`, `zoom` or `bounds` afterwards warns in development
58
+ (`hasInitialViewChanged`, compared by value so a fresh array is not a change).
59
+ `flyTo`, `fitBounds`, `recenter` and `getViewState` are on the `ref`, and
60
+ `useMap()` hands the same handle to anything inside. The handle drives a
61
+ MapLibre `Camera` — `flyTo` for a move, `jumpTo` when the duration is `0`.
62
+ - **The map mounts after its root is measured.** A `bounds` opening is a centre and
63
+ zoom worked out for the view's size (`resolveInitialCamera`), and one guessed
64
+ before layout opens at the wrong zoom. The root paints `bg-muted` for that one
65
+ frame.
66
+ - **Zoom is the web-map scale.** `0` is the world and each step halves it, on
67
+ MapLibre's 512-point tiles, so a zoom copied from a web map means the same here.
68
+ - **Bounds are framed here, not by the native fit, and are antimeridian-safe.** A
69
+ box with `west > east` crosses 180°; a fit that takes corners frames it the long
70
+ way round the world. `fitBoundsCamera` measures the box eastwards and in Web
71
+ Mercator, and returns a centre and zoom for the camera, so `fitBounds` and a
72
+ `bounds` opening are the same sum. `boundsOf` picks the box that leaves out the
73
+ widest stretch of longitude no point touches, so three stops across Fiji are a
74
+ box a few degrees wide rather than one spanning the world.
75
+ - **A marker anchors on its pin's tip.** `Map.Pin` reports its frame inside the
76
+ marker and `resolvePinTipAnchor` turns it into an anchor point, so a popup over
77
+ the pin or a label under it changes the marker's size without moving the place
78
+ it marks. MapLibre takes a named anchor and a pixel offset rather than a point,
79
+ so the marker is anchored at its centre and moved by `resolveMarkerOffset`. An
80
+ explicit `anchor` wins; a marker with no pin anchors on its centre. The pin must
81
+ be a direct child for its frame to be relative to the marker.
82
+ - **A marker's `testID` is repeated on its content, suffixed `-content`.** The
83
+ native annotation the `testID` lands on is not always in the view hierarchy a
84
+ test runner walks; `map-places-museum-content` is the marker's own view, which
85
+ the runner can find and tap.
86
+ - **A marker's press is its own.** It stops propagation, so a tap on a marker does
87
+ not also reach the map's `onPress`; a route's press does the same through its
88
+ source.
89
+ - **The pin is drawn, not an icon.** A pin's tip is a point on the map; a glyph
90
+ centred in a square would mark a place a few points off. `mapPinPath` starts at
91
+ the tip, and the dot in its head is the fill's own `-foreground`, so the pair holds
92
+ contrast in every theme with one choice. Hidden from assistive technology — the
93
+ marker carries the name.
94
+ - **A popup is a React view, not the platform callout.** The native callout takes no
95
+ theme; this is a `Surface` with a caret drawn in `card` and stroked in `border`.
96
+ Inside a marker it sits in the marker's column over the pin; outside one it needs a
97
+ `lngLat` — the props are a union, so a popup with nowhere to go is a type error —
98
+ and becomes a bottom-anchored marker of its own. It holds no open state: the caller
99
+ knows which place is selected and renders it or not.
100
+ - **A label's side is a gap, not a position.** A marker is a column and React Native
101
+ has no `order`, so `side` picks which margin the label keeps from the pin and the
102
+ caller writes it before or after the pin to match. Card fill with a hairline so it
103
+ reads over any basemap; one line, truncated.
104
+ - **Controls live on a layer above the map, not inside it.** The native map renders
105
+ only map features as children. `Map.Controls` renders nothing in place and
106
+ registers its buttons with the root (`setOverlay`), which draws them on an
107
+ `absolute inset-0` layer beside the map with `pointerEvents="box-none"`, so a pan
108
+ on empty space still reaches the map. Each group is a `Surface` of ghost
109
+ `Button`s at `icon-sm`, each labelled by what it does.
110
+ - **The zoom buttons step from where the camera is going.** The zoom is read from
111
+ the last `onRegionDidChange`; a press writes its target back at once, so a second
112
+ press before the first lands steps on from it rather than repeating it.
113
+ - **Location comes through the map, not a location library.** `showLocate` and
114
+ `Map.UserLocation` each request location updates, counted so they stop when the
115
+ last one lets go, and MapLibre's `LocationManager` asks for permission and
116
+ delivers the position. No `expo-location`. A locate press before the first fix
117
+ waits for it rather than flying nowhere, and flies in to at least zoom 14.
118
+ - **The location dot is drawn, so it takes the theme.** Two circle layers — a
119
+ `background` ring and a `primary` dot — rather than MapLibre's own puck, whose
120
+ colours are fixed. `showsHeading` adds a cone in `primary` at 35%, turned to the
121
+ device heading in screen space, which matches the map's north while the map does
122
+ not rotate, the default. An unknown heading draws nothing rather than pointing
123
+ north.
124
+ - **The basemap is painted from tokens.** `buildMapStyle` puts land on
125
+ `background`, water and green space on `muted`, roads, buildings and boundaries
126
+ on `border`, and labels on `muted-foreground` haloed in `background`. MapLibre
127
+ parses three- and six-digit hex but not eight, so `toStyleColor` writes an alpha
128
+ token as `rgba()`. A token that does not resolve drops the layers it paints
129
+ rather than leaving them in MapLibre's default black. A test pins every token it
130
+ reads to both theme variants. Light and dark follow Uniwind's theme, never the
131
+ system's, because the style is the theme.
132
+ - **The tiles are OpenFreeMap's, and the attribution stays.** OpenMapTiles-schema
133
+ vector tiles of OpenStreetMap, free and keyless (`MAP_DEFAULT_BASEMAP`). The data
134
+ is ODbL, so MapLibre's attribution button is on, bottom left — the corner the
135
+ controls leave free by default. `buildMapStyle` takes another OpenMapTiles source
136
+ and glyph server; `mapStyle` takes any style at all.
137
+ - **Routes and arcs are layers, `primary` unless told otherwise.** A GeoJSON source
138
+ and a line layer each, keyed by `useId`, so they sit over the basemap and under
139
+ every marker. `opacity` is the layer's own `line-opacity`. A dashed route's
140
+ dashes are `[2, 2]` — MapLibre measures dashes in line widths, so they stay
141
+ dashes at any weight. A route with `onPress` gets a 12-point hitbox; a 3-point
142
+ line is otherwise a hard target.
143
+ - **An arc is a curve, not a great circle.** A quadratic Bézier in plain lng/lat,
144
+ bowed left of travel by `curvature × length` at its apex. At the scale a connection
145
+ is drawn the two read the same, and the bow is the caller's choice rather than the
146
+ globe's. Its endpoints are the inputs, not recomputed, so a marker on either end
147
+ sits exactly on the line. It does not take the short way across the antimeridian.
148
+ - **Rotation and tilt travel together, and both are off by default.** A map that
149
+ turns but cannot be tilted flat again is one a reader gets lost in; a store finder
150
+ wants north up. `isInteractive={false}` turns every gesture off, rotation included.
151
+ - **Camera moves honour reduced motion.** `resolveCameraDuration` jumps instead of
152
+ flying when the system asks for less motion.
153
+ - **The map view is announced as "Map".** Markers carry their own
154
+ `accessibilityLabel` and become buttons when they have an `onPress`; controls are
155
+ labelled `Button`s.
156
+
157
+ ## Scoped out of v1
158
+
159
+ - **GeoJSON fills, clustering, heatmaps.** `Map.Route` covers polylines. MapLibre
160
+ does all three as layers, so each is a later part rather than a different engine.
161
+ - **Offline packs.** MapLibre's `OfflineManager` can download a region; nothing here
162
+ needs one yet.
163
+ - **A hidden list of markers for screen readers.** Markers are reachable already.
164
+ - **Expo Go.** MapLibre is a native module; the map needs a dev build.
165
+
166
+ ## Platform setup
167
+
168
+ - **Both platforms** need the config plugin — `"@maplibre/maplibre-react-native"` in
169
+ the app config's `plugins` — and a rebuilt dev client. No API key.
170
+ - **iOS** needs `NSLocationWhenInUseUsageDescription` in the app config for a screen
171
+ using `showLocate` or `Map.UserLocation`.
172
+ - **Android** gets its location permissions from MapLibre's own manifest.
@@ -0,0 +1,68 @@
1
+ export { Map, type MapProps } from "./map";
2
+ export {
3
+ type MapContextValue,
4
+ type MapUserLocationValue,
5
+ useMap,
6
+ useMapContext,
7
+ useMapUserLocation,
8
+ } from "./map.context";
9
+ export type {
10
+ LngLat,
11
+ LngLatBounds,
12
+ MapFitBoundsOptions,
13
+ MapFlyToOptions,
14
+ MapHandle,
15
+ ViewState,
16
+ } from "./map.types";
17
+ export {
18
+ buildMapStyle,
19
+ MAP_ACCESSIBILITY_LABEL,
20
+ MAP_CAMERA_DURATION,
21
+ MAP_CONTROLS_CORNERS,
22
+ MAP_DEFAULT_BASEMAP,
23
+ MAP_DEFAULT_ZOOM,
24
+ MAP_LABEL_SIDES,
25
+ MAP_LABEL_SIZES,
26
+ MAP_LABEL_TONES,
27
+ MAP_MARKER_ANCHORS,
28
+ MAP_PIN_DIMENSIONS,
29
+ MAP_PIN_SIZES,
30
+ MAP_PIN_TONE_TOKENS,
31
+ MAP_PIN_TONES,
32
+ MAP_ROUTE_TOKEN,
33
+ MAP_STYLE_TOKENS,
34
+ MAP_ZOOM_STEP,
35
+ type MapAnchorPoint,
36
+ type MapBasemap,
37
+ type MapControlsCorner,
38
+ type MapLabelSide,
39
+ type MapLabelSize,
40
+ type MapLabelTone,
41
+ type MapMarkerAnchor,
42
+ type MapPinSize,
43
+ type MapPinTone,
44
+ type MapStyleTokens,
45
+ type MapVariantProps,
46
+ mapPinPath,
47
+ mapVariants,
48
+ resolveMarkerAnchor,
49
+ resolveMarkerOffset,
50
+ resolvePinTipAnchor,
51
+ toStyleColor,
52
+ } from "./map.variants";
53
+ export type { MapArcProps } from "./map-arc";
54
+ export type { MapControlsProps } from "./map-controls";
55
+ export {
56
+ arcCoordinates,
57
+ boundsOf,
58
+ fitBoundsCamera,
59
+ MAP_FIT_MAX_ZOOM,
60
+ MAP_TILE_SIZE,
61
+ normalizeLongitude,
62
+ } from "./map-geometry";
63
+ export type { MapLabelProps } from "./map-label";
64
+ export type { MapMarkerProps } from "./map-marker";
65
+ export type { MapPinProps } from "./map-pin";
66
+ export type { MapPopupProps } from "./map-popup";
67
+ export type { MapRouteProps } from "./map-route";
68
+ export type { MapUserLocationProps } from "./map-user-location";
@@ -0,0 +1,79 @@
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, toStyleColor } from "./map.variants";
7
+ import { arcCoordinates } from "./map-geometry";
8
+
9
+ export type MapArcProps = {
10
+ from: LngLat;
11
+ to: LngLat;
12
+ /**
13
+ * How far it bows off the straight line, as a fraction of the line's
14
+ * length; negative bows the other way. Defaults to `0.2`.
15
+ */
16
+ curvature?: number;
17
+ /** A theme token or a literal colour. Defaults to `primary`. */
18
+ color?: string;
19
+ /** Stroke width in points. Defaults to `2`. */
20
+ width?: number;
21
+ /** `0`–`1`. Defaults to `0.9`. */
22
+ opacity?: number;
23
+ };
24
+
25
+ /** Points sampled along the curve — smooth at any zoom a connection is read at. */
26
+ const ARC_SAMPLES = 64;
27
+
28
+ /**
29
+ * A curve from one point to another — a flight, a transfer, a connection
30
+ * rather than a path someone travelled.
31
+ *
32
+ * A quadratic curve sampled into a line, not a great circle: at the scale a
33
+ * connection is drawn the two are indistinguishable, and the curve's bow is a
34
+ * choice the caller makes rather than one the globe makes for them. The line
35
+ * starts and ends exactly on `from` and `to`, so markers there sit on it.
36
+ */
37
+ export function MapArc({
38
+ from,
39
+ to,
40
+ curvature = 0.2,
41
+ color = MAP_ROUTE_TOKEN,
42
+ width = 2,
43
+ opacity = 0.9,
44
+ }: MapArcProps): ReactElement {
45
+ useMapPart("Map.Arc");
46
+ const stroke = toStyleColor(useThemeColor(color));
47
+ const id = `delacour-arc-${useId()}`;
48
+ const [fromLng, fromLat] = from;
49
+ const [toLng, toLat] = to;
50
+ const data = useMemo<GeoJSON.Feature<GeoJSON.LineString>>(
51
+ () => ({
52
+ geometry: {
53
+ coordinates: arcCoordinates([fromLng, fromLat], [toLng, toLat], curvature, ARC_SAMPLES).map(
54
+ ([longitude, latitude]) => [longitude, latitude]
55
+ ),
56
+ type: "LineString",
57
+ },
58
+ properties: {},
59
+ type: "Feature",
60
+ }),
61
+ [fromLng, fromLat, toLng, toLat, curvature]
62
+ );
63
+
64
+ return (
65
+ <GeoJSONSource data={data} id={id}>
66
+ <Layer
67
+ id={`${id}-line`}
68
+ layout={{ "line-cap": "round" }}
69
+ paint={{
70
+ ...(stroke === undefined ? {} : { "line-color": stroke }),
71
+ "line-opacity": Math.min(1, Math.max(0, opacity)),
72
+ "line-width": width,
73
+ }}
74
+ type="line"
75
+ />
76
+ </GeoJSONSource>
77
+ );
78
+ }
79
+ MapArc.displayName = "DelacourUI.Map.Arc";
@@ -0,0 +1,133 @@
1
+ import { type ReactElement, useEffect, useId } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { IconCompassRound, IconLocation, IconMinusSmall, IconPlusSmall, IconTarget } from "../../icons/central";
4
+ import { Button } from "../button";
5
+ import { Icon } from "../icon";
6
+ import { Surface } from "../surface";
7
+ import { useMapPart } from "./map.context";
8
+ import type { LngLat } from "./map.types";
9
+ import { MAP_ZOOM_STEP, type MapControlsCorner, mapVariants } from "./map.variants";
10
+
11
+ export type MapControlsProps = Omit<ViewProps, "children"> & {
12
+ /** Defaults to `bottom-right`, under the thumb. */
13
+ corner?: MapControlsCorner;
14
+ /** Zoom in and out. Defaults to `true`. */
15
+ showZoom?: boolean;
16
+ /** Fly to the device's location — turns the location dot on and asks for permission. */
17
+ showLocate?: boolean;
18
+ /** Turn the map back to north-up. Useful only on a map that rotates. */
19
+ showCompass?: boolean;
20
+ /** Return to the view the map opened on. */
21
+ showRecenter?: boolean;
22
+ /** Called with the device's position once a locate press has flown there. */
23
+ onLocate?: (lngLat: LngLat) => void;
24
+ className?: string;
25
+ };
26
+
27
+ /**
28
+ * Zoom, locate, compass and recenter buttons, floated in a corner of the map.
29
+ *
30
+ * Each group is a card-coloured `Surface` holding ghost `Button`s, so the
31
+ * controls read over any basemap and take the theme like every other button.
32
+ * Each is labelled for a screen reader by what it does.
33
+ *
34
+ * Drawn on the layer above the map rather than inside it — the native map
35
+ * takes only map features as children — and that layer passes touches on its
36
+ * empty space through, so only the buttons themselves intercept a pan.
37
+ *
38
+ * `showLocate` turns on the platform's location dot, which asks for foreground
39
+ * location permission the first time; the app's config has to declare why.
40
+ */
41
+ export function MapControls({
42
+ corner = "bottom-right",
43
+ showZoom = true,
44
+ showLocate = false,
45
+ showCompass = false,
46
+ showRecenter = false,
47
+ onLocate,
48
+ className,
49
+ ...props
50
+ }: MapControlsProps): null {
51
+ const map = useMapPart("Map.Controls");
52
+ const id = useId();
53
+ const { requestUserLocation, setOverlay } = map;
54
+
55
+ useEffect(() => (showLocate ? requestUserLocation() : undefined), [showLocate, requestUserLocation]);
56
+
57
+ const slots = mapVariants({ corner });
58
+
59
+ const node: ReactElement = (
60
+ <View className={slots.controls({ className })} pointerEvents="box-none" {...props}>
61
+ {showZoom ? (
62
+ <Surface padding="none" variant="default">
63
+ <Button
64
+ accessibilityLabel="Zoom in"
65
+ onPress={() => map.zoomBy(MAP_ZOOM_STEP)}
66
+ size="icon-sm"
67
+ testID="map-controls-zoom-in"
68
+ variant="ghost"
69
+ >
70
+ <Icon icon={IconPlusSmall} />
71
+ </Button>
72
+ <Button
73
+ accessibilityLabel="Zoom out"
74
+ onPress={() => map.zoomBy(-MAP_ZOOM_STEP)}
75
+ size="icon-sm"
76
+ testID="map-controls-zoom-out"
77
+ variant="ghost"
78
+ >
79
+ <Icon icon={IconMinusSmall} />
80
+ </Button>
81
+ </Surface>
82
+ ) : null}
83
+ {showCompass ? (
84
+ <Surface padding="none" variant="default">
85
+ <Button
86
+ accessibilityLabel="Point north"
87
+ onPress={() => map.resetBearing()}
88
+ size="icon-sm"
89
+ testID="map-controls-compass"
90
+ variant="ghost"
91
+ >
92
+ <Icon icon={IconCompassRound} />
93
+ </Button>
94
+ </Surface>
95
+ ) : null}
96
+ {showRecenter ? (
97
+ <Surface padding="none" variant="default">
98
+ <Button
99
+ accessibilityLabel="Recenter map"
100
+ onPress={() => map.handle.recenter()}
101
+ size="icon-sm"
102
+ testID="map-controls-recenter"
103
+ variant="ghost"
104
+ >
105
+ <Icon icon={IconTarget} />
106
+ </Button>
107
+ </Surface>
108
+ ) : null}
109
+ {showLocate ? (
110
+ <Surface padding="none" variant="default">
111
+ <Button
112
+ accessibilityLabel="Show my location"
113
+ onPress={() => map.locate(onLocate)}
114
+ size="icon-sm"
115
+ testID="map-controls-locate"
116
+ variant="ghost"
117
+ >
118
+ <Icon icon={IconLocation} />
119
+ </Button>
120
+ </Surface>
121
+ ) : null}
122
+ </View>
123
+ );
124
+
125
+ useEffect(() => {
126
+ setOverlay(id, node);
127
+ }, [id, node, setOverlay]);
128
+
129
+ useEffect(() => () => setOverlay(id, null), [id, setOverlay]);
130
+
131
+ return null;
132
+ }
133
+ MapControls.displayName = "DelacourUI.Map.Controls";
@@ -0,0 +1,159 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { LngLat } from "./map.types";
3
+ import { arcCoordinates, boundsOf, fitBoundsCamera, MAP_FIT_MAX_ZOOM, normalizeLongitude } from "./map-geometry";
4
+
5
+ const WELLINGTON: LngLat = [174.776, -41.286];
6
+ const AUCKLAND: LngLat = [174.763, -36.848];
7
+
8
+ function close(actual: number, expected: number, digits = 9): void {
9
+ expect(actual).toBeCloseTo(expected, digits);
10
+ }
11
+
12
+ describe("normalizeLongitude", () => {
13
+ test("leaves a longitude inside the range alone", () => {
14
+ expect(normalizeLongitude(174.776)).toBe(174.776);
15
+ expect(normalizeLongitude(-120)).toBe(-120);
16
+ });
17
+
18
+ test("wraps past the antimeridian, both ways", () => {
19
+ close(normalizeLongitude(190), -170);
20
+ close(normalizeLongitude(-190), 170);
21
+ close(normalizeLongitude(540), -180);
22
+ });
23
+ });
24
+
25
+ describe("fitBoundsCamera", () => {
26
+ const SQUARE = { width: 512, height: 512 };
27
+
28
+ test("the whole world in one tile's width is zoom 0", () => {
29
+ const { center, zoom } = fitBoundsCamera([-180, -85.0511287798, 180, 85.0511287798], SQUARE);
30
+ close(zoom, 0, 6);
31
+ close(center[0], 0, 6);
32
+ close(center[1], 0, 6);
33
+ });
34
+
35
+ test("half the world's longitude in the same width is one step in", () => {
36
+ const { zoom } = fitBoundsCamera([0, -1, 180, 1], SQUARE);
37
+ close(zoom, 1, 6);
38
+ });
39
+
40
+ test("the tighter axis decides: a wide box is held by the width, a tall one by the height", () => {
41
+ const square = { width: 256, height: 256 };
42
+ const wideBox = [0, -1, 4, 1] as const;
43
+ const tallBox = [0, -2, 1, 2] as const;
44
+ close(fitBoundsCamera(wideBox, { width: 256, height: 1024 }).zoom, fitBoundsCamera(wideBox, square).zoom, 6);
45
+ close(fitBoundsCamera(tallBox, { width: 1024, height: 256 }).zoom, fitBoundsCamera(tallBox, square).zoom, 6);
46
+ expect(fitBoundsCamera(wideBox, { width: 1024, height: 256 }).zoom).toBeGreaterThan(
47
+ fitBoundsCamera(wideBox, square).zoom
48
+ );
49
+ });
50
+
51
+ test("centres on the box in Web Mercator, which sits poleward of the mean latitude", () => {
52
+ const { center } = fitBoundsCamera([0, 40, 10, 70], SQUARE);
53
+ close(center[0], 5);
54
+ expect(center[1]).toBeGreaterThan(55);
55
+ });
56
+
57
+ test("padding zooms out so the box clears it", () => {
58
+ const bare = fitBoundsCamera([174, -42, 176, -40], SQUARE);
59
+ const padded = fitBoundsCamera([174, -42, 176, -40], SQUARE, 128);
60
+ close(padded.zoom, bare.zoom - 1, 6);
61
+ });
62
+
63
+ test("padding that would leave no room is ignored", () => {
64
+ const bare = fitBoundsCamera([174, -42, 176, -40], SQUARE);
65
+ expect(fitBoundsCamera([174, -42, 176, -40], SQUARE, 300)).toEqual(bare);
66
+ });
67
+
68
+ test("a box across the antimeridian is framed across it, not round the world", () => {
69
+ const across = fitBoundsCamera([170, -20, -170, -10], SQUARE);
70
+ const plain = fitBoundsCamera([-10, -20, 10, -10], SQUARE);
71
+ close(Math.abs(across.center[0]), 180);
72
+ close(across.zoom, plain.zoom, 6);
73
+ });
74
+
75
+ test("a box of no size is a close view, not an infinite zoom", () => {
76
+ expect(fitBoundsCamera([174.776, -41.286, 174.776, -41.286], SQUARE)).toEqual({
77
+ center: WELLINGTON,
78
+ zoom: MAP_FIT_MAX_ZOOM,
79
+ });
80
+ });
81
+
82
+ test("an unmeasured view is never a negative or non-finite zoom", () => {
83
+ const { zoom } = fitBoundsCamera([0, 0, 10, 10], { width: 0, height: 0 });
84
+ expect(Number.isFinite(zoom)).toBe(true);
85
+ expect(zoom).toBeGreaterThanOrEqual(0);
86
+ });
87
+ });
88
+
89
+ describe("boundsOf", () => {
90
+ test("nothing has no bounds", () => {
91
+ expect(boundsOf([])).toBeUndefined();
92
+ });
93
+
94
+ test("one point is a box of no size", () => {
95
+ expect(boundsOf([WELLINGTON])).toEqual([174.776, -41.286, 174.776, -41.286]);
96
+ });
97
+
98
+ test("the extent of a route", () => {
99
+ expect(boundsOf([WELLINGTON, AUCKLAND, [172.636, -43.532]])).toEqual([172.636, -43.532, 174.776, -36.848]);
100
+ });
101
+
102
+ test("points either side of the antimeridian take the short way round", () => {
103
+ const bounds = boundsOf([
104
+ [178, -18],
105
+ [-179, -17],
106
+ [179.5, -16],
107
+ ]);
108
+ expect(bounds).toEqual([178, -18, -179, -16]);
109
+ });
110
+
111
+ test("points spread round the world without a gap take the Greenwich box", () => {
112
+ expect(
113
+ boundsOf([
114
+ [-150, 0],
115
+ [-30, 10],
116
+ [90, 20],
117
+ ])
118
+ ).toEqual([-150, 0, 90, 20]);
119
+ });
120
+ });
121
+
122
+ describe("arcCoordinates", () => {
123
+ test("starts and ends exactly on its endpoints", () => {
124
+ const points = arcCoordinates(AUCKLAND, WELLINGTON, 0.2, 32);
125
+ expect(points[0]).toEqual(AUCKLAND);
126
+ expect(points.at(-1)).toEqual(WELLINGTON);
127
+ });
128
+
129
+ test("samples the requested number of points, endpoints included", () => {
130
+ expect(arcCoordinates(AUCKLAND, WELLINGTON, 0.2, 32)).toHaveLength(32);
131
+ expect(arcCoordinates(AUCKLAND, WELLINGTON, 0.2, 1)).toHaveLength(2);
132
+ });
133
+
134
+ test("no curvature is the straight line", () => {
135
+ const points = arcCoordinates([0, 0], [10, 0], 0, 11);
136
+ points.forEach(([lng, lat], index) => {
137
+ close(lng, index);
138
+ close(lat, 0);
139
+ });
140
+ });
141
+
142
+ test("bows off the chord by the curvature times its length, at the midpoint", () => {
143
+ const points = arcCoordinates([0, 0], [10, 0], 0.2, 3);
144
+ const [lng, lat] = points[1] as LngLat;
145
+ close(lng, 5);
146
+ close(lat, 2);
147
+ });
148
+
149
+ test("a negative curvature bows the other way", () => {
150
+ const [, up] = arcCoordinates([0, 0], [10, 0], 0.2, 3)[1] as LngLat;
151
+ const [, down] = arcCoordinates([0, 0], [10, 0], -0.2, 3)[1] as LngLat;
152
+ expect(up).toBeGreaterThan(0);
153
+ close(down, -up);
154
+ });
155
+
156
+ test("a zero-length arc is its one point, twice", () => {
157
+ expect(arcCoordinates(WELLINGTON, WELLINGTON, 0.2, 8)).toEqual([WELLINGTON, WELLINGTON]);
158
+ });
159
+ });