From e40b46539286d63d98d705f26e480af2bf3781f9 Mon Sep 17 00:00:00 2001 From: lodev09 Date: Thu, 26 Mar 2026 18:38:01 +0800 Subject: [PATCH 1/4] fix(web): prevent marker from being draggable by default --- src/components/Marker.web.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Marker.web.tsx b/src/components/Marker.web.tsx index 5ff3356..669c331 100644 --- a/src/components/Marker.web.tsx +++ b/src/components/Marker.web.tsx @@ -215,9 +215,9 @@ export const Marker = forwardRef( clickable draggable={draggable} onClick={handleClick} - onDragStart={handleDragStart} - onDrag={handleDrag} - onDragEnd={handleDragEnd} + onDragStart={draggable ? handleDragStart : undefined} + onDrag={draggable ? handleDrag : undefined} + onDragEnd={draggable ? handleDragEnd : undefined} style={ transforms.length > 0 ? { transform: transforms.join(' ') } From 8bec38f30f15c527bbd390db889e5dcaf1367bc0 Mon Sep 17 00:00:00 2001 From: lodev09 Date: Thu, 26 Mar 2026 19:31:26 +0800 Subject: [PATCH 2/4] perf: memoize components to prevent unnecessary re-renders - Add memo/PureComponent to MapView, Marker, Polygon, Circle, GroundOverlay, Polyline, TileOverlay, GeoJson (web + native) - Stabilize MapView.web handlers with useCallback and isDraggingRef - Memoize MapContext value to avoid consumer re-renders - Extract inline callbacks in example Home to stable useCallback refs - Wrap example Map with memo(forwardRef) --- example/shared/src/Home.tsx | 95 ++++++++++++++++---- example/shared/src/components/Map.tsx | 5 +- src/MapView.tsx | 2 +- src/MapView.web.tsx | 122 +++++++++++++++----------- src/components/Circle.web.tsx | 6 +- src/components/GeoJson.tsx | 6 +- src/components/GroundOverlay.web.tsx | 6 +- src/components/Marker.web.tsx | 5 +- src/components/Polygon.web.tsx | 6 +- src/components/Polyline.web.tsx | 6 +- src/components/TileOverlay.web.tsx | 6 +- 11 files changed, 171 insertions(+), 94 deletions(-) diff --git a/example/shared/src/Home.tsx b/example/shared/src/Home.tsx index 192dcf0..ee38d73 100644 --- a/example/shared/src/Home.tsx +++ b/example/shared/src/Home.tsx @@ -6,6 +6,8 @@ import { type MapType, type MapCameraEvent, type MapPressEvent, + type MarkerPressEvent, + type MarkerDragEvent, type GeoJSON, } from '@lugg/maps'; import { @@ -14,7 +16,7 @@ import { } from '@lodev09/react-native-true-sheet'; import { ReanimatedTrueSheetProvider } from '@lodev09/react-native-true-sheet/reanimated'; -import { Map, type MapRef, MapTypeButton } from './components'; +import { Map, type MapRef, type MarkerData, MapTypeButton } from './components'; import { useLocationPermission, useMarkers } from './hooks'; import { randomFrom } from './utils'; import { @@ -153,6 +155,68 @@ const HomeContent = () => { ); }; + const handlePress = useCallback( + (e: MapPressEvent) => formatPressEvent(e, 'Press'), + [formatPressEvent] + ); + + const handleLongPress = useCallback( + (e: MapPressEvent) => { + formatPressEvent(e, 'Long press'); + addMarker(e.nativeEvent.coordinate); + }, + [formatPressEvent, addMarker] + ); + + const handleCameraMove = useCallback( + (e: MapCameraEvent) => formatCameraEvent(e, false), + [formatCameraEvent] + ); + + const handleCameraIdle = useCallback( + (e: MapCameraEvent) => formatCameraEvent(e, true), + [formatCameraEvent] + ); + + const handleMarkerPress = useCallback( + (e: MarkerPressEvent, m: MarkerData) => + formatPressEvent(e, `Marker(${m.name})`), + [formatPressEvent] + ); + + const handleMarkerDragStart = useCallback( + (e: MarkerDragEvent, m: MarkerData) => + formatPressEvent(e, `Drag start(${m.name})`), + [formatPressEvent] + ); + + const handleMarkerDragChange = useCallback( + (e: MarkerDragEvent, m: MarkerData) => + formatPressEvent(e, `Dragging(${m.name})`), + [formatPressEvent] + ); + + const handleMarkerDragEnd = useCallback( + (e: MarkerDragEvent, m: MarkerData) => + formatPressEvent(e, `Drag end(${m.name})`), + [formatPressEvent] + ); + + const handlePolygonPress = useCallback( + () => handleOverlayPress('Polygon'), + [handleOverlayPress] + ); + + const handleCirclePress = useCallback( + () => handleOverlayPress('Circle'), + [handleOverlayPress] + ); + + const handleGroundOverlayPress = useCallback( + () => handleOverlayPress('Ground overlay'), + [handleOverlayPress] + ); + return ( {showMap && ( @@ -166,24 +230,17 @@ const HomeContent = () => { animatedPosition={controlSheetRef.current?.animatedPosition} userLocationEnabled={locationPermission} onReady={handleMapReady} - onPress={(e) => formatPressEvent(e, 'Press')} - onLongPress={(e) => { - formatPressEvent(e, 'Long press'); - addMarker(e.nativeEvent.coordinate); - }} - onCameraMove={(e) => formatCameraEvent(e, false)} - onCameraIdle={(e) => formatCameraEvent(e, true)} - onMarkerPress={(e, m) => formatPressEvent(e, `Marker(${m.name})`)} - onMarkerDragStart={(e, m) => - formatPressEvent(e, `Drag start(${m.name})`) - } - onMarkerDragChange={(e, m) => - formatPressEvent(e, `Dragging(${m.name})`) - } - onMarkerDragEnd={(e, m) => formatPressEvent(e, `Drag end(${m.name})`)} - onPolygonPress={() => handleOverlayPress('Polygon')} - onCirclePress={() => handleOverlayPress('Circle')} - onGroundOverlayPress={() => handleOverlayPress('Ground overlay')} + onPress={handlePress} + onLongPress={handleLongPress} + onCameraMove={handleCameraMove} + onCameraIdle={handleCameraIdle} + onMarkerPress={handleMarkerPress} + onMarkerDragStart={handleMarkerDragStart} + onMarkerDragChange={handleMarkerDragChange} + onMarkerDragEnd={handleMarkerDragEnd} + onPolygonPress={handlePolygonPress} + onCirclePress={handleCirclePress} + onGroundOverlayPress={handleGroundOverlayPress} /> )} diff --git a/example/shared/src/components/Map.tsx b/example/shared/src/components/Map.tsx index ab05105..640e9d9 100644 --- a/example/shared/src/components/Map.tsx +++ b/example/shared/src/components/Map.tsx @@ -1,5 +1,6 @@ import { forwardRef, + memo, useCallback, useImperativeHandle, useMemo, @@ -238,7 +239,7 @@ export interface MapRef extends MapViewRef { hideMarkerCallout(markerId: string): void; } -export const Map = forwardRef( +export const Map = memo(forwardRef( ( { markers, @@ -391,7 +392,7 @@ export const Map = forwardRef( ); } -); +)); const styles = StyleSheet.create({ container: { diff --git a/src/MapView.tsx b/src/MapView.tsx index 0422485..481bd83 100644 --- a/src/MapView.tsx +++ b/src/MapView.tsx @@ -14,7 +14,7 @@ import type { import type { Coordinate, EdgeInsets } from './types'; export class MapView - extends React.Component + extends React.PureComponent implements MapViewRef { static defaultProps: Partial = { diff --git a/src/MapView.web.tsx b/src/MapView.web.tsx index cc5d525..62bb771 100644 --- a/src/MapView.web.tsx +++ b/src/MapView.web.tsx @@ -1,9 +1,11 @@ import { forwardRef, + memo, useCallback, useEffect, useId, useImperativeHandle, + useMemo, useRef, useState, type CSSProperties, @@ -96,7 +98,7 @@ const UserLocationMarker = ({ enabled }: { enabled?: boolean }) => { ); }; -export const MapView = forwardRef(function MapView( +export const MapView = memo(forwardRef(function MapView( props, ref ) { @@ -369,54 +371,67 @@ export const MapView = forwardRef(function MapView( }; }, [map, onLongPress, handleMouseDown, handleMouseUp]); - const handleDragStart = () => { + const isDraggingRef = useRef(false); + + const handleDragStart = useCallback(() => { handleMouseUp(); + isDraggingRef.current = true; setIsDragging(true); wasGesture.current = true; - }; + }, [handleMouseUp]); - const handleDragEnd = () => { + const handleDragEnd = useCallback(() => { + isDraggingRef.current = false; setIsDragging(false); - }; - - const handleCameraChanged = (event: MapCameraChangedEvent) => { - const logicalCenter = offsetCenter( - { latitude: event.detail.center.lat, longitude: event.detail.center.lng }, - event.detail.zoom, - undefined, - true - ); - const payload: CameraEventPayload = { - coordinate: { - latitude: logicalCenter.lat, - longitude: logicalCenter.lng, - }, - zoom: event.detail.zoom, - gesture: isDragging, - }; - onCameraMove?.(createSyntheticEvent(payload)); - }; - - const handleIdle = (event: MapEvent) => { - const center = event.map.getCenter(); - const zoom = event.map.getZoom() ?? 0; - const logicalCenter = offsetCenter( - { latitude: center?.lat() ?? 0, longitude: center?.lng() ?? 0 }, - zoom, - undefined, - true - ); - const payload: CameraEventPayload = { - coordinate: { - latitude: logicalCenter.lat, - longitude: logicalCenter.lng, - }, - zoom, - gesture: wasGesture.current, - }; - onCameraIdle?.(createSyntheticEvent(payload)); - wasGesture.current = false; - }; + }, []); + + const handleCameraChanged = useCallback( + (event: MapCameraChangedEvent) => { + const logicalCenter = offsetCenter( + { + latitude: event.detail.center.lat, + longitude: event.detail.center.lng, + }, + event.detail.zoom, + undefined, + true + ); + const payload: CameraEventPayload = { + coordinate: { + latitude: logicalCenter.lat, + longitude: logicalCenter.lng, + }, + zoom: event.detail.zoom, + gesture: isDraggingRef.current, + }; + onCameraMove?.(createSyntheticEvent(payload)); + }, + [offsetCenter, onCameraMove] + ); + + const handleIdle = useCallback( + (event: MapEvent) => { + const center = event.map.getCenter(); + const zoom = event.map.getZoom() ?? 0; + const logicalCenter = offsetCenter( + { latitude: center?.lat() ?? 0, longitude: center?.lng() ?? 0 }, + zoom, + undefined, + true + ); + const payload: CameraEventPayload = { + coordinate: { + latitude: logicalCenter.lat, + longitude: logicalCenter.lng, + }, + zoom, + gesture: wasGesture.current, + }; + onCameraIdle?.(createSyntheticEvent(payload)); + wasGesture.current = false; + }, + [offsetCenter, onCameraIdle] + ); const mapTypeId = mapType === 'satellite' @@ -447,13 +462,16 @@ export const MapView = forwardRef(function MapView( return ( ({ + map, + isDragging, + moveCamera: panToCoordinate, + onCalloutClose, + closeCallouts, + }), + [map, isDragging, panToCoordinate, onCalloutClose, closeCallouts] + )} > (function MapView( ); -}); +})); diff --git a/src/components/Circle.web.tsx b/src/components/Circle.web.tsx index 3cb57ad..34922b1 100644 --- a/src/components/Circle.web.tsx +++ b/src/components/Circle.web.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { memo, useCallback, useEffect, useRef } from 'react'; import { useMapContext } from '../MapProvider.web'; import type { CircleProps } from './Circle.types'; -export const Circle = ({ +export const Circle = memo(({ center, radius, strokeColor = '#000000', @@ -90,4 +90,4 @@ export const Circle = ({ ]); return null; -}; +}); diff --git a/src/components/GeoJson.tsx b/src/components/GeoJson.tsx index b5e4caa..8afbeec 100644 --- a/src/components/GeoJson.tsx +++ b/src/components/GeoJson.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, type ReactElement } from 'react'; +import React, { memo, useMemo, type ReactElement } from 'react'; import type { Feature, FeatureCollection, @@ -199,7 +199,7 @@ const renderGeometry = ( return elements; }; -export const GeoJson = (props: GeoJsonProps) => { +export const GeoJson = memo((props: GeoJsonProps) => { const { geojson } = props; const elements = useMemo(() => { @@ -218,4 +218,4 @@ export const GeoJson = (props: GeoJsonProps) => { }, [geojson, props]); return <>{elements}; -}; +}); diff --git a/src/components/GroundOverlay.web.tsx b/src/components/GroundOverlay.web.tsx index d5a3cac..195d038 100644 --- a/src/components/GroundOverlay.web.tsx +++ b/src/components/GroundOverlay.web.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { memo, useCallback, useEffect, useRef } from 'react'; import { useMapContext } from '../MapProvider.web'; import type { GroundOverlayProps } from './GroundOverlay.types'; -export const GroundOverlay = ({ +export const GroundOverlay = memo(({ image, bounds, opacity = 1, @@ -82,4 +82,4 @@ export const GroundOverlay = ({ }, [map, image, bounds, opacity, zIndex, onPress, handleClick]); return null; -}; +}); diff --git a/src/components/Marker.web.tsx b/src/components/Marker.web.tsx index 669c331..9b14163 100644 --- a/src/components/Marker.web.tsx +++ b/src/components/Marker.web.tsx @@ -1,6 +1,7 @@ import React, { forwardRef, isValidElement, + memo, useCallback, useEffect, useImperativeHandle, @@ -60,7 +61,7 @@ const createEvent = ( }, } as any); -export const Marker = forwardRef( +export const Marker = memo(forwardRef( ( { coordinate, @@ -250,4 +251,4 @@ export const Marker = forwardRef( ); } -); +)); diff --git a/src/components/Polygon.web.tsx b/src/components/Polygon.web.tsx index 7651037..7b8e75b 100644 --- a/src/components/Polygon.web.tsx +++ b/src/components/Polygon.web.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { memo, useCallback, useEffect, useRef } from 'react'; import { useMapContext } from '../MapProvider.web'; import type { PolygonProps } from './Polygon.types'; -export const Polygon = ({ +export const Polygon = memo(({ coordinates, holes, strokeColor = '#000000', @@ -101,4 +101,4 @@ export const Polygon = ({ ]); return null; -}; +}); diff --git a/src/components/Polyline.web.tsx b/src/components/Polyline.web.tsx index a64c187..59935d6 100644 --- a/src/components/Polyline.web.tsx +++ b/src/components/Polyline.web.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useMapContext } from '../MapProvider.web'; import type { PolylineProps, PolylineEasing } from './Polyline.types'; @@ -51,7 +51,7 @@ const getGradientColor = (colors: string[], position: number): string => { return interpolateColor(colors[index]!, colors[index + 1]!, t); }; -export const Polyline = ({ +export const Polyline = memo(({ coordinates, strokeColors, strokeWidth = 1, @@ -317,4 +317,4 @@ export const Polyline = ({ ]); return null; -}; +}); diff --git a/src/components/TileOverlay.web.tsx b/src/components/TileOverlay.web.tsx index c35ed68..a8b90ad 100644 --- a/src/components/TileOverlay.web.tsx +++ b/src/components/TileOverlay.web.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { memo, useCallback, useEffect, useRef } from 'react'; import { useMapContext } from '../MapProvider.web'; import type { TileOverlayProps } from './TileOverlay.types'; -export const TileOverlay = ({ +export const TileOverlay = memo(({ urlTemplate, tileSize = 256, opacity = 1, @@ -92,4 +92,4 @@ export const TileOverlay = ({ ]); return null; -}; +}); From 185b2dba2e86e103d38032e3a55e81845d1a7283 Mon Sep 17 00:00:00 2001 From: lodev09 Date: Thu, 26 Mar 2026 19:31:50 +0800 Subject: [PATCH 3/4] perf: memoize components to prevent unnecessary re-renders - Use PureComponent for native MapView - Add memo to web components: MapView, Marker, Polygon, Circle, GroundOverlay, Polyline, TileOverlay, GeoJson - Stabilize MapView.web handlers with useCallback and isDraggingRef - Memoize MapContext value to avoid consumer re-renders - Extract inline callbacks in example Home to stable useCallback refs - Wrap example Map with memo(forwardRef) --- example/shared/src/components/Map.tsx | 294 +++++----- src/MapView.web.tsx | 758 +++++++++++++------------- src/components/Circle.web.tsx | 158 +++--- src/components/GroundOverlay.web.tsx | 132 +++-- src/components/Marker.web.tsx | 366 ++++++------- src/components/Polygon.web.tsx | 180 +++--- src/components/Polyline.web.tsx | 473 ++++++++-------- src/components/TileOverlay.web.tsx | 162 +++--- 8 files changed, 1272 insertions(+), 1251 deletions(-) diff --git a/example/shared/src/components/Map.tsx b/example/shared/src/components/Map.tsx index 640e9d9..f66556e 100644 --- a/example/shared/src/components/Map.tsx +++ b/example/shared/src/components/Map.tsx @@ -239,160 +239,162 @@ export interface MapRef extends MapViewRef { hideMarkerCallout(markerId: string): void; } -export const Map = memo(forwardRef( - ( - { - markers, - geojson, - provider, - edgeInsets, - animatedPosition, - onCameraIdle, - onCameraMove, - onPress, - onLongPress, - onPolygonPress, - onCirclePress, - onGroundOverlayPress, - onMarkerPress, - onMarkerDragStart, - onMarkerDragChange, - onMarkerDragEnd, - ...props - }, - ref - ) => { - const { height: screenHeight } = useWindowDimensions(); - const mapRef = useRef(null); - const markerRefsMap = useRef(new globalThis.Map()); - const [zoom, setZoom] = useState(INITIAL_ZOOM); +export const Map = memo( + forwardRef( + ( + { + markers, + geojson, + provider, + edgeInsets, + animatedPosition, + onCameraIdle, + onCameraMove, + onPress, + onLongPress, + onPolygonPress, + onCirclePress, + onGroundOverlayPress, + onMarkerPress, + onMarkerDragStart, + onMarkerDragChange, + onMarkerDragEnd, + ...props + }, + ref + ) => { + const { height: screenHeight } = useWindowDimensions(); + const mapRef = useRef(null); + const markerRefsMap = useRef(new globalThis.Map()); + const [zoom, setZoom] = useState(INITIAL_ZOOM); - const handleMarkerRef = useCallback((id: string, r: Marker | null) => { - if (r) { - markerRefsMap.current.set(id, r); - } else { - markerRefsMap.current.delete(id); - } - }, []); + const handleMarkerRef = useCallback((id: string, r: Marker | null) => { + if (r) { + markerRefsMap.current.set(id, r); + } else { + markerRefsMap.current.delete(id); + } + }, []); - useImperativeHandle( - ref, - () => ({ - moveCamera: (...args) => mapRef.current?.moveCamera(...args), - fitCoordinates: (...args) => mapRef.current?.fitCoordinates(...args), - setEdgeInsets: (...args) => mapRef.current?.setEdgeInsets(...args), - showMarkerCallout: (markerId) => - markerRefsMap.current.get(markerId)?.showCallout(), - hideMarkerCallout: (markerId) => - markerRefsMap.current.get(markerId)?.hideCallout(), - }), - [] - ); + useImperativeHandle( + ref, + () => ({ + moveCamera: (...args) => mapRef.current?.moveCamera(...args), + fitCoordinates: (...args) => mapRef.current?.fitCoordinates(...args), + setEdgeInsets: (...args) => mapRef.current?.setEdgeInsets(...args), + showMarkerCallout: (markerId) => + markerRefsMap.current.get(markerId)?.showCallout(), + hideMarkerCallout: (markerId) => + markerRefsMap.current.get(markerId)?.hideCallout(), + }), + [] + ); - const smoothedRoute = useMemo( - () => smoothCoordinates(markers.map((m) => m.coordinate)), - [markers] - ); + const smoothedRoute = useMemo( + () => smoothCoordinates(markers.map((m) => m.coordinate)), + [markers] + ); - const centerPinStyle = useAnimatedStyle(() => { - const bottom = animatedPosition - ? screenHeight - animatedPosition.value - : 0; - return { transform: [{ translateY: -bottom / 2 }] }; - }); + const centerPinStyle = useAnimatedStyle(() => { + const bottom = animatedPosition + ? screenHeight - animatedPosition.value + : 0; + return { transform: [{ translateY: -bottom / 2 }] }; + }); - const handleMarkerPress = (e: MarkerPressEvent, marker: MarkerData) => { - onMarkerPress?.(e, marker); - }; + const handleMarkerPress = (e: MarkerPressEvent, marker: MarkerData) => { + onMarkerPress?.(e, marker); + }; - const handleCameraIdle = (e: MapCameraEvent) => { - setZoom(e.nativeEvent.zoom); - onCameraIdle?.(e); - }; + const handleCameraIdle = (e: MapCameraEvent) => { + setZoom(e.nativeEvent.zoom); + onCameraIdle?.(e); + }; - return ( - - - {markers.map((m) => - renderMarker( - m, - handleMarkerPress, - onMarkerDragStart, - onMarkerDragChange, - onMarkerDragEnd, - handleMarkerRef, - provider === 'apple' || Platform.OS === 'android' - ) - )} - - - - - - - - {geojson && ( - ( - - )} + return ( + + + {markers.map((m) => + renderMarker( + m, + handleMarkerPress, + onMarkerDragStart, + onMarkerDragChange, + onMarkerDragEnd, + handleMarkerRef, + provider === 'apple' || Platform.OS === 'android' + ) + )} + + + - )} - - - - - - ); - } -)); + + + + + {geojson && ( + ( + + )} + /> + )} + + + + + + ); + } + ) +); const styles = StyleSheet.create({ container: { diff --git a/src/MapView.web.tsx b/src/MapView.web.tsx index 62bb771..4575a24 100644 --- a/src/MapView.web.tsx +++ b/src/MapView.web.tsx @@ -98,405 +98,413 @@ const UserLocationMarker = ({ enabled }: { enabled?: boolean }) => { ); }; -export const MapView = memo(forwardRef(function MapView( - props, - ref -) { - const { - mapType = 'standard', - mapId = 'DEMO_MAP_ID', - initialCoordinate, - initialZoom = 10, - minZoom, - maxZoom, - zoomEnabled = true, - scrollEnabled = true, - pitchEnabled = true, - edgeInsets, - userLocationEnabled, - theme = 'system', - onPress, - onLongPress, - onCameraMove, - onCameraIdle, - onReady, - children, - style, - } = props; - - const id = useId(); - const map = useMap(id); - const containerRef = useRef(null); - const readyFired = useRef(false); - const [isDragging, setIsDragging] = useState(false); - const wasGesture = useRef(false); - const prevEdgeInsets = useRef(edgeInsets); - - const offsetCenter = useCallback( - (coord: Coordinate, zoom: number, insets?: EdgeInsets, reverse = false) => { - const p = insets ?? prevEdgeInsets.current; - if (!p) { - return { lat: coord.latitude, lng: coord.longitude }; - } - - const dir = reverse ? -1 : 1; - const scale = 256 * Math.pow(2, zoom); - const offsetX = (dir * ((p.right ?? 0) - (p.left ?? 0))) / 2; - const offsetY = (dir * ((p.bottom ?? 0) - (p.top ?? 0))) / 2; - - const latRad = (coord.latitude * Math.PI) / 180; - const x = ((coord.longitude + 180) / 360) * scale + offsetX; - const y = - ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / - 2) * - scale + - offsetY; - - const lng = (x / scale) * 360 - 180; - const lat = - (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / scale))) * 180) / Math.PI; - - return { lat, lng }; - }, - [] - ); - - const applyEdgeInsets = useCallback( - (newEdgeInsets: EdgeInsets, duration?: number) => { - if (!map) return; - - const prev = prevEdgeInsets.current; - const center = map.getCenter(); - const zoom = map.getZoom() ?? initialZoom; - - if (center) { - const logicalCenter = offsetCenter( - { latitude: center.lat(), longitude: center.lng() }, - zoom, - prev, - true - ); - const newCenter = offsetCenter( - { latitude: logicalCenter.lat, longitude: logicalCenter.lng }, - zoom, - newEdgeInsets, - false - ); - - if (duration === 0) { - map.moveCamera({ center: newCenter, zoom }); - } else { - map.panTo(newCenter); +export const MapView = memo( + forwardRef(function MapView(props, ref) { + const { + mapType = 'standard', + mapId = 'DEMO_MAP_ID', + initialCoordinate, + initialZoom = 10, + minZoom, + maxZoom, + zoomEnabled = true, + scrollEnabled = true, + pitchEnabled = true, + edgeInsets, + userLocationEnabled, + theme = 'system', + onPress, + onLongPress, + onCameraMove, + onCameraIdle, + onReady, + children, + style, + } = props; + + const id = useId(); + const map = useMap(id); + const containerRef = useRef(null); + const readyFired = useRef(false); + const [isDragging, setIsDragging] = useState(false); + const wasGesture = useRef(false); + const prevEdgeInsets = useRef(edgeInsets); + + const offsetCenter = useCallback( + ( + coord: Coordinate, + zoom: number, + insets?: EdgeInsets, + reverse = false + ) => { + const p = insets ?? prevEdgeInsets.current; + if (!p) { + return { lat: coord.latitude, lng: coord.longitude }; } - } - - prevEdgeInsets.current = newEdgeInsets; - }, - [map, initialZoom, offsetCenter] - ); - const panToCoordinate = useCallback( - (coordinate: Coordinate) => { - if (!map) return; - const zoom = map.getZoom() || initialZoom; - const center = offsetCenter(coordinate, zoom, undefined, false); - map.panTo(center); - }, - [map, initialZoom, offsetCenter] - ); + const dir = reverse ? -1 : 1; + const scale = 256 * Math.pow(2, zoom); + const offsetX = (dir * ((p.right ?? 0) - (p.left ?? 0))) / 2; + const offsetY = (dir * ((p.bottom ?? 0) - (p.top ?? 0))) / 2; + + const latRad = (coord.latitude * Math.PI) / 180; + const x = ((coord.longitude + 180) / 360) * scale + offsetX; + const y = + ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / + 2) * + scale + + offsetY; + + const lng = (x / scale) * 360 - 180; + const lat = + (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / scale))) * 180) / + Math.PI; + + return { lat, lng }; + }, + [] + ); - useImperativeHandle( - ref, - () => ({ - moveCamera(coordinate: Coordinate, options?: MoveCameraOptions) { + const applyEdgeInsets = useCallback( + (newEdgeInsets: EdgeInsets, duration?: number) => { if (!map) return; - const { zoom = 0, duration = -1 } = options ?? {}; - const targetZoom = zoom || map.getZoom() || initialZoom; - const center = offsetCenter(coordinate, targetZoom, undefined, false); - - if (duration === 0) { - map.moveCamera({ center, zoom: targetZoom }); - } else { - const currentZoom = map.getZoom(); - const zoomChanged = zoom !== 0 && zoom !== currentZoom; - - if (zoomChanged) { - map.setZoom(zoom); + const prev = prevEdgeInsets.current; + const center = map.getCenter(); + const zoom = map.getZoom() ?? initialZoom; + + if (center) { + const logicalCenter = offsetCenter( + { latitude: center.lat(), longitude: center.lng() }, + zoom, + prev, + true + ); + const newCenter = offsetCenter( + { latitude: logicalCenter.lat, longitude: logicalCenter.lng }, + zoom, + newEdgeInsets, + false + ); + + if (duration === 0) { + map.moveCamera({ center: newCenter, zoom }); + } else { + map.panTo(newCenter); } - map.panTo(center); } - }, - fitCoordinates( - coordinates: Coordinate[], - options?: FitCoordinatesOptions - ) { - const first = coordinates[0]; - if (!map || !first) return; - - const { padding: fitPadding, duration = -1 } = options ?? {}; - - if (coordinates.length === 1) { - this.moveCamera(first, { zoom: initialZoom, duration }); - return; - } - - const bounds = new google.maps.LatLngBounds(); - coordinates.forEach((coord) => { - bounds.extend({ lat: coord.latitude, lng: coord.longitude }); - }); - - const ei = prevEdgeInsets.current; - map.fitBounds(bounds, { - top: (ei?.top ?? 0) + (fitPadding?.top ?? 0), - left: (ei?.left ?? 0) + (fitPadding?.left ?? 0), - bottom: (ei?.bottom ?? 0) + (fitPadding?.bottom ?? 0), - right: (ei?.right ?? 0) + (fitPadding?.right ?? 0), - }); + prevEdgeInsets.current = newEdgeInsets; }, + [map, initialZoom, offsetCenter] + ); - setEdgeInsets(newEdgeInsets: EdgeInsets, options?: SetEdgeInsetsOptions) { - applyEdgeInsets(newEdgeInsets, options?.duration); + const panToCoordinate = useCallback( + (coordinate: Coordinate) => { + if (!map) return; + const zoom = map.getZoom() || initialZoom; + const center = offsetCenter(coordinate, zoom, undefined, false); + map.panTo(center); }, - }), - [map, initialZoom, offsetCenter, applyEdgeInsets] - ); + [map, initialZoom, offsetCenter] + ); + + useImperativeHandle( + ref, + () => ({ + moveCamera(coordinate: Coordinate, options?: MoveCameraOptions) { + if (!map) return; + + const { zoom = 0, duration = -1 } = options ?? {}; + const targetZoom = zoom || map.getZoom() || initialZoom; + const center = offsetCenter(coordinate, targetZoom, undefined, false); + + if (duration === 0) { + map.moveCamera({ center, zoom: targetZoom }); + } else { + const currentZoom = map.getZoom(); + const zoomChanged = zoom !== 0 && zoom !== currentZoom; + + if (zoomChanged) { + map.setZoom(zoom); + } + map.panTo(center); + } + }, - useEffect(() => { - if (map && !readyFired.current) { - readyFired.current = true; - onReady?.(); - } - }, [map, onReady]); + fitCoordinates( + coordinates: Coordinate[], + options?: FitCoordinatesOptions + ) { + const first = coordinates[0]; + if (!map || !first) return; - useEffect(() => { - if (!map || !edgeInsets) return; + const { padding: fitPadding, duration = -1 } = options ?? {}; - const prev = prevEdgeInsets.current; - const changed = - prev?.top !== edgeInsets.top || - prev?.left !== edgeInsets.left || - prev?.bottom !== edgeInsets.bottom || - prev?.right !== edgeInsets.right; + if (coordinates.length === 1) { + this.moveCamera(first, { zoom: initialZoom, duration }); + return; + } - if (changed) { - applyEdgeInsets(edgeInsets); - } - }, [map, edgeInsets, applyEdgeInsets]); - - const longPressTimer = useRef | null>(null); - const longPressFired = useRef(false); - - const calloutListeners = useRef(new Set<() => void>()).current; - const onCalloutClose = useCallback( - (listener: () => void) => { - calloutListeners.add(listener); - return () => calloutListeners.delete(listener); - }, - [calloutListeners] - ); - const closeCallouts = useCallback( - (except?: () => void) => { - calloutListeners.forEach((l) => { - if (l !== except) l(); - }); - }, - [calloutListeners] - ); + const bounds = new google.maps.LatLngBounds(); + coordinates.forEach((coord) => { + bounds.extend({ lat: coord.latitude, lng: coord.longitude }); + }); + + const ei = prevEdgeInsets.current; + map.fitBounds(bounds, { + top: (ei?.top ?? 0) + (fitPadding?.top ?? 0), + left: (ei?.left ?? 0) + (fitPadding?.left ?? 0), + bottom: (ei?.bottom ?? 0) + (fitPadding?.bottom ?? 0), + right: (ei?.right ?? 0) + (fitPadding?.right ?? 0), + }); + }, - const getPoint = useCallback((domEvent?: Event) => { - const el = containerRef.current as unknown as HTMLElement | null; - if (!domEvent || !el) return { x: 0, y: 0 }; - const rect = el.getBoundingClientRect(); - const e = domEvent as MouseEvent | Touch; - return { x: e.clientX - rect.left, y: e.clientY - rect.top }; - }, []); - - const handleClick = useCallback( - (event: MapMouseEvent) => { - if (longPressFired.current) { - longPressFired.current = false; - return; + setEdgeInsets( + newEdgeInsets: EdgeInsets, + options?: SetEdgeInsetsOptions + ) { + applyEdgeInsets(newEdgeInsets, options?.duration); + }, + }), + [map, initialZoom, offsetCenter, applyEdgeInsets] + ); + + useEffect(() => { + if (map && !readyFired.current) { + readyFired.current = true; + onReady?.(); } - closeCallouts(); - const latLng = event.detail.latLng; - if (!onPress || !latLng) return; - onPress( - createSyntheticEvent({ - coordinate: { latitude: latLng.lat, longitude: latLng.lng }, - point: getPoint(event.domEvent), - }) - ); - }, - [onPress, getPoint, closeCallouts] - ); + }, [map, onReady]); - const handleMouseDown = useCallback( - (event: google.maps.MapMouseEvent) => { - if (!onLongPress) return; - const point = getPoint(event.domEvent); - longPressFired.current = false; - longPressTimer.current = setTimeout(() => { - longPressFired.current = true; - const latLng = event.latLng; - if (!latLng) return; - onLongPress( + useEffect(() => { + if (!map || !edgeInsets) return; + + const prev = prevEdgeInsets.current; + const changed = + prev?.top !== edgeInsets.top || + prev?.left !== edgeInsets.left || + prev?.bottom !== edgeInsets.bottom || + prev?.right !== edgeInsets.right; + + if (changed) { + applyEdgeInsets(edgeInsets); + } + }, [map, edgeInsets, applyEdgeInsets]); + + const longPressTimer = useRef | null>(null); + const longPressFired = useRef(false); + + const calloutListeners = useRef(new Set<() => void>()).current; + const onCalloutClose = useCallback( + (listener: () => void) => { + calloutListeners.add(listener); + return () => calloutListeners.delete(listener); + }, + [calloutListeners] + ); + const closeCallouts = useCallback( + (except?: () => void) => { + calloutListeners.forEach((l) => { + if (l !== except) l(); + }); + }, + [calloutListeners] + ); + + const getPoint = useCallback((domEvent?: Event) => { + const el = containerRef.current as unknown as HTMLElement | null; + if (!domEvent || !el) return { x: 0, y: 0 }; + const rect = el.getBoundingClientRect(); + const e = domEvent as MouseEvent | Touch; + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + }, []); + + const handleClick = useCallback( + (event: MapMouseEvent) => { + if (longPressFired.current) { + longPressFired.current = false; + return; + } + closeCallouts(); + const latLng = event.detail.latLng; + if (!onPress || !latLng) return; + onPress( createSyntheticEvent({ - coordinate: { latitude: latLng.lat(), longitude: latLng.lng() }, - point, + coordinate: { latitude: latLng.lat, longitude: latLng.lng }, + point: getPoint(event.domEvent), }) ); - }, 500); - }, - [onLongPress, getPoint] - ); + }, + [onPress, getPoint, closeCallouts] + ); - const handleMouseUp = useCallback(() => { - if (longPressTimer.current) { - clearTimeout(longPressTimer.current); - longPressTimer.current = null; - } - }, []); + const handleMouseDown = useCallback( + (event: google.maps.MapMouseEvent) => { + if (!onLongPress) return; + const point = getPoint(event.domEvent); + longPressFired.current = false; + longPressTimer.current = setTimeout(() => { + longPressFired.current = true; + const latLng = event.latLng; + if (!latLng) return; + onLongPress( + createSyntheticEvent({ + coordinate: { latitude: latLng.lat(), longitude: latLng.lng() }, + point, + }) + ); + }, 500); + }, + [onLongPress, getPoint] + ); - useEffect(() => { - if (!map || !onLongPress) return; - const listeners = [ - map.addListener('mousedown', handleMouseDown), - map.addListener('mouseup', handleMouseUp), - ]; - return () => { - listeners.forEach((l) => google.maps.event.removeListener(l)); - handleMouseUp(); - }; - }, [map, onLongPress, handleMouseDown, handleMouseUp]); - - const isDraggingRef = useRef(false); - - const handleDragStart = useCallback(() => { - handleMouseUp(); - isDraggingRef.current = true; - setIsDragging(true); - wasGesture.current = true; - }, [handleMouseUp]); - - const handleDragEnd = useCallback(() => { - isDraggingRef.current = false; - setIsDragging(false); - }, []); - - const handleCameraChanged = useCallback( - (event: MapCameraChangedEvent) => { - const logicalCenter = offsetCenter( - { - latitude: event.detail.center.lat, - longitude: event.detail.center.lng, - }, - event.detail.zoom, - undefined, - true - ); - const payload: CameraEventPayload = { - coordinate: { - latitude: logicalCenter.lat, - longitude: logicalCenter.lng, - }, - zoom: event.detail.zoom, - gesture: isDraggingRef.current, + const handleMouseUp = useCallback(() => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }, []); + + useEffect(() => { + if (!map || !onLongPress) return; + const listeners = [ + map.addListener('mousedown', handleMouseDown), + map.addListener('mouseup', handleMouseUp), + ]; + return () => { + listeners.forEach((l) => google.maps.event.removeListener(l)); + handleMouseUp(); }; - onCameraMove?.(createSyntheticEvent(payload)); - }, - [offsetCenter, onCameraMove] - ); + }, [map, onLongPress, handleMouseDown, handleMouseUp]); - const handleIdle = useCallback( - (event: MapEvent) => { - const center = event.map.getCenter(); - const zoom = event.map.getZoom() ?? 0; - const logicalCenter = offsetCenter( - { latitude: center?.lat() ?? 0, longitude: center?.lng() ?? 0 }, - zoom, - undefined, - true - ); - const payload: CameraEventPayload = { - coordinate: { - latitude: logicalCenter.lat, - longitude: logicalCenter.lng, - }, - zoom, - gesture: wasGesture.current, - }; - onCameraIdle?.(createSyntheticEvent(payload)); - wasGesture.current = false; - }, - [offsetCenter, onCameraIdle] - ); + const isDraggingRef = useRef(false); - const mapTypeId = - mapType === 'satellite' - ? 'satellite' - : mapType === 'terrain' - ? 'terrain' - : mapType === 'hybrid' - ? 'hybrid' - : 'roadmap'; - - const gestureHandling = - scrollEnabled === false && zoomEnabled === false - ? 'none' - : scrollEnabled === false - ? 'cooperative' - : 'auto'; - - const colorScheme = - theme === 'dark' - ? ColorScheme.DARK - : theme === 'light' - ? ColorScheme.LIGHT - : ColorScheme.FOLLOW_SYSTEM; - - const defaultCenter = initialCoordinate - ? { lat: initialCoordinate.latitude, lng: initialCoordinate.longitude } - : undefined; + const handleDragStart = useCallback(() => { + handleMouseUp(); + isDraggingRef.current = true; + setIsDragging(true); + wasGesture.current = true; + }, [handleMouseUp]); + + const handleDragEnd = useCallback(() => { + isDraggingRef.current = false; + setIsDragging(false); + }, []); + + const handleCameraChanged = useCallback( + (event: MapCameraChangedEvent) => { + const logicalCenter = offsetCenter( + { + latitude: event.detail.center.lat, + longitude: event.detail.center.lng, + }, + event.detail.zoom, + undefined, + true + ); + const payload: CameraEventPayload = { + coordinate: { + latitude: logicalCenter.lat, + longitude: logicalCenter.lng, + }, + zoom: event.detail.zoom, + gesture: isDraggingRef.current, + }; + onCameraMove?.(createSyntheticEvent(payload)); + }, + [offsetCenter, onCameraMove] + ); - return ( - ({ - map, - isDragging, - moveCamera: panToCoordinate, - onCalloutClose, - closeCallouts, - }), - [map, isDragging, panToCoordinate, onCalloutClose, closeCallouts] - )} - > - - - - {children} - - - - ); -})); + const handleIdle = useCallback( + (event: MapEvent) => { + const center = event.map.getCenter(); + const zoom = event.map.getZoom() ?? 0; + const logicalCenter = offsetCenter( + { latitude: center?.lat() ?? 0, longitude: center?.lng() ?? 0 }, + zoom, + undefined, + true + ); + const payload: CameraEventPayload = { + coordinate: { + latitude: logicalCenter.lat, + longitude: logicalCenter.lng, + }, + zoom, + gesture: wasGesture.current, + }; + onCameraIdle?.(createSyntheticEvent(payload)); + wasGesture.current = false; + }, + [offsetCenter, onCameraIdle] + ); + + const mapTypeId = + mapType === 'satellite' + ? 'satellite' + : mapType === 'terrain' + ? 'terrain' + : mapType === 'hybrid' + ? 'hybrid' + : 'roadmap'; + + const gestureHandling = + scrollEnabled === false && zoomEnabled === false + ? 'none' + : scrollEnabled === false + ? 'cooperative' + : 'auto'; + + const colorScheme = + theme === 'dark' + ? ColorScheme.DARK + : theme === 'light' + ? ColorScheme.LIGHT + : ColorScheme.FOLLOW_SYSTEM; + + const defaultCenter = initialCoordinate + ? { lat: initialCoordinate.latitude, lng: initialCoordinate.longitude } + : undefined; + + return ( + ({ + map, + isDragging, + moveCamera: panToCoordinate, + onCalloutClose, + closeCallouts, + }), + [map, isDragging, panToCoordinate, onCalloutClose, closeCallouts] + )} + > + + + + {children} + + + + ); + }) +); diff --git a/src/components/Circle.web.tsx b/src/components/Circle.web.tsx index 34922b1..730ef3d 100644 --- a/src/components/Circle.web.tsx +++ b/src/components/Circle.web.tsx @@ -2,92 +2,94 @@ import { memo, useCallback, useEffect, useRef } from 'react'; import { useMapContext } from '../MapProvider.web'; import type { CircleProps } from './Circle.types'; -export const Circle = memo(({ - center, - radius, - strokeColor = '#000000', - strokeWidth = 1, - fillColor = 'rgba(0, 0, 0, 0.3)', - zIndex = 0, - onPress, -}: CircleProps) => { - const { map } = useMapContext(); - const circleRef = useRef(null); - const listenersRef = useRef([]); +export const Circle = memo( + ({ + center, + radius, + strokeColor = '#000000', + strokeWidth = 1, + fillColor = 'rgba(0, 0, 0, 0.3)', + zIndex = 0, + onPress, + }: CircleProps) => { + const { map } = useMapContext(); + const circleRef = useRef(null); + const listenersRef = useRef([]); - const handleClick = useCallback(() => { - onPress?.(); - }, [onPress]); + const handleClick = useCallback(() => { + onPress?.(); + }, [onPress]); - useEffect(() => { - return () => { - listenersRef.current.forEach((l) => l.remove()); - listenersRef.current = []; - circleRef.current?.setMap(null); - circleRef.current = null; - }; - }, []); + useEffect(() => { + return () => { + listenersRef.current.forEach((l) => l.remove()); + listenersRef.current = []; + circleRef.current?.setMap(null); + circleRef.current = null; + }; + }, []); - useEffect(() => { - const circle = circleRef.current; - if (!circle) return; + useEffect(() => { + const circle = circleRef.current; + if (!circle) return; - listenersRef.current.forEach((l) => l.remove()); - listenersRef.current = []; + listenersRef.current.forEach((l) => l.remove()); + listenersRef.current = []; - if (onPress) { - listenersRef.current.push(circle.addListener('click', handleClick)); - } - circle.set('clickable', !!onPress); - }, [onPress, handleClick]); + if (onPress) { + listenersRef.current.push(circle.addListener('click', handleClick)); + } + circle.set('clickable', !!onPress); + }, [onPress, handleClick]); - useEffect(() => { - if (!map) { - circleRef.current?.setMap(null); - return; - } + useEffect(() => { + if (!map) { + circleRef.current?.setMap(null); + return; + } - const googleCenter = { lat: center.latitude, lng: center.longitude }; + const googleCenter = { lat: center.latitude, lng: center.longitude }; - if (circleRef.current) { - circleRef.current.setCenter(googleCenter); - circleRef.current.setRadius(radius); - circleRef.current.setOptions({ - strokeColor: strokeColor as string, - strokeWeight: strokeWidth, - fillColor: fillColor as string, - zIndex, - }); - } else { - const circle = new google.maps.Circle({ - center: googleCenter, - radius, - strokeColor: strokeColor as string, - strokeWeight: strokeWidth, - strokeOpacity: 1, - fillColor: fillColor as string, - fillOpacity: 1, - zIndex, - clickable: !!onPress, - map, - }); - circleRef.current = circle; + if (circleRef.current) { + circleRef.current.setCenter(googleCenter); + circleRef.current.setRadius(radius); + circleRef.current.setOptions({ + strokeColor: strokeColor as string, + strokeWeight: strokeWidth, + fillColor: fillColor as string, + zIndex, + }); + } else { + const circle = new google.maps.Circle({ + center: googleCenter, + radius, + strokeColor: strokeColor as string, + strokeWeight: strokeWidth, + strokeOpacity: 1, + fillColor: fillColor as string, + fillOpacity: 1, + zIndex, + clickable: !!onPress, + map, + }); + circleRef.current = circle; - if (onPress) { - listenersRef.current.push(circle.addListener('click', handleClick)); + if (onPress) { + listenersRef.current.push(circle.addListener('click', handleClick)); + } } - } - }, [ - map, - center, - radius, - strokeColor, - strokeWidth, - fillColor, - zIndex, - onPress, - handleClick, - ]); + }, [ + map, + center, + radius, + strokeColor, + strokeWidth, + fillColor, + zIndex, + onPress, + handleClick, + ]); - return null; -}); + return null; + } +); diff --git a/src/components/GroundOverlay.web.tsx b/src/components/GroundOverlay.web.tsx index 195d038..8574ac8 100644 --- a/src/components/GroundOverlay.web.tsx +++ b/src/components/GroundOverlay.web.tsx @@ -2,84 +2,80 @@ import { memo, useCallback, useEffect, useRef } from 'react'; import { useMapContext } from '../MapProvider.web'; import type { GroundOverlayProps } from './GroundOverlay.types'; -export const GroundOverlay = memo(({ - image, - bounds, - opacity = 1, - zIndex = 0, - onPress, -}: GroundOverlayProps) => { - const { map } = useMapContext(); - const overlayRef = useRef(null); - const listenersRef = useRef([]); +export const GroundOverlay = memo( + ({ image, bounds, opacity = 1, zIndex = 0, onPress }: GroundOverlayProps) => { + const { map } = useMapContext(); + const overlayRef = useRef(null); + const listenersRef = useRef([]); - const handleClick = useCallback(() => { - onPress?.(); - }, [onPress]); + const handleClick = useCallback(() => { + onPress?.(); + }, [onPress]); + + // Cleanup on unmount + useEffect(() => { + return () => { + listenersRef.current.forEach((l) => l.remove()); + listenersRef.current = []; + overlayRef.current?.setMap(null); + overlayRef.current = null; + }; + }, []); + + // Sync listeners + useEffect(() => { + const overlay = overlayRef.current; + if (!overlay) return; - // Cleanup on unmount - useEffect(() => { - return () => { listenersRef.current.forEach((l) => l.remove()); listenersRef.current = []; - overlayRef.current?.setMap(null); - overlayRef.current = null; - }; - }, []); - // Sync listeners - useEffect(() => { - const overlay = overlayRef.current; - if (!overlay) return; + if (onPress) { + listenersRef.current.push(overlay.addListener('click', handleClick)); + } + overlay.set('clickable', !!onPress); + }, [onPress, handleClick]); - listenersRef.current.forEach((l) => l.remove()); - listenersRef.current = []; + // Sync overlay with props + useEffect(() => { + if (!map) { + overlayRef.current?.setMap(null); + return; + } - if (onPress) { - listenersRef.current.push(overlay.addListener('click', handleClick)); - } - overlay.set('clickable', !!onPress); - }, [onPress, handleClick]); - - // Sync overlay with props - useEffect(() => { - if (!map) { - overlayRef.current?.setMap(null); - return; - } + const source = + typeof image === 'number' + ? null + : Array.isArray(image) + ? image[0] + : image; + const imageUrl = source?.uri ?? ''; + if (!imageUrl) return; - const source = - typeof image === 'number' - ? null - : Array.isArray(image) - ? image[0] - : image; - const imageUrl = source?.uri ?? ''; - if (!imageUrl) return; + const latLngBounds = new google.maps.LatLngBounds( + { lat: bounds.southwest.latitude, lng: bounds.southwest.longitude }, + { lat: bounds.northeast.latitude, lng: bounds.northeast.longitude } + ); - const latLngBounds = new google.maps.LatLngBounds( - { lat: bounds.southwest.latitude, lng: bounds.southwest.longitude }, - { lat: bounds.northeast.latitude, lng: bounds.northeast.longitude } - ); - - // GroundOverlay bounds are immutable — recreate if bounds change - overlayRef.current?.setMap(null); - listenersRef.current.forEach((l) => l.remove()); - listenersRef.current = []; + // GroundOverlay bounds are immutable — recreate if bounds change + overlayRef.current?.setMap(null); + listenersRef.current.forEach((l) => l.remove()); + listenersRef.current = []; - const overlay = new google.maps.GroundOverlay(imageUrl, latLngBounds, { - opacity, - clickable: !!onPress, - map, - }); + const overlay = new google.maps.GroundOverlay(imageUrl, latLngBounds, { + opacity, + clickable: !!onPress, + map, + }); - overlay.set('zIndex', zIndex); - overlayRef.current = overlay; + overlay.set('zIndex', zIndex); + overlayRef.current = overlay; - if (onPress) { - listenersRef.current.push(overlay.addListener('click', handleClick)); - } - }, [map, image, bounds, opacity, zIndex, onPress, handleClick]); + if (onPress) { + listenersRef.current.push(overlay.addListener('click', handleClick)); + } + }, [map, image, bounds, opacity, zIndex, onPress, handleClick]); - return null; -}); + return null; + } +); diff --git a/src/components/Marker.web.tsx b/src/components/Marker.web.tsx index 9b14163..2626f79 100644 --- a/src/components/Marker.web.tsx +++ b/src/components/Marker.web.tsx @@ -61,194 +61,196 @@ const createEvent = ( }, } as any); -export const Marker = memo(forwardRef( - ( - { - coordinate, - title, - description, - anchor, - zIndex, - rotate, - scale, - centerOnPress = true, - draggable, - onPress, - onDragStart, - onDragChange, - onDragEnd, - callout, - calloutOptions, - children, - }, - ref - ) => { - const { moveCamera, onCalloutClose, closeCallouts } = useMapContext(); - const dragPositionRef = useRef(null); - const [markerRef, markerElement] = useAdvancedMarkerRef(); - const [infoWindowOpen, setInfoWindowOpen] = useState(false); - const calloutBubbled = calloutOptions?.bubbled ?? true; - const calloutOffset = calloutOptions?.offset; - - const closeCallout = useCallback(() => setInfoWindowOpen(false), []); - - useEffect( - () => onCalloutClose(closeCallout), - [onCalloutClose, closeCallout] - ); - - useEffect(() => { - if (!calloutBubbled) injectUnbubbledStyle(); - }, [calloutBubbled]); - - const hasCallout = !!(callout || title); - - useImperativeHandle( - ref, - () => ({ - showCallout() { +export const Marker = memo( + forwardRef( + ( + { + coordinate, + title, + description, + anchor, + zIndex, + rotate, + scale, + centerOnPress = true, + draggable, + onPress, + onDragStart, + onDragChange, + onDragEnd, + callout, + calloutOptions, + children, + }, + ref + ) => { + const { moveCamera, onCalloutClose, closeCallouts } = useMapContext(); + const dragPositionRef = useRef(null); + const [markerRef, markerElement] = useAdvancedMarkerRef(); + const [infoWindowOpen, setInfoWindowOpen] = useState(false); + const calloutBubbled = calloutOptions?.bubbled ?? true; + const calloutOffset = calloutOptions?.offset; + + const closeCallout = useCallback(() => setInfoWindowOpen(false), []); + + useEffect( + () => onCalloutClose(closeCallout), + [onCalloutClose, closeCallout] + ); + + useEffect(() => { + if (!calloutBubbled) injectUnbubbledStyle(); + }, [calloutBubbled]); + + const hasCallout = !!(callout || title); + + useImperativeHandle( + ref, + () => ({ + showCallout() { + if (hasCallout) { + closeCallouts(closeCallout); + setInfoWindowOpen(true); + } + }, + hideCallout() { + setInfoWindowOpen(false); + }, + }), + [hasCallout, closeCallouts, closeCallout] + ); + + const calloutContent = callout + ? isValidElement(callout) + ? callout + : React.createElement(callout) + : title + ? React.createElement( + 'div', + { style: { fontSize: 14 } }, + React.createElement('div', { style: { fontWeight: 500 } }, title), + description ? React.createElement('div', null, description) : null + ) + : null; + + const transforms: string[] = []; + if (rotate) transforms.push(`rotate(${rotate}deg)`); + if (scale && scale !== 1) transforms.push(`scale(${scale})`); + + const handleClick = useCallback( + (e: google.maps.MapMouseEvent) => { + const pos = dragPositionRef.current; + const coord = pos + ? { latitude: pos.lat, longitude: pos.lng } + : coordinate; + if (centerOnPress) moveCamera(coord); + onPress?.(createEvent(e, coordinate)); if (hasCallout) { closeCallouts(closeCallout); - setInfoWindowOpen(true); + setInfoWindowOpen((prev) => !prev); } }, - hideCallout() { - setInfoWindowOpen(false); + [ + centerOnPress, + moveCamera, + onPress, + coordinate, + hasCallout, + closeCallouts, + closeCallout, + ] + ); + + const handleDragStart = useCallback( + (e: google.maps.MapMouseEvent) => { + const latLng = e.latLng; + if (latLng) { + dragPositionRef.current = { lat: latLng.lat(), lng: latLng.lng() }; + } + onDragStart?.(createEvent(e, coordinate)); }, - }), - [hasCallout, closeCallouts, closeCallout] - ); - - const calloutContent = callout - ? isValidElement(callout) - ? callout - : React.createElement(callout) - : title - ? React.createElement( - 'div', - { style: { fontSize: 14 } }, - React.createElement('div', { style: { fontWeight: 500 } }, title), - description ? React.createElement('div', null, description) : null - ) - : null; - - const transforms: string[] = []; - if (rotate) transforms.push(`rotate(${rotate}deg)`); - if (scale && scale !== 1) transforms.push(`scale(${scale})`); - - const handleClick = useCallback( - (e: google.maps.MapMouseEvent) => { - const pos = dragPositionRef.current; - const coord = pos - ? { latitude: pos.lat, longitude: pos.lng } - : coordinate; - if (centerOnPress) moveCamera(coord); - onPress?.(createEvent(e, coordinate)); - if (hasCallout) { - closeCallouts(closeCallout); - setInfoWindowOpen((prev) => !prev); - } - }, - [ - centerOnPress, - moveCamera, - onPress, - coordinate, - hasCallout, - closeCallouts, - closeCallout, - ] - ); - - const handleDragStart = useCallback( - (e: google.maps.MapMouseEvent) => { - const latLng = e.latLng; - if (latLng) { - dragPositionRef.current = { lat: latLng.lat(), lng: latLng.lng() }; - } - onDragStart?.(createEvent(e, coordinate)); - }, - [onDragStart, coordinate] - ); - - const handleDrag = useCallback( - (e: google.maps.MapMouseEvent) => { - const latLng = e.latLng; - if (latLng) { - dragPositionRef.current = { lat: latLng.lat(), lng: latLng.lng() }; - } - onDragChange?.(createEvent(e, coordinate)); - }, - [onDragChange, coordinate] - ); - - const handleDragEnd = useCallback( - (e: google.maps.MapMouseEvent) => { - const latLng = e.latLng; - if (latLng) { - dragPositionRef.current = { lat: latLng.lat(), lng: latLng.lng() }; - } - onDragEnd?.(createEvent(e, coordinate)); - }, - [onDragEnd, coordinate] - ); - - useEffect(() => { - dragPositionRef.current = null; - }, [coordinate.latitude, coordinate.longitude]); - - const latLngPosition = { - lat: coordinate.latitude, - lng: coordinate.longitude, - }; - - const position = dragPositionRef.current ?? latLngPosition; - - return ( - <> - 0 - ? { transform: transforms.join(' ') } - : undefined + [onDragStart, coordinate] + ); + + const handleDrag = useCallback( + (e: google.maps.MapMouseEvent) => { + const latLng = e.latLng; + if (latLng) { + dragPositionRef.current = { lat: latLng.lat(), lng: latLng.lng() }; } - > - {children} - - {calloutContent && infoWindowOpen && markerElement && ( - { + const latLng = e.latLng; + if (latLng) { + dragPositionRef.current = { lat: latLng.lat(), lng: latLng.lng() }; + } + onDragEnd?.(createEvent(e, coordinate)); + }, + [onDragEnd, coordinate] + ); + + useEffect(() => { + dragPositionRef.current = null; + }, [coordinate.latitude, coordinate.longitude]); + + const latLngPosition = { + lat: coordinate.latitude, + lng: coordinate.longitude, + }; + + const position = dragPositionRef.current ?? latLngPosition; + + return ( + <> + 0 + ? { transform: transforms.join(' ') } : undefined } - headerDisabled - onClose={() => setInfoWindowOpen(false)} > - {!calloutBubbled ? ( -
{calloutContent}
- ) : ( - calloutContent - )} -
- )} - - ); - } -)); + {children} + + {calloutContent && infoWindowOpen && markerElement && ( + setInfoWindowOpen(false)} + > + {!calloutBubbled ? ( +
{calloutContent}
+ ) : ( + calloutContent + )} +
+ )} + + ); + } + ) +); diff --git a/src/components/Polygon.web.tsx b/src/components/Polygon.web.tsx index 7b8e75b..e065abc 100644 --- a/src/components/Polygon.web.tsx +++ b/src/components/Polygon.web.tsx @@ -2,103 +2,107 @@ import { memo, useCallback, useEffect, useRef } from 'react'; import { useMapContext } from '../MapProvider.web'; import type { PolygonProps } from './Polygon.types'; -export const Polygon = memo(({ - coordinates, - holes, - strokeColor = '#000000', - strokeWidth = 1, - fillColor = 'rgba(0, 0, 0, 0.3)', - zIndex = 0, - onPress, -}: PolygonProps) => { - const { map } = useMapContext(); - const polygonRef = useRef(null); - const listenersRef = useRef([]); +export const Polygon = memo( + ({ + coordinates, + holes, + strokeColor = '#000000', + strokeWidth = 1, + fillColor = 'rgba(0, 0, 0, 0.3)', + zIndex = 0, + onPress, + }: PolygonProps) => { + const { map } = useMapContext(); + const polygonRef = useRef(null); + const listenersRef = useRef([]); - const handleClick = useCallback(() => { - onPress?.(); - }, [onPress]); + const handleClick = useCallback(() => { + onPress?.(); + }, [onPress]); - // Cleanup on unmount - useEffect(() => { - return () => { - listenersRef.current.forEach((l) => l.remove()); - listenersRef.current = []; - polygonRef.current?.setMap(null); - polygonRef.current = null; - }; - }, []); + // Cleanup on unmount + useEffect(() => { + return () => { + listenersRef.current.forEach((l) => l.remove()); + listenersRef.current = []; + polygonRef.current?.setMap(null); + polygonRef.current = null; + }; + }, []); - // Sync listeners - useEffect(() => { - const polygon = polygonRef.current; - if (!polygon) return; + // Sync listeners + useEffect(() => { + const polygon = polygonRef.current; + if (!polygon) return; - listenersRef.current.forEach((l) => l.remove()); - listenersRef.current = []; + listenersRef.current.forEach((l) => l.remove()); + listenersRef.current = []; - if (onPress) { - listenersRef.current.push(polygon.addListener('click', handleClick)); - } - polygon.set('clickable', !!onPress); - }, [onPress, handleClick]); + if (onPress) { + listenersRef.current.push(polygon.addListener('click', handleClick)); + } + polygon.set('clickable', !!onPress); + }, [onPress, handleClick]); - // Sync polygon with props - useEffect(() => { - if (!map || coordinates.length === 0) { - polygonRef.current?.setMap(null); - return; - } + // Sync polygon with props + useEffect(() => { + if (!map || coordinates.length === 0) { + polygonRef.current?.setMap(null); + return; + } - const outerPath = coordinates.map((c) => ({ - lat: c.latitude, - lng: c.longitude, - })); + const outerPath = coordinates.map((c) => ({ + lat: c.latitude, + lng: c.longitude, + })); - const paths = [ - outerPath, - ...(holes ?? []).map((hole) => - [...hole].reverse().map((c) => ({ lat: c.latitude, lng: c.longitude })) - ), - ]; + const paths = [ + outerPath, + ...(holes ?? []).map((hole) => + [...hole] + .reverse() + .map((c) => ({ lat: c.latitude, lng: c.longitude })) + ), + ]; - if (polygonRef.current) { - polygonRef.current.setPaths(paths); - polygonRef.current.setOptions({ - strokeColor: strokeColor as string, - strokeWeight: strokeWidth, - fillColor: fillColor as string, - zIndex, - }); - } else { - const polygon = new google.maps.Polygon({ - paths, - strokeColor: strokeColor as string, - strokeWeight: strokeWidth, - strokeOpacity: 1, - fillColor: fillColor as string, - fillOpacity: 1, - zIndex, - clickable: !!onPress, - map, - }); - polygonRef.current = polygon; + if (polygonRef.current) { + polygonRef.current.setPaths(paths); + polygonRef.current.setOptions({ + strokeColor: strokeColor as string, + strokeWeight: strokeWidth, + fillColor: fillColor as string, + zIndex, + }); + } else { + const polygon = new google.maps.Polygon({ + paths, + strokeColor: strokeColor as string, + strokeWeight: strokeWidth, + strokeOpacity: 1, + fillColor: fillColor as string, + fillOpacity: 1, + zIndex, + clickable: !!onPress, + map, + }); + polygonRef.current = polygon; - if (onPress) { - listenersRef.current.push(polygon.addListener('click', handleClick)); + if (onPress) { + listenersRef.current.push(polygon.addListener('click', handleClick)); + } } - } - }, [ - map, - coordinates, - holes, - strokeColor, - strokeWidth, - fillColor, - zIndex, - onPress, - handleClick, - ]); + }, [ + map, + coordinates, + holes, + strokeColor, + strokeWidth, + fillColor, + zIndex, + onPress, + handleClick, + ]); - return null; -}); + return null; + } +); diff --git a/src/components/Polyline.web.tsx b/src/components/Polyline.web.tsx index 59935d6..f88610a 100644 --- a/src/components/Polyline.web.tsx +++ b/src/components/Polyline.web.tsx @@ -51,270 +51,275 @@ const getGradientColor = (colors: string[], position: number): string => { return interpolateColor(colors[index]!, colors[index + 1]!, t); }; -export const Polyline = memo(({ - coordinates, - strokeColors, - strokeWidth = 1, - animated, - animatedOptions, - zIndex, -}: PolylineProps) => { - const resolvedZIndex = zIndex ?? (animated ? 1 : 0); - const { map, isDragging } = useMapContext(); - - const { duration, easing, trailLength, delay } = useMemo( - () => ({ - duration: animatedOptions?.duration ?? DEFAULT_DURATION, - easing: animatedOptions?.easing ?? 'linear', - trailLength: Math.max( - 0.01, - Math.min(1, animatedOptions?.trailLength ?? 1) - ), - delay: animatedOptions?.delay ?? 0, - }), - [ - animatedOptions?.duration, - animatedOptions?.easing, - animatedOptions?.trailLength, - animatedOptions?.delay, - ] - ); - const polylinesRef = useRef([]); - const animationRef = useRef(0); - const isPausedRef = useRef(false); - - const colors = useMemo( - () => - strokeColors && strokeColors.length > 0 - ? (strokeColors as string[]) - : ['#000000'], - [strokeColors] - ); - - const hasGradient = colors.length > 1; - - // Refs for animation loop access - const propsRef = useRef({ - map, - colors, - strokeWidth, - hasGradient, - zIndex: resolvedZIndex, - }); - const [mapReady, setMapReady] = useState(!!map); - - useEffect(() => { - propsRef.current = { +export const Polyline = memo( + ({ + coordinates, + strokeColors, + strokeWidth = 1, + animated, + animatedOptions, + zIndex, + }: PolylineProps) => { + const resolvedZIndex = zIndex ?? (animated ? 1 : 0); + const { map, isDragging } = useMapContext(); + + const { duration, easing, trailLength, delay } = useMemo( + () => ({ + duration: animatedOptions?.duration ?? DEFAULT_DURATION, + easing: animatedOptions?.easing ?? 'linear', + trailLength: Math.max( + 0.01, + Math.min(1, animatedOptions?.trailLength ?? 1) + ), + delay: animatedOptions?.delay ?? 0, + }), + [ + animatedOptions?.duration, + animatedOptions?.easing, + animatedOptions?.trailLength, + animatedOptions?.delay, + ] + ); + const polylinesRef = useRef([]); + const animationRef = useRef(0); + const isPausedRef = useRef(false); + + const colors = useMemo( + () => + strokeColors && strokeColors.length > 0 + ? (strokeColors as string[]) + : ['#000000'], + [strokeColors] + ); + + const hasGradient = colors.length > 1; + + // Refs for animation loop access + const propsRef = useRef({ map, colors, strokeWidth, hasGradient, zIndex: resolvedZIndex, - }; - if (map && !mapReady) setMapReady(true); - }, [map, colors, strokeWidth, hasGradient, resolvedZIndex, mapReady]); - - const updatePath = useCallback((path: google.maps.LatLngLiteral[]) => { - const { - map: currentMap, - colors: currentColors, - strokeWidth: currentStrokeWidth, - hasGradient: currentHasGradient, - zIndex: currentZIndex, - } = propsRef.current; - const existing = polylinesRef.current; - if (!currentMap) return; - - if (path.length < 2) { - existing.forEach((p) => p.setMap(null)); - existing.length = 0; - return; - } - - const neededSegments = currentHasGradient ? path.length - 1 : 1; - - // Update or create segments - for (let i = 0; i < neededSegments; i++) { - const segmentPath = currentHasGradient ? [path[i]!, path[i + 1]!] : path; - const color = currentHasGradient - ? getGradientColor(currentColors, i / (path.length - 1)) - : currentColors[0]!; - - const segment = existing[i]; - if (segment) { - segment.setPath(segmentPath); - segment.setOptions({ strokeColor: color }); - } else { - existing.push( - new google.maps.Polyline({ - path: segmentPath, - strokeColor: color, - strokeWeight: currentStrokeWidth, - strokeOpacity: 1, - zIndex: currentZIndex, - map: currentMap, - }) - ); + }); + const [mapReady, setMapReady] = useState(!!map); + + useEffect(() => { + propsRef.current = { + map, + colors, + strokeWidth, + hasGradient, + zIndex: resolvedZIndex, + }; + if (map && !mapReady) setMapReady(true); + }, [map, colors, strokeWidth, hasGradient, resolvedZIndex, mapReady]); + + const updatePath = useCallback((path: google.maps.LatLngLiteral[]) => { + const { + map: currentMap, + colors: currentColors, + strokeWidth: currentStrokeWidth, + hasGradient: currentHasGradient, + zIndex: currentZIndex, + } = propsRef.current; + const existing = polylinesRef.current; + if (!currentMap) return; + + if (path.length < 2) { + existing.forEach((p) => p.setMap(null)); + existing.length = 0; + return; } - } - - // Remove extra segments - for (let i = neededSegments; i < existing.length; i++) { - existing[i]?.setMap(null); - } - existing.length = neededSegments; - }, []); - - // Cleanup on unmount - useEffect(() => { - const polylines = polylinesRef.current; - return () => { - cancelAnimationFrame(animationRef.current); - polylines.forEach((p) => p.setMap(null)); - }; - }, []); - - // Pause/resume animation during drag - useEffect(() => { - isPausedRef.current = isDragging; - }, [isDragging]); - // Main effect - useEffect(() => { - if (!propsRef.current.map || coordinates.length === 0) return; + const neededSegments = currentHasGradient ? path.length - 1 : 1; + + // Update or create segments + for (let i = 0; i < neededSegments; i++) { + const segmentPath = currentHasGradient + ? [path[i]!, path[i + 1]!] + : path; + const color = currentHasGradient + ? getGradientColor(currentColors, i / (path.length - 1)) + : currentColors[0]!; + + const segment = existing[i]; + if (segment) { + segment.setPath(segmentPath); + segment.setOptions({ strokeColor: color }); + } else { + existing.push( + new google.maps.Polyline({ + path: segmentPath, + strokeColor: color, + strokeWeight: currentStrokeWidth, + strokeOpacity: 1, + zIndex: currentZIndex, + map: currentMap, + }) + ); + } + } - const fullPath = coordinates.map((c) => ({ - lat: c.latitude, - lng: c.longitude, - })); + // Remove extra segments + for (let i = neededSegments; i < existing.length; i++) { + existing[i]?.setMap(null); + } + existing.length = neededSegments; + }, []); + + // Cleanup on unmount + useEffect(() => { + const polylines = polylinesRef.current; + return () => { + cancelAnimationFrame(animationRef.current); + polylines.forEach((p) => p.setMap(null)); + }; + }, []); + + // Pause/resume animation during drag + useEffect(() => { + isPausedRef.current = isDragging; + }, [isDragging]); + + // Main effect + useEffect(() => { + if (!propsRef.current.map || coordinates.length === 0) return; + + const fullPath = coordinates.map((c) => ({ + lat: c.latitude, + lng: c.longitude, + })); - cancelAnimationFrame(animationRef.current); + cancelAnimationFrame(animationRef.current); - if (!animated) { - updatePath(fullPath); - return; - } + if (!animated) { + updatePath(fullPath); + return; + } - const totalPoints = fullPath.length; - const useTrailMode = trailLength < 1; - const renderMax = useTrailMode ? 1 : 2; - const cycleMax = useTrailMode ? 1 : 2.15; + const totalPoints = fullPath.length; + const useTrailMode = trailLength < 1; + const renderMax = useTrailMode ? 1 : 2; + const cycleMax = useTrailMode ? 1 : 2.15; - let startTime: number | null = null; - let delayRemaining = delay; + let startTime: number | null = null; + let delayRemaining = delay; - let pausedAt: number | null = null; + let pausedAt: number | null = null; - const animate = (time: number) => { - if (isPausedRef.current) { - if (pausedAt === null) pausedAt = time; - animationRef.current = requestAnimationFrame(animate); - return; - } + const animate = (time: number) => { + if (isPausedRef.current) { + if (pausedAt === null) pausedAt = time; + animationRef.current = requestAnimationFrame(animate); + return; + } - if (pausedAt !== null) { - if (startTime !== null) { - startTime += time - pausedAt; + if (pausedAt !== null) { + if (startTime !== null) { + startTime += time - pausedAt; + } + pausedAt = null; } - pausedAt = null; - } - if (startTime === null) { - startTime = time; - } + if (startTime === null) { + startTime = time; + } - const elapsed = time - startTime; + const elapsed = time - startTime; - if (delayRemaining > 0) { - if (elapsed < delayRemaining) { - animationRef.current = requestAnimationFrame(animate); - return; + if (delayRemaining > 0) { + if (elapsed < delayRemaining) { + animationRef.current = requestAnimationFrame(animate); + return; + } + startTime = time - (elapsed - delayRemaining); + delayRemaining = 0; } - startTime = time - (elapsed - delayRemaining); - delayRemaining = 0; - } - const rawProgress = (time - startTime) / duration; + const rawProgress = (time - startTime) / duration; - if (rawProgress >= cycleMax) { - delayRemaining = delay; - startTime = time; - } + if (rawProgress >= cycleMax) { + delayRemaining = delay; + startTime = time; + } - const clampedProgress = Math.min(rawProgress, renderMax); - const easedProgress = - applyEasing(clampedProgress / renderMax, easing) * renderMax; + const clampedProgress = Math.min(rawProgress, renderMax); + const easedProgress = + applyEasing(clampedProgress / renderMax, easing) * renderMax; - let startIdx: number; - let endIdx: number; + let startIdx: number; + let endIdx: number; - if (useTrailMode) { - endIdx = easedProgress * totalPoints; - startIdx = Math.max(0, endIdx - trailLength * totalPoints); - } else { - startIdx = easedProgress <= 1 ? 0 : (easedProgress - 1) * totalPoints; - endIdx = easedProgress <= 1 ? easedProgress * totalPoints : totalPoints; - } + if (useTrailMode) { + endIdx = easedProgress * totalPoints; + startIdx = Math.max(0, endIdx - trailLength * totalPoints); + } else { + startIdx = easedProgress <= 1 ? 0 : (easedProgress - 1) * totalPoints; + endIdx = + easedProgress <= 1 ? easedProgress * totalPoints : totalPoints; + } - const partialPath: google.maps.LatLngLiteral[] = []; - const startFloor = Math.floor(startIdx); - const endFloor = Math.floor(endIdx); - - // Start point (interpolated) - if (startFloor < totalPoints) { - const frac = startIdx - startFloor; - const from = fullPath[startFloor]!; - const to = fullPath[Math.min(startFloor + 1, totalPoints - 1)]!; - partialPath.push( - frac > 0 - ? { - lat: from.lat + (to.lat - from.lat) * frac, - lng: from.lng + (to.lng - from.lng) * frac, - } - : from - ); - } + const partialPath: google.maps.LatLngLiteral[] = []; + const startFloor = Math.floor(startIdx); + const endFloor = Math.floor(endIdx); + + // Start point (interpolated) + if (startFloor < totalPoints) { + const frac = startIdx - startFloor; + const from = fullPath[startFloor]!; + const to = fullPath[Math.min(startFloor + 1, totalPoints - 1)]!; + partialPath.push( + frac > 0 + ? { + lat: from.lat + (to.lat - from.lat) * frac, + lng: from.lng + (to.lng - from.lng) * frac, + } + : from + ); + } - // Middle points - for ( - let i = startFloor + 1; - i <= Math.min(endFloor, totalPoints - 1); - i++ - ) { - partialPath.push(fullPath[i]!); - } + // Middle points + for ( + let i = startFloor + 1; + i <= Math.min(endFloor, totalPoints - 1); + i++ + ) { + partialPath.push(fullPath[i]!); + } - // End point (interpolated) - if (endFloor < totalPoints - 1) { - const frac = endIdx - endFloor; - const from = fullPath[endFloor]!; - const to = fullPath[endFloor + 1]!; - if (frac > 0) { - partialPath.push({ - lat: from.lat + (to.lat - from.lat) * frac, - lng: from.lng + (to.lng - from.lng) * frac, - }); + // End point (interpolated) + if (endFloor < totalPoints - 1) { + const frac = endIdx - endFloor; + const from = fullPath[endFloor]!; + const to = fullPath[endFloor + 1]!; + if (frac > 0) { + partialPath.push({ + lat: from.lat + (to.lat - from.lat) * frac, + lng: from.lng + (to.lng - from.lng) * frac, + }); + } } - } - updatePath(partialPath); + updatePath(partialPath); + animationRef.current = requestAnimationFrame(animate); + }; + animationRef.current = requestAnimationFrame(animate); - }; - animationRef.current = requestAnimationFrame(animate); + return () => cancelAnimationFrame(animationRef.current); + }, [ + coordinates, + animated, + duration, + easing, + trailLength, + delay, + hasGradient, + updatePath, + mapReady, + ]); - return () => cancelAnimationFrame(animationRef.current); - }, [ - coordinates, - animated, - duration, - easing, - trailLength, - delay, - hasGradient, - updatePath, - mapReady, - ]); - - return null; -}); + return null; + } +); diff --git a/src/components/TileOverlay.web.tsx b/src/components/TileOverlay.web.tsx index a8b90ad..5015664 100644 --- a/src/components/TileOverlay.web.tsx +++ b/src/components/TileOverlay.web.tsx @@ -2,94 +2,96 @@ import { memo, useCallback, useEffect, useRef } from 'react'; import { useMapContext } from '../MapProvider.web'; import type { TileOverlayProps } from './TileOverlay.types'; -export const TileOverlay = memo(({ - urlTemplate, - tileSize = 256, - opacity = 1, - bounds, - zIndex = 0, - onPress, -}: TileOverlayProps) => { - const { map } = useMapContext(); - const overlayRef = useRef(null); - const indexRef = useRef(-1); +export const TileOverlay = memo( + ({ + urlTemplate, + tileSize = 256, + opacity = 1, + bounds, + zIndex = 0, + onPress, + }: TileOverlayProps) => { + const { map } = useMapContext(); + const overlayRef = useRef(null); + const indexRef = useRef(-1); - const handleClick = useCallback(() => { - onPress?.(); - }, [onPress]); + const handleClick = useCallback(() => { + onPress?.(); + }, [onPress]); - // Cleanup on unmount - useEffect(() => { - return () => { - if (overlayRef.current && indexRef.current >= 0) { - map?.overlayMapTypes.removeAt(indexRef.current); - } - overlayRef.current = null; - indexRef.current = -1; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + // Cleanup on unmount + useEffect(() => { + return () => { + if (overlayRef.current && indexRef.current >= 0) { + map?.overlayMapTypes.removeAt(indexRef.current); + } + overlayRef.current = null; + indexRef.current = -1; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - // Sync overlay with props - useEffect(() => { - if (!map) return; + // Sync overlay with props + useEffect(() => { + if (!map) return; - // Remove old overlay - if (overlayRef.current && indexRef.current >= 0) { - map.overlayMapTypes.removeAt(indexRef.current); - overlayRef.current = null; - indexRef.current = -1; - } + // Remove old overlay + if (overlayRef.current && indexRef.current >= 0) { + map.overlayMapTypes.removeAt(indexRef.current); + overlayRef.current = null; + indexRef.current = -1; + } - if (!urlTemplate) return; + if (!urlTemplate) return; - const imageMapType = new google.maps.ImageMapType({ - getTileUrl: (coord, zoom) => { - if (bounds) { - const n = Math.pow(2, zoom); - const tileSWLat = - (Math.atan(Math.sinh(Math.PI * (1 - (2 * (coord.y + 1)) / n))) * - 180) / - Math.PI; - const tileNELat = - (Math.atan(Math.sinh(Math.PI * (1 - (2 * coord.y) / n))) * 180) / - Math.PI; - const tileSWLng = (coord.x / n) * 360 - 180; - const tileNELng = ((coord.x + 1) / n) * 360 - 180; + const imageMapType = new google.maps.ImageMapType({ + getTileUrl: (coord, zoom) => { + if (bounds) { + const n = Math.pow(2, zoom); + const tileSWLat = + (Math.atan(Math.sinh(Math.PI * (1 - (2 * (coord.y + 1)) / n))) * + 180) / + Math.PI; + const tileNELat = + (Math.atan(Math.sinh(Math.PI * (1 - (2 * coord.y) / n))) * 180) / + Math.PI; + const tileSWLng = (coord.x / n) * 360 - 180; + const tileNELng = ((coord.x + 1) / n) * 360 - 180; - if ( - tileNELat < bounds.southwest.latitude || - tileSWLat > bounds.northeast.latitude || - tileNELng < bounds.southwest.longitude || - tileSWLng > bounds.northeast.longitude - ) { - return null; + if ( + tileNELat < bounds.southwest.latitude || + tileSWLat > bounds.northeast.latitude || + tileNELng < bounds.southwest.longitude || + tileSWLng > bounds.northeast.longitude + ) { + return null; + } } - } - return urlTemplate - .replace('{x}', String(coord.x)) - .replace('{y}', String(coord.y)) - .replace('{z}', String(zoom)); - }, - tileSize: new google.maps.Size(tileSize, tileSize), - opacity, - }); + return urlTemplate + .replace('{x}', String(coord.x)) + .replace('{y}', String(coord.y)) + .replace('{z}', String(zoom)); + }, + tileSize: new google.maps.Size(tileSize, tileSize), + opacity, + }); - const length = map.overlayMapTypes.getLength(); - map.overlayMapTypes.insertAt(length, imageMapType); - overlayRef.current = imageMapType; - indexRef.current = length; - }, [ - map, - urlTemplate, - tileSize, - opacity, - bounds, - zIndex, - onPress, - handleClick, - ]); + const length = map.overlayMapTypes.getLength(); + map.overlayMapTypes.insertAt(length, imageMapType); + overlayRef.current = imageMapType; + indexRef.current = length; + }, [ + map, + urlTemplate, + tileSize, + opacity, + bounds, + zIndex, + onPress, + handleClick, + ]); - return null; -}); + return null; + } +); From 7931677a21a184bc8a731a4cecf2a886879ef2e3 Mon Sep 17 00:00:00 2001 From: lodev09 Date: Thu, 26 Mar 2026 19:37:58 +0800 Subject: [PATCH 4/4] fix(web): replace isDragging state with ref to prevent camera reset during drag State updates from setIsDragging caused MapView re-renders mid-drag, resetting the camera position. Using a ref avoids re-renders while still pausing Polyline animation during drag. --- src/MapProvider.web.tsx | 8 +++++--- src/MapView.web.tsx | 10 +++------- src/components/Polyline.web.tsx | 11 +++-------- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/MapProvider.web.tsx b/src/MapProvider.web.tsx index 1e97107..ec917ef 100644 --- a/src/MapProvider.web.tsx +++ b/src/MapProvider.web.tsx @@ -1,18 +1,20 @@ -import { createContext, useContext } from 'react'; +import { createContext, useContext, type MutableRefObject } from 'react'; import { APIProvider } from '@vis.gl/react-google-maps'; import type { MapProviderProps } from './MapProvider.types'; type CalloutCloseListener = () => void; +const defaultDraggingRef = { current: false }; + export const MapContext = createContext<{ map: google.maps.Map | null; - isDragging: boolean; + isDraggingRef: MutableRefObject; moveCamera: (coordinate: { latitude: number; longitude: number }) => void; onCalloutClose: (listener: CalloutCloseListener) => () => void; closeCallouts: (except?: CalloutCloseListener) => void; }>({ map: null, - isDragging: false, + isDraggingRef: defaultDraggingRef, moveCamera: () => {}, onCalloutClose: () => () => {}, closeCallouts: () => {}, diff --git a/src/MapView.web.tsx b/src/MapView.web.tsx index 4575a24..2182586 100644 --- a/src/MapView.web.tsx +++ b/src/MapView.web.tsx @@ -126,7 +126,7 @@ export const MapView = memo( const map = useMap(id); const containerRef = useRef(null); const readyFired = useRef(false); - const [isDragging, setIsDragging] = useState(false); + const isDraggingRef = useRef(false); const wasGesture = useRef(false); const prevEdgeInsets = useRef(edgeInsets); @@ -378,18 +378,14 @@ export const MapView = memo( }; }, [map, onLongPress, handleMouseDown, handleMouseUp]); - const isDraggingRef = useRef(false); - const handleDragStart = useCallback(() => { handleMouseUp(); isDraggingRef.current = true; - setIsDragging(true); wasGesture.current = true; }, [handleMouseUp]); const handleDragEnd = useCallback(() => { isDraggingRef.current = false; - setIsDragging(false); }, []); const handleCameraChanged = useCallback( @@ -472,12 +468,12 @@ export const MapView = memo( value={useMemo( () => ({ map, - isDragging, + isDraggingRef, moveCamera: panToCoordinate, onCalloutClose, closeCallouts, }), - [map, isDragging, panToCoordinate, onCalloutClose, closeCallouts] + [map, panToCoordinate, onCalloutClose, closeCallouts] )} > diff --git a/src/components/Polyline.web.tsx b/src/components/Polyline.web.tsx index f88610a..6d74684 100644 --- a/src/components/Polyline.web.tsx +++ b/src/components/Polyline.web.tsx @@ -61,7 +61,7 @@ export const Polyline = memo( zIndex, }: PolylineProps) => { const resolvedZIndex = zIndex ?? (animated ? 1 : 0); - const { map, isDragging } = useMapContext(); + const { map, isDraggingRef } = useMapContext(); const { duration, easing, trailLength, delay } = useMemo( () => ({ @@ -82,7 +82,6 @@ export const Polyline = memo( ); const polylinesRef = useRef([]); const animationRef = useRef(0); - const isPausedRef = useRef(false); const colors = useMemo( () => @@ -177,11 +176,6 @@ export const Polyline = memo( }; }, []); - // Pause/resume animation during drag - useEffect(() => { - isPausedRef.current = isDragging; - }, [isDragging]); - // Main effect useEffect(() => { if (!propsRef.current.map || coordinates.length === 0) return; @@ -209,7 +203,7 @@ export const Polyline = memo( let pausedAt: number | null = null; const animate = (time: number) => { - if (isPausedRef.current) { + if (isDraggingRef.current) { if (pausedAt === null) pausedAt = time; animationRef.current = requestAnimationFrame(animate); return; @@ -318,6 +312,7 @@ export const Polyline = memo( hasGradient, updatePath, mapReady, + isDraggingRef, ]); return null;