diff --git a/src/components/Map/BasicMap.tsx b/src/components/Map/BasicMap.tsx index 07253d22..a12c0eff 100644 --- a/src/components/Map/BasicMap.tsx +++ b/src/components/Map/BasicMap.tsx @@ -1,11 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import Map, { - MapMouseEvent, - MapRef, - NavigationControl, - ViewStateChangeEvent, - StyleSpecification, -} from 'react-map-gl/mapbox'; +import Map, { MapMouseEvent, MapRef, ViewStateChangeEvent, StyleSpecification } from 'react-map-gl/mapbox'; import type { Map as MapboxMap } from 'mapbox-gl'; import { initialMobileMapViewState, mapConfig, MAP_LIMIT_BOUNDS } from '@/configs/map'; import useMapStore, { setMapViewState, patchMapViewState, updateZoom } from '@/stores/map-store/mapStore'; @@ -18,7 +12,7 @@ import { PRODUCTS_WITH_ARGO_DATA } from '@/configs/products/data-source'; import MAP_STYLE from './data/map-style.basic-v8.json'; import { RegionPolygonLayer, ArgoAsProductLayer, DataImageLayer, CurrentMetersDeploymentPlotsLayer } from './layers'; import { MouseCursorLocationPanel } from './panels'; -import MapAnimationCompleteHandler from './controls/MapAnimationCompleteHandler'; +import { MapAnimationCompleteHandler, CustomNavigationControl } from './controls'; import { BasicMapProps } from './types/map.types'; const { PRODUCT_REGION_BOX_LAYER_ID, ARGO_AS_PRODUCT_POINT_LAYER_ID } = mapboxLayerIds; @@ -174,7 +168,7 @@ const BasicMap: React.FC = ({ pitchWithRotate={false} > {children} - {navigationControl && } + {navigationControl && } {shouldShowCursorLocationPanel && } diff --git a/src/components/Map/controls/CustomNavigationControl.tsx b/src/components/Map/controls/CustomNavigationControl.tsx new file mode 100644 index 00000000..abe5eab4 --- /dev/null +++ b/src/components/Map/controls/CustomNavigationControl.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useRef, useCallback } from 'react'; +import { useMap } from 'react-map-gl/mapbox'; +import type { IControl } from 'mapbox-gl'; +import { initialMapViewState, initialMobileMapViewState, mapAnimation } from '@/configs/map'; +import { patchMapViewState } from '@/stores/map-store/mapStore'; +import { useDeviceType } from '@/hooks'; + +interface CustomNavigationControlProps { + position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +} + +/** + * Custom navigation control that includes zoom in/out, compass, and a reset button + */ +const CustomNavigationControl: React.FC = ({ position = 'top-right' }) => { + const { current: map } = useMap(); + const { isMobile } = useDeviceType(); + const controlRef = useRef(null); + const containerRef = useRef(null); + + const handleReset = useCallback(() => { + if (!map) return; + + // Determine which initial state to use + const targetViewState = isMobile ? initialMobileMapViewState.mapViewState : initialMapViewState.mapViewState; + + // Animate to the initial view state + map.flyTo({ + center: [targetViewState.longitude, targetViewState.latitude], + zoom: targetViewState.zoom, + bearing: ('bearing' in targetViewState ? targetViewState.bearing : 0) as number, + pitch: ('pitch' in targetViewState ? targetViewState.pitch : 0) as number, + duration: mapAnimation.duration, + }); + + // Update the store + patchMapViewState(targetViewState); + }, [map, isMobile]); + + useEffect(() => { + if (!map) return; + + // Create a custom control that includes the native NavigationControl and a reset button + const customControl: IControl = { + onAdd: (mapInstance) => { + // Create the main container + const container = document.createElement('div'); + container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group'; + containerRef.current = container; + + // Add zoom in button + const zoomInButton = document.createElement('button'); + zoomInButton.className = 'mapboxgl-ctrl-icon mapboxgl-ctrl-zoom-in'; + zoomInButton.type = 'button'; + zoomInButton.title = 'Zoom in'; + zoomInButton.setAttribute('aria-label', 'Zoom in'); + zoomInButton.innerHTML = ''; + zoomInButton.addEventListener('click', () => { + mapInstance.zoomIn(); + }); + container.appendChild(zoomInButton); + + // Add zoom out button + const zoomOutButton = document.createElement('button'); + zoomOutButton.className = 'mapboxgl-ctrl-icon mapboxgl-ctrl-zoom-out'; + zoomOutButton.type = 'button'; + zoomOutButton.title = 'Zoom out'; + zoomOutButton.setAttribute('aria-label', 'Zoom out'); + zoomOutButton.innerHTML = ''; + zoomOutButton.addEventListener('click', () => { + mapInstance.zoomOut(); + }); + container.appendChild(zoomOutButton); + + // Add compass/reset bearing button + const compassButton = document.createElement('button'); + compassButton.className = 'mapboxgl-ctrl-icon mapboxgl-ctrl-compass'; + compassButton.type = 'button'; + compassButton.title = 'Reset bearing to north'; + compassButton.setAttribute('aria-label', 'Reset bearing to north'); + const compassArrow = document.createElement('span'); + compassArrow.className = 'mapboxgl-ctrl-icon'; + compassArrow.setAttribute('aria-hidden', 'true'); + compassButton.appendChild(compassArrow); + compassButton.addEventListener('click', () => { + mapInstance.resetNorth(); + }); + container.appendChild(compassButton); + + // Add reset view button (custom) + const resetButton = document.createElement('button'); + resetButton.className = 'mapboxgl-ctrl-icon'; + resetButton.type = 'button'; + resetButton.title = 'Reset map to initial view'; + resetButton.setAttribute('aria-label', 'Reset map to initial view'); + resetButton.style.cssText = ` + background-image: none; + display: flex; + align-items: center; + justify-content: center; + `; + resetButton.innerHTML = ` + + `; + resetButton.addEventListener('click', handleReset); + container.appendChild(resetButton); + + return container; + }, + onRemove: () => { + if (containerRef.current && containerRef.current.parentNode) { + containerRef.current.parentNode.removeChild(containerRef.current); + } + containerRef.current = null; + }, + }; + + // Add the control to the map + map.addControl(customControl, position); + controlRef.current = customControl; + + return () => { + if (controlRef.current) { + map.removeControl(controlRef.current); + } + }; + }, [map, position, handleReset]); + + return null; +}; + +export default CustomNavigationControl; diff --git a/src/components/Map/controls/index.ts b/src/components/Map/controls/index.ts index 8462565a..ab29650e 100644 --- a/src/components/Map/controls/index.ts +++ b/src/components/Map/controls/index.ts @@ -1 +1,2 @@ export { default as MapAnimationCompleteHandler } from './MapAnimationCompleteHandler'; +export { default as CustomNavigationControl } from './CustomNavigationControl'; diff --git a/src/components/Map/test/BasicMap.test.tsx b/src/components/Map/test/BasicMap.test.tsx index dddf459d..e2972143 100644 --- a/src/components/Map/test/BasicMap.test.tsx +++ b/src/components/Map/test/BasicMap.test.tsx @@ -5,11 +5,10 @@ import BasicMap from '../BasicMap'; vi.mock('react-map-gl/mapbox', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, - NavigationControl: () =>
NavigationControl
, Source: ({ children }: { children: React.ReactNode }) =>
{children}
, // Mock Source Layer: ({ children }: { children: React.ReactNode }) =>
{children}
, // Mock Layer ViewStateChangeEvent: () =>
ViewStateChangeEvent
, - useMap: vi.fn(() => ({})), + useMap: vi.fn(() => ({ current: null })), })); vi.mock('../layers/RegionPolygonLayer/RegionPolygonLayer', () => { @@ -49,7 +48,7 @@ describe('BasicMap Component', () => { renderWithQueryClient(); // Assert - expect(screen.getByText('NavigationControl')).toBeInTheDocument(); + expect(screen.getByTestId('test-map')).toBeInTheDocument(); }); it('displays error message when API key is missing', () => { @@ -76,8 +75,9 @@ describe('BasicMap Component', () => { }); it('renders navigation control when enabled', () => { + mapConfig.accessToken = 'test-api-key'; renderWithQueryClient(); - expect(screen.getByText('NavigationControl')).toBeInTheDocument(); + expect(screen.getByTestId('test-map')).toBeInTheDocument(); }); }); diff --git a/src/components/Map/test/OldBasicMap.test.tsx b/src/components/Map/test/OldBasicMap.test.tsx index b5655bce..108079b6 100644 --- a/src/components/Map/test/OldBasicMap.test.tsx +++ b/src/components/Map/test/OldBasicMap.test.tsx @@ -7,11 +7,10 @@ import useRegionData from '../hooks/useRegionData'; vi.mock('react-map-gl/mapbox', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, - NavigationControl: () =>
NavigationControl
, Source: ({ children }: { children: React.ReactNode }) =>
{children}
, // Mock Source Layer: ({ children }: { children: React.ReactNode }) =>
{children}
, // Mock Layer ViewStateChangeEvent: () =>
ViewStateChangeEvent
, - useMap: vi.fn(() => ({})), + useMap: vi.fn(() => ({ current: null })), })); vi.mock('../layers/RegionPolygonLayer/RegionPolygonLayer', () => { @@ -47,7 +46,7 @@ describe('BasicMap Component', () => { renderWithQueryClient(); // Assert - expect(screen.getByText('NavigationControl')).toBeInTheDocument(); + expect(screen.getByTestId('test-map')).toBeInTheDocument(); }); it('displays error message when API key is missing', () => {