@superrouteca/smap-react-native 0.0.0-stage → 1.0.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/src/SMap.tsx ADDED
@@ -0,0 +1,226 @@
1
+ import * as React from 'react';
2
+ import {
3
+ createContext,
4
+ forwardRef,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useImperativeHandle,
9
+ useRef,
10
+ useState,
11
+ } from 'react';
12
+ import { StyleSheet, View } from 'react-native';
13
+ import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from 'react-native';
14
+ import NativeSMapView, { Commands } from './SMapNativeViewNativeComponent';
15
+ import { cameraJson, toFeatureCollection, toLatLng, type LayerData, type LngLatLike } from './geojson';
16
+
17
+ export type StyleFlavor = 'light' | 'white' | 'grayscale' | 'dark' | 'black';
18
+ export const styles: StyleFlavor[] = ['light', 'white', 'grayscale', 'dark', 'black'];
19
+
20
+ export type LayerKind = 'points' | 'lines' | 'polygons';
21
+
22
+ /** Called with the tapped feature's properties and its position. */
23
+ export type TapHandler = (properties: Record<string, unknown>, position: { lat: number; lng: number }) => void;
24
+
25
+ /** The map, as the ref of `<SMap>` and `useSMap()` give it once it is ready. */
26
+ export interface SMapHandle {
27
+ addPoints(id: string, data: LayerData, options?: Record<string, unknown>): void;
28
+ addLines(id: string, data: LayerData, options?: Record<string, unknown>): void;
29
+ addPolygons(id: string, data: LayerData, options?: Record<string, unknown>): void;
30
+ /** Replaces a layer's data and keeps its look. */
31
+ setData(id: string, data: LayerData): void;
32
+ setVisible(id: string, visible: boolean): void;
33
+ removeLayer(id: string): void;
34
+ /** Layer ids in draw order, bottom first. */
35
+ layers(): string[];
36
+ /** Moves the camera to show a layer's data; padding in points (40), maxZoom 15. */
37
+ fitLayer(id: string, options?: { padding?: number; maxZoom?: number }): void;
38
+ flyTo(center: LngLatLike, zoom?: number): void;
39
+ /** Calls `handler` when a feature of layer `id` is tapped (null removes it). */
40
+ onTap(id: string, handler: TapHandler | null): void;
41
+ setStyle(style: StyleFlavor): void;
42
+ setLanguage(language: string): void;
43
+ setBuildings3D(on: boolean): void;
44
+ }
45
+
46
+ const MapContext = createContext<SMapHandle | null>(null);
47
+
48
+ /** The map of the nearest `<SMap>` above, once it is ready (null before). */
49
+ export function useSMap(): SMapHandle | null {
50
+ return useContext(MapContext);
51
+ }
52
+
53
+ export interface SMapProps extends Omit<ViewProps, 'style'> {
54
+ /** A map key (`smk_…`) whose allowlist names this app. Read when the map is created. */
55
+ apiKey?: string;
56
+ /** A map token. Read when the map is created; changing it creates the map again (use `getToken` to renew). */
57
+ token?: string;
58
+ /** Called at start when no token is given and again two minutes before each token expires. */
59
+ getToken?: () => string | Promise<string>;
60
+ /** The colour scheme (layers are kept on a change). A style object here is taken as `containerStyle`. */
61
+ style?: StyleFlavor | StyleProp<ViewStyle>;
62
+ /** Label language (`en`, `fr`, `zh-Hans`, …); the device language by default. Layers are kept on a change. */
63
+ language?: string;
64
+ /** [longitude, latitude] or `{lng, lat}`; the camera follows a change. */
65
+ center?: LngLatLike;
66
+ zoom?: number;
67
+ bearing?: number;
68
+ pitch?: number;
69
+ /** Node URLs known in advance, used while `baseUrl` is down. */
70
+ nodes?: string[];
71
+ /** The map service, `https://tile.srsr.ca` by default. */
72
+ baseUrl?: string;
73
+ /** Read from the nearest service node (default true). */
74
+ nearest?: boolean;
75
+ /** Buildings in 3D. */
76
+ buildings3D?: boolean;
77
+ /** Called once the first style has loaded. */
78
+ onReady?: (map: SMapHandle) => void;
79
+ /** Errors from the map (a failed token, a style that could not load). */
80
+ onError?: (error: Error) => void;
81
+ /** The layout of the map view. */
82
+ containerStyle?: StyleProp<ViewStyle>;
83
+ children?: React.ReactNode;
84
+ }
85
+
86
+ type NativeRef = React.ElementRef<typeof NativeSMapView>;
87
+
88
+ /**
89
+ * A map view. The ref is the map (`SMapHandle`) once the first style has loaded, null before.
90
+ */
91
+ export const SMap = forwardRef<SMapHandle | null, SMapProps>(function SMap(props, ref) {
92
+ const {
93
+ apiKey, token, getToken, style, language, center, zoom, bearing, pitch, nodes, baseUrl, nearest, buildings3D,
94
+ onReady, onError, containerStyle, children, ...viewProps
95
+ } = props;
96
+ const flavorProp = typeof style === 'string' ? style : undefined;
97
+ const layoutStyle = typeof style === 'string' ? containerStyle : [containerStyle, style as StyleProp<ViewStyle>];
98
+
99
+ const native = useRef<NativeRef | null>(null);
100
+ const latest = useRef(props);
101
+ latest.current = props;
102
+ const [handle, setHandle] = useState<SMapHandle | null>(null);
103
+ const [flavor, setFlavor] = useState<string>(flavorProp || 'light');
104
+ const [lang, setLang] = useState<string>(language || '');
105
+ const [buildings, setBuildings] = useState<boolean>(!!buildings3D);
106
+ useEffect(() => { if (flavorProp) { setFlavor(flavorProp); } }, [flavorProp]);
107
+ useEffect(() => { if (language !== undefined) { setLang(language); } }, [language]);
108
+ useEffect(() => { setBuildings(!!buildings3D); }, [buildings3D]);
109
+
110
+ // Read when the native map is created; a change creates it again (the native view gets a new key).
111
+ const config = JSON.stringify({
112
+ baseUrl: baseUrl || null,
113
+ nodes: nodes || [],
114
+ apiKey: apiKey || null,
115
+ token: token || null,
116
+ nearest: nearest !== false,
117
+ getToken: !!getToken,
118
+ });
119
+ const camera = cameraJson({ center, zoom, bearing, pitch });
120
+
121
+ // The layers of this map, in the order the native SDKs keep them (zIndex, then the order added).
122
+ const layers = useRef<{ id: string; zIndex: number }[]>([]);
123
+ const taps = useRef<Record<string, TapHandler>>({});
124
+ // A handle works only for the native view it was made for; a new view (new config) makes the old handle inert.
125
+ const generation = useRef(0);
126
+ useEffect(() => {
127
+ generation.current += 1;
128
+ layers.current = [];
129
+ taps.current = {};
130
+ setHandle(null);
131
+ }, [config]);
132
+
133
+ const makeHandle = useCallback((): SMapHandle => {
134
+ const mine = generation.current;
135
+ const live = () => generation.current === mine && native.current !== null;
136
+ const view = () => native.current as NativeRef;
137
+ const add = (kind: LayerKind, id: string, data: LayerData, options: Record<string, unknown> = {}) => {
138
+ if (!live()) { return; }
139
+ const zIndex = typeof options.zIndex === 'number' ? options.zIndex : 0;
140
+ const rest = layers.current.filter((l) => l.id !== id);
141
+ rest.push({ id, zIndex });
142
+ // A stable sort: equal zIndex keeps the order added.
143
+ layers.current = rest.map((l, i) => ({ l, i })).sort((a, b) => a.l.zIndex - b.l.zIndex || a.i - b.i).map((x) => x.l);
144
+ Commands.addLayer(view(), kind, id, JSON.stringify(toFeatureCollection(data)), JSON.stringify(options));
145
+ };
146
+ return {
147
+ addPoints: (id, data, options) => add('points', id, data, options),
148
+ addLines: (id, data, options) => add('lines', id, data, options),
149
+ addPolygons: (id, data, options) => add('polygons', id, data, options),
150
+ setData: (id, data) => { if (live()) { Commands.setData(view(), id, JSON.stringify(toFeatureCollection(data))); } },
151
+ setVisible: (id, visible) => { if (live()) { Commands.setVisible(view(), id, visible); } },
152
+ removeLayer: (id) => {
153
+ if (!live()) { return; }
154
+ layers.current = layers.current.filter((l) => l.id !== id);
155
+ delete taps.current[id];
156
+ Commands.removeLayer(view(), id);
157
+ },
158
+ layers: () => (live() ? layers.current.map((l) => l.id) : []),
159
+ fitLayer: (id, options = {}) => { if (live()) { Commands.fitLayer(view(), id, options.padding ?? 40, options.maxZoom ?? 15); } },
160
+ flyTo: (c, z) => {
161
+ if (!live()) { return; }
162
+ const p = toLatLng(c);
163
+ Commands.flyTo(view(), p.lat, p.lng, typeof z === 'number' ? z : -1);
164
+ },
165
+ onTap: (id, handler) => {
166
+ if (!live()) { return; }
167
+ if (handler) { taps.current[id] = handler; } else { delete taps.current[id]; }
168
+ },
169
+ setStyle: (s) => setFlavor(s),
170
+ setLanguage: (l) => setLang(l),
171
+ setBuildings3D: (on) => setBuildings(on),
172
+ };
173
+ }, []);
174
+
175
+ useImperativeHandle(ref, () => handle as SMapHandle, [handle]);
176
+
177
+ const handleReady = useCallback(() => {
178
+ const h = makeHandle();
179
+ setHandle(h);
180
+ latest.current.onReady?.(h);
181
+ }, [makeHandle]);
182
+
183
+ const handleError = useCallback((e: NativeSyntheticEvent<{ message: string }>) => {
184
+ latest.current.onError?.(new Error(e.nativeEvent.message));
185
+ }, []);
186
+
187
+ const handleTap = useCallback((e: NativeSyntheticEvent<{ layerId: string; properties: string; lat: number; lng: number }>) => {
188
+ const { layerId, properties, lat, lng } = e.nativeEvent;
189
+ const handler = taps.current[layerId];
190
+ if (!handler) { return; }
191
+ let parsed: Record<string, unknown> = {};
192
+ try { parsed = JSON.parse(properties || '{}'); } catch { parsed = {}; }
193
+ handler(parsed, { lat, lng });
194
+ }, []);
195
+
196
+ const handleTokenRequest = useCallback((e: NativeSyntheticEvent<{ requestId: number }>) => {
197
+ const id = e.nativeEvent.requestId;
198
+ const answer = (t: string, err: string) => { if (native.current) { Commands.resolveToken(native.current, id, t, err); } };
199
+ const ask = latest.current.getToken;
200
+ if (!ask) { answer('', 'sMap: no getToken'); return; }
201
+ Promise.resolve()
202
+ .then(() => ask())
203
+ .then((t) => answer(String(t || ''), t ? '' : 'sMap: getToken gave no token'),
204
+ (err: unknown) => answer('', 'sMap: ' + (err instanceof Error ? err.message : String(err))));
205
+ }, []);
206
+
207
+ return (
208
+ <View {...viewProps} style={[{ flex: 1 }, layoutStyle]}>
209
+ <NativeSMapView
210
+ key={config}
211
+ ref={native}
212
+ style={StyleSheet.absoluteFill}
213
+ config={config}
214
+ mapStyle={flavor}
215
+ language={lang}
216
+ camera={camera}
217
+ buildings3D={buildings}
218
+ onMapReady={handleReady}
219
+ onMapError={handleError}
220
+ onLayerTap={handleTap}
221
+ onTokenRequest={handleTokenRequest}
222
+ />
223
+ <MapContext.Provider value={handle}>{children}</MapContext.Provider>
224
+ </View>
225
+ );
226
+ });
@@ -0,0 +1,47 @@
1
+ // The native map view (Fabric component), generated for Android and iOS from this file by the codegen.
2
+ // Values that change shape (options, data, the camera) cross as JSON text.
3
+ import type * as React from 'react';
4
+ import { codegenNativeCommands, codegenNativeComponent } from 'react-native';
5
+ import type { CodegenTypes, HostComponent, ViewProps } from 'react-native';
6
+
7
+ type ReadyEvent = Readonly<{ node: string }>;
8
+ type ErrorEvent = Readonly<{ message: string }>;
9
+ type TapEvent = Readonly<{ layerId: string; properties: string; lat: CodegenTypes.Double; lng: CodegenTypes.Double }>;
10
+ type TokenRequestEvent = Readonly<{ requestId: CodegenTypes.Int32 }>;
11
+
12
+ export interface NativeProps extends ViewProps {
13
+ /** JSON {baseUrl, nodes, apiKey, token, nearest, getToken}: read once, when the native map is created. */
14
+ config: string;
15
+ /** The colour scheme. */
16
+ mapStyle?: string;
17
+ /** Label language; empty = the device language. */
18
+ language?: string;
19
+ /** JSON {lat, lng, zoom, bearing, pitch}: the starting camera, and where it moves when the value changes. */
20
+ camera?: string;
21
+ buildings3D?: boolean;
22
+ onMapReady?: CodegenTypes.DirectEventHandler<ReadyEvent>;
23
+ onMapError?: CodegenTypes.DirectEventHandler<ErrorEvent>;
24
+ onLayerTap?: CodegenTypes.DirectEventHandler<TapEvent>;
25
+ onTokenRequest?: CodegenTypes.DirectEventHandler<TokenRequestEvent>;
26
+ }
27
+
28
+ export type SMapNativeViewType = HostComponent<NativeProps>;
29
+
30
+ interface NativeCommands {
31
+ /** kind: points | lines | polygons; data: GeoJSON FeatureCollection; options: JSON. */
32
+ addLayer: (view: React.ElementRef<SMapNativeViewType>, kind: string, id: string, data: string, options: string) => void;
33
+ setData: (view: React.ElementRef<SMapNativeViewType>, id: string, data: string) => void;
34
+ setVisible: (view: React.ElementRef<SMapNativeViewType>, id: string, visible: boolean) => void;
35
+ removeLayer: (view: React.ElementRef<SMapNativeViewType>, id: string) => void;
36
+ fitLayer: (view: React.ElementRef<SMapNativeViewType>, id: string, padding: CodegenTypes.Double, maxZoom: CodegenTypes.Double) => void;
37
+ /** zoom below 0 keeps the zoom. */
38
+ flyTo: (view: React.ElementRef<SMapNativeViewType>, lat: CodegenTypes.Double, lng: CodegenTypes.Double, zoom: CodegenTypes.Double) => void;
39
+ /** The answer to onTokenRequest: a token, or an error message (token empty). */
40
+ resolveToken: (view: React.ElementRef<SMapNativeViewType>, requestId: CodegenTypes.Int32, token: string, error: string) => void;
41
+ }
42
+
43
+ export const Commands: NativeCommands = codegenNativeCommands<NativeCommands>({
44
+ supportedCommands: ['addLayer', 'setData', 'setVisible', 'removeLayer', 'fitLayer', 'flyTo', 'resolveToken'],
45
+ });
46
+
47
+ export default codegenNativeComponent<NativeProps>('SMapNativeView') as SMapNativeViewType;
package/src/geojson.ts ADDED
@@ -0,0 +1,66 @@
1
+ // Layer data and the camera in the shapes the native SDKs read. Same rules as the Web SDK: GeoJSON as given, a
2
+ // Feature wrapped in a collection, an array of points `{lng, lat, …properties}` turned into point features.
3
+
4
+ export type GeoJSONGeometry = { type: string; coordinates?: unknown; geometries?: unknown[] };
5
+ export interface GeoJSONFeature<P = Record<string, unknown>> {
6
+ type: 'Feature';
7
+ id?: string | number;
8
+ properties: P | null;
9
+ geometry: GeoJSONGeometry;
10
+ }
11
+ export interface GeoJSONFeatureCollection<P = Record<string, unknown>> {
12
+ type: 'FeatureCollection';
13
+ features: GeoJSONFeature<P>[];
14
+ }
15
+ export interface PointInput {
16
+ lat?: number;
17
+ lng?: number;
18
+ lon?: number;
19
+ latitude?: number;
20
+ longitude?: number;
21
+ coordinates?: [number, number];
22
+ id?: string | number;
23
+ [property: string]: unknown;
24
+ }
25
+ export type LayerData = GeoJSONFeatureCollection | GeoJSONFeature | Array<PointInput | GeoJSONFeature> | null | undefined;
26
+
27
+ /** [longitude, latitude] or `{lng, lat}` (`lon` accepted). */
28
+ export type LngLatLike = [number, number] | { lng: number; lat: number } | { lon: number; lat: number };
29
+
30
+ export function toFeatureCollection(data: LayerData): GeoJSONFeatureCollection {
31
+ if (!data) { return { type: 'FeatureCollection', features: [] }; }
32
+ if (Array.isArray(data)) {
33
+ return {
34
+ type: 'FeatureCollection',
35
+ features: data.map((p) => {
36
+ if (p && (p as GeoJSONFeature).type === 'Feature') { return p as GeoJSONFeature; }
37
+ const q = p as PointInput;
38
+ let lng = q.lng !== undefined ? q.lng : q.lon !== undefined ? q.lon : q.longitude;
39
+ let lat = q.lat !== undefined ? q.lat : q.latitude;
40
+ if (q.coordinates) { lng = q.coordinates[0]; lat = q.coordinates[1]; }
41
+ const f: GeoJSONFeature = { type: 'Feature', properties: q as Record<string, unknown>, geometry: { type: 'Point', coordinates: [lng, lat] } };
42
+ if (q.id !== undefined) { f.id = q.id; }
43
+ return f;
44
+ }),
45
+ };
46
+ }
47
+ if (data.type === 'FeatureCollection') { return data; }
48
+ if (data.type === 'Feature') { return { type: 'FeatureCollection', features: [data] }; }
49
+ return { type: 'FeatureCollection', features: [] };
50
+ }
51
+
52
+ export function toLatLng(c: LngLatLike): { lat: number; lng: number } {
53
+ if (Array.isArray(c)) { return { lng: c[0], lat: c[1] }; }
54
+ const o = c as { lng?: number; lon?: number; lat: number };
55
+ return { lng: (o.lng !== undefined ? o.lng : o.lon) as number, lat: o.lat };
56
+ }
57
+
58
+ /** The camera as JSON, with only the values given. */
59
+ export function cameraJson(camera: { center?: LngLatLike; zoom?: number; bearing?: number; pitch?: number }): string {
60
+ const out: Record<string, number> = {};
61
+ if (camera.center) { const p = toLatLng(camera.center); out.lat = p.lat; out.lng = p.lng; }
62
+ for (const k of ['zoom', 'bearing', 'pitch'] as const) {
63
+ if (typeof camera[k] === 'number') { out[k] = camera[k] as number; }
64
+ }
65
+ return JSON.stringify(out);
66
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,6 @@
1
+ export { SMap, useSMap, styles } from './SMap';
2
+ export type { SMapHandle, SMapProps, StyleFlavor, TapHandler, LayerKind } from './SMap';
3
+ export { Points, Lines, Polygons } from './layers';
4
+ export type { LayerProps, PointsProps, LinesProps, PolygonsProps, PointOptions, LineOptions, PolygonOptions } from './layers';
5
+ export type { LayerData, LngLatLike, GeoJSONFeature, GeoJSONFeatureCollection, PointInput } from './geojson';
6
+ export { toFeatureCollection } from './geojson';
package/src/layers.tsx ADDED
@@ -0,0 +1,114 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import type { LayerData } from './geojson';
3
+ import { useSMap, type SMapHandle, type TapHandler } from './SMap';
4
+
5
+ interface ZoomRange {
6
+ minZoom?: number;
7
+ maxZoom?: number;
8
+ /** Higher draws on top; equal values keep the order added. */
9
+ zIndex?: number;
10
+ }
11
+
12
+ export interface PointOptions extends ZoomRange {
13
+ /** Groups nearby points into circles sized by their count; a tap zooms in to split them. */
14
+ cluster?: boolean | { radius?: number; maxZoom?: number };
15
+ color?: string;
16
+ radius?: number;
17
+ strokeColor?: string;
18
+ /** The name of an image registered with the native map; drawn instead of a circle. */
19
+ icon?: string;
20
+ iconSize?: number;
21
+ /** The property written under each point. */
22
+ label?: string;
23
+ }
24
+
25
+ export interface LineOptions extends ZoomRange {
26
+ color?: string;
27
+ width?: number;
28
+ /** Dash and gap lengths in line widths. */
29
+ dash?: number[];
30
+ opacity?: number;
31
+ }
32
+
33
+ export interface PolygonOptions extends ZoomRange {
34
+ fill?: string;
35
+ opacity?: number;
36
+ outline?: string;
37
+ outlineWidth?: number;
38
+ /** Draws the areas in 3D: a height and a base in metres, each a number or the name of a property. */
39
+ extrude?: { height?: number | string; base?: number | string };
40
+ }
41
+
42
+ export interface LayerProps<O> {
43
+ /** The layer id, unique on the map. */
44
+ id: string;
45
+ /** GeoJSON, or an array of points `{lng, lat, …properties}`. A new value replaces the data and keeps the look. */
46
+ data: LayerData;
47
+ /** How the layer is drawn; a change draws the layer again. */
48
+ options?: O;
49
+ /** Called with the tapped feature's properties and position. */
50
+ onTap?: TapHandler;
51
+ /** Shows or hides the layer (default true). */
52
+ visible?: boolean;
53
+ }
54
+
55
+ export type PointsProps = LayerProps<PointOptions>;
56
+ export type LinesProps = LayerProps<LineOptions>;
57
+ export type PolygonsProps = LayerProps<PolygonOptions>;
58
+
59
+ type Method = 'addPoints' | 'addLines' | 'addPolygons';
60
+
61
+ function useLayer(method: Method, props: LayerProps<object>): null {
62
+ const map = useSMap();
63
+ const { id, data, options, onTap, visible = true } = props;
64
+ const latest = useRef(props);
65
+ latest.current = props;
66
+ const shown = useRef<{ map: SMapHandle; data: LayerData; visible: boolean } | null>(null);
67
+ const looks = JSON.stringify(options ?? {});
68
+ const hasTap = !!onTap;
69
+
70
+ useEffect(() => {
71
+ if (!map) { return undefined; }
72
+ const p = latest.current;
73
+ map[method](id, p.data, (p.options ?? {}) as Record<string, unknown>);
74
+ const v = p.visible !== false;
75
+ if (!v) { map.setVisible(id, false); }
76
+ if (hasTap) { map.onTap(id, (properties, position) => latest.current.onTap?.(properties, position)); }
77
+ shown.current = { map, data: p.data, visible: v };
78
+ return () => {
79
+ shown.current = null;
80
+ map.removeLayer(id);
81
+ };
82
+ }, [map, id, looks, hasTap, method]);
83
+
84
+ useEffect(() => {
85
+ const s = shown.current;
86
+ if (!map || !s || s.map !== map || s.data === data) { return; }
87
+ s.data = data;
88
+ map.setData(id, data);
89
+ }, [map, id, data]);
90
+
91
+ useEffect(() => {
92
+ const s = shown.current;
93
+ if (!map || !s || s.map !== map || s.visible === visible) { return; }
94
+ s.visible = visible;
95
+ map.setVisible(id, visible);
96
+ }, [map, id, visible]);
97
+
98
+ return null;
99
+ }
100
+
101
+ /** Points at any scale, clustered or not. Must be inside `<SMap>`. */
102
+ export function Points(props: PointsProps): null {
103
+ return useLayer('addPoints', props);
104
+ }
105
+
106
+ /** Lines (routes, tracks). Must be inside `<SMap>`. */
107
+ export function Lines(props: LinesProps): null {
108
+ return useLayer('addLines', props);
109
+ }
110
+
111
+ /** Areas, flat or in 3D (`options.extrude`). Must be inside `<SMap>`. */
112
+ export function Polygons(props: PolygonsProps): null {
113
+ return useLayer('addPolygons', props);
114
+ }