Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions src/components/Map/BasicMap.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -174,7 +168,7 @@ const BasicMap: React.FC<BasicMapProps> = ({
pitchWithRotate={false}
>
{children}
{navigationControl && <NavigationControl position="top-right" />}
{navigationControl && <CustomNavigationControl position="top-right" />}

{shouldShowCursorLocationPanel && <MouseCursorLocationPanel lat={cursorLngLat?.lat} lng={cursorLngLat?.lng} />}

Expand Down
132 changes: 132 additions & 0 deletions src/components/Map/controls/CustomNavigationControl.tsx
Original file line number Diff line number Diff line change
@@ -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<CustomNavigationControlProps> = ({ position = 'top-right' }) => {
const { current: map } = useMap();
const { isMobile } = useDeviceType();
const controlRef = useRef<IControl | null>(null);
const containerRef = useRef<HTMLDivElement | null>(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 = '<span class="mapboxgl-ctrl-icon" aria-hidden="true"></span>';
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 = '<span class="mapboxgl-ctrl-icon" aria-hidden="true"></span>';
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 = `
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="20px" fill="currentColor"><path d="M204-318q-22-38-33-78t-11-82q0-134 93-228t227-94h7l-64-64 56-56 160 160-160 160-56-56 64-64h-7q-100 0-170 70.5T240-478q0 26 6 51t18 49l-60 60ZM481-40 321-200l160-160 56 56-64 64h7q100 0 170-70.5T720-482q0-26-6-51t-18-49l60-60q22 38 33 78t11 82q0 134-93 228t-227 94h-7l64 64-56 56Z"/></svg>
`;
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;
1 change: 1 addition & 0 deletions src/components/Map/controls/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default as MapAnimationCompleteHandler } from './MapAnimationCompleteHandler';
export { default as CustomNavigationControl } from './CustomNavigationControl';
8 changes: 4 additions & 4 deletions src/components/Map/test/BasicMap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@ import BasicMap from '../BasicMap';

vi.mock('react-map-gl/mapbox', () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="test-map">{children}</div>,
NavigationControl: () => <div>NavigationControl</div>,
Source: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, // Mock Source
Layer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, // Mock Layer
ViewStateChangeEvent: () => <div>ViewStateChangeEvent</div>,
useMap: vi.fn(() => ({})),
useMap: vi.fn(() => ({ current: null })),
}));

vi.mock('../layers/RegionPolygonLayer/RegionPolygonLayer', () => {
Expand Down Expand Up @@ -49,7 +48,7 @@ describe('BasicMap Component', () => {
renderWithQueryClient(<BasicMap />);

// Assert
expect(screen.getByText('NavigationControl')).toBeInTheDocument();
expect(screen.getByTestId('test-map')).toBeInTheDocument();
});

it('displays error message when API key is missing', () => {
Expand All @@ -76,8 +75,9 @@ describe('BasicMap Component', () => {
});

it('renders navigation control when enabled', () => {
mapConfig.accessToken = 'test-api-key';
renderWithQueryClient(<BasicMap navigationControl />);
expect(screen.getByText('NavigationControl')).toBeInTheDocument();
expect(screen.getByTestId('test-map')).toBeInTheDocument();
});
});

Expand Down
5 changes: 2 additions & 3 deletions src/components/Map/test/OldBasicMap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@ import useRegionData from '../hooks/useRegionData';

vi.mock('react-map-gl/mapbox', () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="test-map">{children}</div>,
NavigationControl: () => <div>NavigationControl</div>,
Source: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, // Mock Source
Layer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, // Mock Layer
ViewStateChangeEvent: () => <div>ViewStateChangeEvent</div>,
useMap: vi.fn(() => ({})),
useMap: vi.fn(() => ({ current: null })),
}));

vi.mock('../layers/RegionPolygonLayer/RegionPolygonLayer', () => {
Expand Down Expand Up @@ -47,7 +46,7 @@ describe('BasicMap Component', () => {
renderWithQueryClient(<BasicMap />);

// Assert
expect(screen.getByText('NavigationControl')).toBeInTheDocument();
expect(screen.getByTestId('test-map')).toBeInTheDocument();
});

it('displays error message when API key is missing', () => {
Expand Down
Loading