react-native-platform-maps 0.1.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/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # react-native-platform-maps
2
+
3
+ Cross-platform React Native maps wrapper:
4
+
5
+ - Android: Leaflet inside `react-native-webview`
6
+ - iOS: `react-native-maps`
7
+
8
+ ## Install
9
+
10
+ Install the package and its peer dependencies in the consumer app:
11
+
12
+ ```bash
13
+ npm install react-native-platform-maps
14
+ npx expo install react-native-maps react-native-webview
15
+ ```
16
+
17
+ Or with Yarn:
18
+
19
+ ```bash
20
+ yarn add react-native-platform-maps
21
+ npx expo install react-native-maps react-native-webview
22
+ ```
23
+
24
+ ## Local development in this repo
25
+
26
+ The current app can consume this package directly from `package.json` with:
27
+
28
+ ```json
29
+ {
30
+ "dependencies": {
31
+ "react-native-platform-maps": "file:./packages/gs-rn-maps"
32
+ }
33
+ }
34
+ ```
35
+
36
+ Then run:
37
+
38
+ ```bash
39
+ yarn install
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```jsx
45
+ import { MapView, Marker, Callout, PROVIDER_GOOGLE } from 'react-native-platform-maps';
46
+
47
+ <MapView
48
+ style={{ flex: 1 }}
49
+ initialRegion={{
50
+ latitude: 10.8231,
51
+ longitude: 106.6297,
52
+ latitudeDelta: 0.01,
53
+ longitudeDelta: 0.01,
54
+ }}
55
+ provider={PROVIDER_GOOGLE}
56
+ >
57
+ <Marker
58
+ coordinate={{
59
+ latitude: 10.8231,
60
+ longitude: 106.6297,
61
+ }}
62
+ title="Ho Chi Minh City"
63
+ description="Example marker"
64
+ />
65
+ </MapView>
66
+ ```
67
+
68
+ ## API
69
+
70
+ Exports:
71
+
72
+ - `MapView`
73
+ - `Marker`
74
+ - `Callout`
75
+ - `PROVIDER`
76
+ - `PROVIDER_DEFAULT`
77
+ - `PROVIDER_GOOGLE`
78
+ - `LeafletMapView`
79
+
80
+ ## Android behavior
81
+
82
+ The Android implementation focuses on a compatible subset of the `react-native-maps` API:
83
+
84
+ - `initialRegion`
85
+ - `region`
86
+ - `animateToRegion(...)`
87
+ - `Marker` with `coordinate`, `title`, `description`, `onPress`
88
+
89
+ Rich React marker trees and fully custom callout JSX are not rendered inside the Leaflet WebView.
90
+
91
+ ## Publish
92
+
93
+ Before publishing, make sure the package name is available on npm and update metadata if needed.
94
+
95
+ ```bash
96
+ cd packages/gs-rn-maps
97
+ npm pack --dry-run
98
+ npm publish --access public
99
+ ```
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "react-native-platform-maps",
3
+ "version": "0.1.0",
4
+ "description": "Cross-platform React Native maps wrapper using Leaflet on Android and react-native-maps on iOS.",
5
+ "main": "src/index.js",
6
+ "react-native": "src/index.js",
7
+ "files": [
8
+ "src",
9
+ "README.md"
10
+ ],
11
+ "keywords": [
12
+ "react-native",
13
+ "expo",
14
+ "maps",
15
+ "leaflet",
16
+ "webview"
17
+ ],
18
+ "peerDependencies": {
19
+ "react": ">=18",
20
+ "react-native": ">=0.81",
21
+ "react-native-maps": ">=1.20.1",
22
+ "react-native-webview": ">=13.15.0"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "license": "UNLICENSED"
28
+ }
@@ -0,0 +1,449 @@
1
+ import React, {
2
+ useRef,
3
+ useEffect,
4
+ useMemo,
5
+ useCallback,
6
+ forwardRef,
7
+ useImperativeHandle,
8
+ } from 'react';
9
+ import { View, StyleSheet } from 'react-native';
10
+ import { WebView } from 'react-native-webview';
11
+
12
+ const DEFAULT_REGION = {
13
+ latitude: 10.8231,
14
+ longitude: 106.6297,
15
+ latitudeDelta: 0.01,
16
+ longitudeDelta: 0.01,
17
+ };
18
+
19
+ const hasValidCoordinate = coordinate =>
20
+ Number.isFinite(coordinate?.latitude) && Number.isFinite(coordinate?.longitude);
21
+
22
+ const getZoomFromDelta = latitudeDelta => {
23
+ if (!latitudeDelta) return 15;
24
+ return Math.round(Math.log2(360 / latitudeDelta));
25
+ };
26
+
27
+ const LeafletMapView = forwardRef((props, ref) => {
28
+ const {
29
+ style,
30
+ initialRegion,
31
+ region,
32
+ children,
33
+ onRegionChange,
34
+ onRegionChangeComplete,
35
+ userLocation,
36
+ } = props;
37
+
38
+ const webViewRef = useRef(null);
39
+ const markersRef = useRef([]);
40
+ const userLocationRef = useRef(null);
41
+ const isMapReadyRef = useRef(false);
42
+ const initialHtmlRegionRef = useRef(region || initialRegion || DEFAULT_REGION);
43
+
44
+ const postMessageToWebView = useCallback(message => {
45
+ if (!webViewRef.current) return;
46
+ webViewRef.current.postMessage(JSON.stringify(message));
47
+ }, []);
48
+
49
+ const updateMarkers = useCallback(() => {
50
+ postMessageToWebView({
51
+ type: 'updateMarkers',
52
+ payload: markersRef.current
53
+ .filter(marker => hasValidCoordinate(marker.coordinate))
54
+ .map(marker => ({
55
+ id: marker.id,
56
+ latitude: marker.coordinate.latitude,
57
+ longitude: marker.coordinate.longitude,
58
+ title: marker.title,
59
+ description: marker.description,
60
+ })),
61
+ });
62
+ }, [postMessageToWebView]);
63
+
64
+ const updateUserLocation = useCallback(coords => {
65
+ if (!hasValidCoordinate(coords) || !isMapReadyRef.current) return;
66
+ userLocationRef.current = coords;
67
+ postMessageToWebView({
68
+ type: 'updateUserLocation',
69
+ payload: coords,
70
+ });
71
+ }, [postMessageToWebView]);
72
+
73
+ const setRegion = useCallback((nextRegion, duration = 0) => {
74
+ if (!hasValidCoordinate(nextRegion) || !isMapReadyRef.current) return;
75
+ postMessageToWebView({
76
+ type: duration > 0 ? 'animateToRegion' : 'setRegion',
77
+ payload: {
78
+ latitude: nextRegion.latitude,
79
+ longitude: nextRegion.longitude,
80
+ zoom: getZoomFromDelta(nextRegion.latitudeDelta),
81
+ duration,
82
+ },
83
+ });
84
+ }, [postMessageToWebView]);
85
+
86
+ useEffect(() => {
87
+ const markers = [];
88
+
89
+ React.Children.forEach(children, child => {
90
+ if (child && child.type && child.type.displayName === 'LeafletMarker') {
91
+ markers.push({
92
+ id: child.key ?? String(markers.length),
93
+ coordinate: child.props.coordinate,
94
+ title: child.props.title || '',
95
+ description: child.props.description || '',
96
+ onPress: child.props.onPress,
97
+ children: child.props.children,
98
+ });
99
+ }
100
+ });
101
+
102
+ markersRef.current = markers;
103
+
104
+ if (isMapReadyRef.current) {
105
+ updateMarkers();
106
+ }
107
+ }, [children, updateMarkers]);
108
+
109
+ useImperativeHandle(ref, () => ({
110
+ animateToRegion: (nextRegion, duration = 500) => {
111
+ setRegion(nextRegion, duration);
112
+ },
113
+ }));
114
+
115
+ const handleMessage = event => {
116
+ try {
117
+ const data = JSON.parse(event.nativeEvent.data);
118
+
119
+ switch (data.type) {
120
+ case 'markerPress': {
121
+ const marker = markersRef.current.find(item => item.id === data.markerId);
122
+ if (marker?.onPress) {
123
+ marker.onPress();
124
+ }
125
+ break;
126
+ }
127
+ case 'regionChange':
128
+ onRegionChange?.(data.region);
129
+ break;
130
+ case 'regionChangeComplete':
131
+ onRegionChangeComplete?.(data.region);
132
+ break;
133
+ case 'mapReady':
134
+ isMapReadyRef.current = true;
135
+ updateMarkers();
136
+ if (userLocationRef.current) {
137
+ updateUserLocation(userLocationRef.current);
138
+ } else if (hasValidCoordinate(userLocation)) {
139
+ updateUserLocation(userLocation);
140
+ }
141
+ if (region) {
142
+ setRegion(region);
143
+ }
144
+ break;
145
+ default:
146
+ break;
147
+ }
148
+ } catch (error) {
149
+ console.warn('LeafletMapView: Error parsing message', error);
150
+ }
151
+ };
152
+
153
+ const htmlContent = useMemo(() => {
154
+ const htmlRegion = initialHtmlRegionRef.current;
155
+ const lat = htmlRegion?.latitude ?? DEFAULT_REGION.latitude;
156
+ const lng = htmlRegion?.longitude ?? DEFAULT_REGION.longitude;
157
+ const zoom = getZoomFromDelta(htmlRegion?.latitudeDelta ?? DEFAULT_REGION.latitudeDelta);
158
+
159
+ return `
160
+ <!DOCTYPE html>
161
+ <html>
162
+ <head>
163
+ <meta charset="utf-8">
164
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
165
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
166
+ <link rel="stylesheet" href="https://unpkg.com/maplibre-gl/dist/maplibre-gl.css" />
167
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
168
+ <script src="https://unpkg.com/maplibre-gl/dist/maplibre-gl.js"></script>
169
+ <script src="https://unpkg.com/@maplibre/maplibre-gl-leaflet/leaflet-maplibre-gl.js"></script>
170
+ <style>
171
+ * { margin: 0; padding: 0; box-sizing: border-box; }
172
+ html, body, #map { width: 100%; height: 100%; }
173
+ .user-location-marker {
174
+ width: 20px;
175
+ height: 20px;
176
+ background: #4285F4;
177
+ border: 3px solid white;
178
+ border-radius: 50%;
179
+ box-shadow: 0 2px 6px rgba(0,0,0,0.3);
180
+ }
181
+ .user-location-pulse {
182
+ width: 40px;
183
+ height: 40px;
184
+ background: rgba(66, 133, 244, 0.2);
185
+ border-radius: 50%;
186
+ position: absolute;
187
+ top: -10px;
188
+ left: -10px;
189
+ animation: pulse 2s infinite;
190
+ }
191
+ @keyframes pulse {
192
+ 0% { transform: scale(0.5); opacity: 1; }
193
+ 100% { transform: scale(1.5); opacity: 0; }
194
+ }
195
+ .custom-marker {
196
+ width: 32px;
197
+ height: 32px;
198
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
199
+ border: 2px solid white;
200
+ border-radius: 50%;
201
+ display: flex;
202
+ align-items: center;
203
+ justify-content: center;
204
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
205
+ }
206
+ .custom-marker svg {
207
+ width: 18px;
208
+ height: 18px;
209
+ fill: white;
210
+ }
211
+ .marker-pin {
212
+ width: 3px;
213
+ height: 8px;
214
+ background: #667eea;
215
+ margin-top: -2px;
216
+ border-bottom-left-radius: 2px;
217
+ border-bottom-right-radius: 2px;
218
+ }
219
+ .leaflet-popup-content-wrapper {
220
+ border-radius: 12px;
221
+ box-shadow: 0 2px 10px rgba(0,0,0,0.15);
222
+ }
223
+ .popup-content {
224
+ padding: 4px 0;
225
+ }
226
+ .popup-title {
227
+ font-weight: bold;
228
+ font-size: 14px;
229
+ color: #333;
230
+ margin-bottom: 4px;
231
+ }
232
+ .popup-desc {
233
+ font-size: 12px;
234
+ color: #666;
235
+ }
236
+ </style>
237
+ </head>
238
+ <body>
239
+ <div id="map"></div>
240
+ <script>
241
+ var map = L.map('map', {
242
+ zoomControl: false,
243
+ attributionControl: true
244
+ }).setView([${lat}, ${lng}], ${zoom});
245
+
246
+ try {
247
+ if (L.maplibreGL) {
248
+ L.maplibreGL({
249
+ style: 'https://tiles.openfreemap.org/styles/liberty',
250
+ }).addTo(map);
251
+ } else {
252
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
253
+ maxZoom: 19,
254
+ attribution: '&copy; OpenStreetMap contributors'
255
+ }).addTo(map);
256
+ }
257
+ } catch (error) {
258
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
259
+ maxZoom: 19,
260
+ attribution: '&copy; OpenStreetMap contributors'
261
+ }).addTo(map);
262
+ }
263
+
264
+ var markers = {};
265
+ var userMarker = null;
266
+
267
+ var shopIcon = L.divIcon({
268
+ className: 'marker-wrapper',
269
+ html: '<div class="custom-marker"><svg viewBox="0 0 24 24"><path d="M18.36 9L18.96 12H5.04L5.64 9H18.36ZM20 4H4V6H20V4ZM20 7H4L3 12V14H4V20H14V14H18V20H20V14H21V12L20 7ZM6 18V14H12V18H6Z"/></svg></div><div class="marker-pin"></div>',
270
+ iconSize: [32, 42],
271
+ iconAnchor: [16, 42],
272
+ popupAnchor: [0, -42]
273
+ });
274
+
275
+ var userIcon = L.divIcon({
276
+ className: 'user-location-wrapper',
277
+ html: '<div class="user-location-pulse"></div><div class="user-location-marker"></div>',
278
+ iconSize: [20, 20],
279
+ iconAnchor: [10, 10]
280
+ });
281
+
282
+ function updateMarkers(markerData) {
283
+ Object.keys(markers).forEach(function(id) {
284
+ map.removeLayer(markers[id]);
285
+ });
286
+ markers = {};
287
+
288
+ markerData.forEach(function(markerDataItem) {
289
+ if (Number.isFinite(markerDataItem.latitude) && Number.isFinite(markerDataItem.longitude)) {
290
+ var marker = L.marker([markerDataItem.latitude, markerDataItem.longitude], { icon: shopIcon });
291
+
292
+ if (markerDataItem.title || markerDataItem.description) {
293
+ var popupContent = '<div class="popup-content">';
294
+ if (markerDataItem.title) popupContent += '<div class="popup-title">' + markerDataItem.title + '</div>';
295
+ if (markerDataItem.description) popupContent += '<div class="popup-desc">' + markerDataItem.description + '</div>';
296
+ popupContent += '</div>';
297
+ marker.bindPopup(popupContent);
298
+ }
299
+
300
+ marker.on('click', function() {
301
+ window.ReactNativeWebView.postMessage(JSON.stringify({
302
+ type: 'markerPress',
303
+ markerId: markerDataItem.id
304
+ }));
305
+ });
306
+
307
+ marker.addTo(map);
308
+ markers[markerDataItem.id] = marker;
309
+ }
310
+ });
311
+ }
312
+
313
+ function updateUserLocation(coords) {
314
+ if (!coords || !Number.isFinite(coords.latitude) || !Number.isFinite(coords.longitude)) return;
315
+
316
+ var latlng = [coords.latitude, coords.longitude];
317
+
318
+ if (userMarker) {
319
+ userMarker.setLatLng(latlng);
320
+ } else {
321
+ userMarker = L.marker(latlng, { icon: userIcon }).addTo(map);
322
+ }
323
+ }
324
+
325
+ function setRegion(lat, lng, zoom) {
326
+ map.setView([lat, lng], zoom, { animate: false });
327
+ }
328
+
329
+ function animateToRegion(lat, lng, zoom, duration) {
330
+ map.flyTo([lat, lng], zoom, { duration: duration / 1000 });
331
+ }
332
+
333
+ document.addEventListener('message', function(e) {
334
+ handleMessage(e.data);
335
+ });
336
+ window.addEventListener('message', function(e) {
337
+ handleMessage(e.data);
338
+ });
339
+
340
+ function handleMessage(data) {
341
+ try {
342
+ var msg = JSON.parse(data);
343
+ switch(msg.type) {
344
+ case 'updateMarkers':
345
+ updateMarkers(msg.payload);
346
+ break;
347
+ case 'updateUserLocation':
348
+ updateUserLocation(msg.payload);
349
+ break;
350
+ case 'setRegion':
351
+ setRegion(
352
+ msg.payload.latitude,
353
+ msg.payload.longitude,
354
+ msg.payload.zoom
355
+ );
356
+ break;
357
+ case 'animateToRegion':
358
+ animateToRegion(
359
+ msg.payload.latitude,
360
+ msg.payload.longitude,
361
+ msg.payload.zoom,
362
+ msg.payload.duration
363
+ );
364
+ break;
365
+ default:
366
+ break;
367
+ }
368
+ } catch (e) {}
369
+ }
370
+
371
+ map.whenReady(function() {
372
+ window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'mapReady' }));
373
+ });
374
+
375
+ map.on('moveend', function() {
376
+ var center = map.getCenter();
377
+ var bounds = map.getBounds();
378
+ window.ReactNativeWebView.postMessage(JSON.stringify({
379
+ type: 'regionChangeComplete',
380
+ region: {
381
+ latitude: center.lat,
382
+ longitude: center.lng,
383
+ latitudeDelta: bounds.getNorth() - bounds.getSouth(),
384
+ longitudeDelta: bounds.getEast() - bounds.getWest()
385
+ }
386
+ }));
387
+ });
388
+ </script>
389
+ </body>
390
+ </html>
391
+ `;
392
+ }, []);
393
+
394
+ useEffect(() => {
395
+ if (hasValidCoordinate(userLocation)) {
396
+ userLocationRef.current = userLocation;
397
+ updateUserLocation(userLocation);
398
+ }
399
+ }, [updateUserLocation, userLocation]);
400
+
401
+ useEffect(() => {
402
+ if (region && isMapReadyRef.current) {
403
+ setRegion(region);
404
+ }
405
+ }, [region, setRegion]);
406
+
407
+ return (
408
+ <View style={[styles.container, style]}>
409
+ <WebView
410
+ ref={webViewRef}
411
+ source={{ html: htmlContent }}
412
+ style={styles.webview}
413
+ onMessage={handleMessage}
414
+ javaScriptEnabled={true}
415
+ domStorageEnabled={true}
416
+ originWhitelist={['*']}
417
+ scrollEnabled={false}
418
+ bounces={false}
419
+ overScrollMode="never"
420
+ showsHorizontalScrollIndicator={false}
421
+ showsVerticalScrollIndicator={false}
422
+ cacheEnabled={true}
423
+ cacheMode="LOAD_CACHE_ELSE_NETWORK"
424
+ />
425
+ </View>
426
+ );
427
+ });
428
+
429
+ LeafletMapView.displayName = 'LeafletMapView';
430
+
431
+ const LeafletMarker = () => null;
432
+ LeafletMarker.displayName = 'LeafletMarker';
433
+
434
+ const LeafletCallout = () => null;
435
+ LeafletCallout.displayName = 'LeafletCallout';
436
+
437
+ const styles = StyleSheet.create({
438
+ container: {
439
+ flex: 1,
440
+ overflow: 'hidden',
441
+ },
442
+ webview: {
443
+ flex: 1,
444
+ backgroundColor: 'transparent',
445
+ },
446
+ });
447
+
448
+ export default LeafletMapView;
449
+ export { LeafletMarker as Marker, LeafletCallout as Callout };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * PlatformMapView - platform-specific map component
3
+ * Android: uses Leaflet/OpenStreetMap via WebView
4
+ * iOS: uses react-native-maps
5
+ */
6
+ import { Platform } from 'react-native';
7
+
8
+ let MapView;
9
+ let Marker;
10
+ let Callout;
11
+ let PROVIDER = null;
12
+ let PROVIDER_DEFAULT = null;
13
+ let PROVIDER_GOOGLE = null;
14
+
15
+ if (Platform.OS === 'android') {
16
+ const Leaflet = require('./LeafletMapView');
17
+ MapView = Leaflet.default;
18
+ Marker = Leaflet.Marker;
19
+ Callout = Leaflet.Callout;
20
+ } else {
21
+ const RNMaps = require('react-native-maps');
22
+ MapView = RNMaps.default;
23
+ Marker = RNMaps.Marker;
24
+ Callout = RNMaps.Callout;
25
+ PROVIDER = RNMaps.PROVIDER_DEFAULT;
26
+ PROVIDER_DEFAULT = RNMaps.PROVIDER_DEFAULT;
27
+ PROVIDER_GOOGLE = RNMaps.PROVIDER_GOOGLE;
28
+ }
29
+
30
+ export {
31
+ MapView as default,
32
+ Marker,
33
+ Callout,
34
+ PROVIDER,
35
+ PROVIDER_DEFAULT,
36
+ PROVIDER_GOOGLE,
37
+ };
package/src/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export { default } from './PlatformMapView';
2
+ export {
3
+ default as MapView,
4
+ Marker,
5
+ Callout,
6
+ PROVIDER,
7
+ PROVIDER_DEFAULT,
8
+ PROVIDER_GOOGLE,
9
+ } from './PlatformMapView';
10
+ export { default as LeafletMapView } from './LeafletMapView';