@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.2.0
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
});
|