diff --git a/src/App.tsx b/src/App.tsx index d2f601f..9f904fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,6 @@ import { fetchProducts } from './utils/fetchUtils'; import { assertBand, baselayersReducer, - CHANGE_BASELAYER, CHANGE_CMAP_TYPE, CHANGE_CMAP_VALUES, initialBaselayersState, @@ -19,11 +18,10 @@ import { useQuery } from './hooks/useQuery'; import { useHighlightBoxes } from './hooks/useHighlightBoxes'; import { OpenLayersMap } from './components/OpenLayersMap'; import { handleSelectChange } from './utils/layerUtils'; -import { EXTERNAL_BASELAYERS } from './configs/mapSettings'; function App() { /** contains useful state of the baselayer for tile requests and matplotlib color mapping */ - const [baselayersState, dispatch] = useReducer( + const [baselayersState, dispatchBaselayersChange] = useReducer( baselayersReducer, initialBaselayersState ); @@ -58,7 +56,7 @@ function App() { // Set the baselayersState with the finalBands; note that this action will also set the // activeBaselayer to be finalBands[0] - dispatch({ + dispatchBaselayersChange({ type: SET_BASELAYERS_STATE, baselayers: finalBands, }); @@ -101,33 +99,6 @@ function App() { const [activeSourceListIds, setActiveSourceListIds] = useState([]); - /** - * Handler fires when user changes map layers. If the units of the new - * layer are the same as the active layer, then we just set a new active - * layer. If the units differ, we set new values for vmin, vmax, and cmap - * from the band's recommended values in order to prevent nonsensical - * TileLayer requests. - */ - const onBaseLayerChange = useCallback( - (selectedBaselayerId: string) => { - const isExternalBaselayer = selectedBaselayerId.includes('external'); - - const newActiveBaselayer = isExternalBaselayer - ? EXTERNAL_BASELAYERS.find((b) => b.id === selectedBaselayerId) - : baselayersState.internalBaselayersState?.find( - (b) => b.id === Number(selectedBaselayerId) - ); - - if (!newActiveBaselayer) return; - - dispatch({ - type: CHANGE_BASELAYER, - newBaselayer: newActiveBaselayer, - }); - }, - [baselayersState.internalBaselayersState] - ); - const onSelectedSourceListsChange = useCallback( (e: ChangeEvent) => { if (!sourceLists) return; @@ -147,7 +118,7 @@ function App() { const onCmapValuesChange = useCallback( (values: number[]) => { if (baselayersState.activeBaselayer) { - dispatch({ + dispatchBaselayersChange({ type: CHANGE_CMAP_VALUES, activeBaselayer: baselayersState.activeBaselayer, cmapValues: { @@ -163,7 +134,7 @@ function App() { const onCmapChange = useCallback( (cmap: string) => { if (baselayersState.activeBaselayer) { - dispatch({ + dispatchBaselayersChange({ type: CHANGE_CMAP_TYPE, activeBaselayer: baselayersState.activeBaselayer, cmap, @@ -199,7 +170,7 @@ function App() { {activeBaselayer && internalBaselayersState && ( void; + goForward: () => void; +}) { + return ( + <> +
+ +
+
+ +
+ + ); +} diff --git a/src/components/BoxMenu.tsx b/src/components/BoxMenu.tsx index 73a8494..4dd23ba 100644 --- a/src/components/BoxMenu.tsx +++ b/src/components/BoxMenu.tsx @@ -29,7 +29,7 @@ export function BoxMenu({ return (
-
-
- - {showMenu && ( -
- {SUBMAP_DOWNLOAD_OPTIONS.map((option) => ( - - ))} - {...additionalButtons} -
- )} - {!isNewBox && 'name' in boxData &&

{boxData.name}

} -
- {!isNewBox && 'description' in boxData &&

{boxData.description}

} +
+ + {showMenu && ( +
+ {SUBMAP_DOWNLOAD_OPTIONS.map((option) => ( + + ))} + {...additionalButtons} +
+ )} + {!isNewBox && 'name' in boxData &&

{boxData.name}

}
+ {!isNewBox && 'description' in boxData &&

{boxData.description}

}
); } diff --git a/src/components/LayerSelector.tsx b/src/components/LayerSelector.tsx index f714633..b568756 100644 --- a/src/components/LayerSelector.tsx +++ b/src/components/LayerSelector.tsx @@ -1,4 +1,4 @@ -import { MouseEvent, useCallback, useRef } from 'react'; +import { MouseEvent, useCallback, useEffect, useRef, useState } from 'react'; import { BandWithCmapValues, SourceList } from '../types/maps'; import { makeLayerName } from '../utils/layerUtils'; import { LayersIcon } from './icons/LayersIcon'; @@ -14,7 +14,13 @@ interface Props | 'setActiveBoxIds' | 'setBoxes' | 'addOptimisticHighlightBox' + | 'dispatchBaselayersChange' > { + onBaselayerChange: ( + selectedBaselayerId: string, + context: 'layerMenu' | 'goBack' | 'goForward', + flipped?: boolean + ) => void; activeBaselayerId?: number | string; sourceLists: SourceList[]; isFlipped: boolean; @@ -23,7 +29,7 @@ interface Props export function LayerSelector({ internalBaselayers, - onBaseLayerChange, + onBaselayerChange, activeBaselayerId, sourceLists, onSelectedSourceListsChange, @@ -34,10 +40,50 @@ export function LayerSelector({ isFlipped, }: Props) { const menuRef = useRef(null); + const [lockMenu, setLockMenu] = useState(false); + const previousLockMenuHandlerRef = useRef<(e: KeyboardEvent) => void>(null); + + useEffect(() => { + if (previousLockMenuHandlerRef.current) { + document.removeEventListener( + 'keypress', + previousLockMenuHandlerRef.current + ); + } + + // Create new handler + const newHandler = (e: KeyboardEvent) => { + // Return early if target is in an input + if ((e.target as HTMLElement)?.closest('input')) { + return; + } + if (e.key === 'm') { + setLockMenu(!lockMenu); + if (menuRef.current?.classList.contains('hide')) { + menuRef.current.classList.remove('hide'); + } else { + if (lockMenu) { + menuRef.current?.classList.add('hide'); + } + } + } + }; + + // Add new handler and update the ref + document.addEventListener('keypress', newHandler); + previousLockMenuHandlerRef.current = newHandler; + + // Remove handler when component unmounts + return () => + document.removeEventListener( + 'keypress', + previousLockMenuHandlerRef.current ?? newHandler + ); + }, [setLockMenu, lockMenu, menuRef.current]); const toggleMenu = useCallback( (e: MouseEvent) => { - if (!menuRef.current) return; + if (!menuRef.current || lockMenu) return; const target = (e.target as HTMLElement).closest('div'); if (target && target.classList.contains('btn')) { menuRef.current.classList.remove('hide'); @@ -46,7 +92,7 @@ export function LayerSelector({ menuRef.current.classList.add('hide'); } }, - [menuRef.current] + [menuRef.current, lockMenu] ); return ( @@ -59,6 +105,18 @@ export function LayerSelector({ className={'layer-selector-container menu hide'} onMouseLeave={toggleMenu} > +
+ + setLockMenu(!lockMenu)} + /> +
Baselayers {internalBaselayers?.map((band) => ( @@ -69,7 +127,7 @@ export function LayerSelector({ value={band.id} name="baselayer" checked={band.id === activeBaselayerId} - onChange={(e) => onBaseLayerChange(e.target.value)} + onChange={(e) => onBaselayerChange(e.target.value, 'layerMenu')} />
@@ -78,6 +136,11 @@ export function LayerSelector({
onBaseLayerChange(e.target.value)} + onChange={(e) => onBaselayerChange(e.target.value, 'layerMenu')} disabled={bl.disabledState(isFlipped)} /> diff --git a/src/components/OpenLayersMap.tsx b/src/components/OpenLayersMap.tsx index 4a39af4..db6d6c7 100644 --- a/src/components/OpenLayersMap.tsx +++ b/src/components/OpenLayersMap.tsx @@ -31,19 +31,23 @@ import { GraticuleLayer } from './layers/GraticuleLayer'; import { SourcesLayer } from './layers/SourcesLayer'; import { AddHighlightBoxLayer } from './layers/AddHighlightBoxLayer'; import { generateSearchContent } from '../utils/externalSearchUtils'; -import './styles/highlight-controls.css'; -import './styles/area-selection.css'; -import { assertBand } from '../reducers/baselayersReducer'; +import './styles/highlight-box.css'; +import { + Action, + assertBand, + CHANGE_BASELAYER, +} from '../reducers/baselayersReducer'; import { getBaselayerResolutions, transformCoords, transformGraticuleCoords, } from '../utils/layerUtils'; import { ToggleSwitch } from './ToggleSwitch'; +import { BaselayerHistoryNavigation } from './BaselayerHistoryNavigation'; export type MapProps = { baselayersState: BaselayersState; - onBaseLayerChange: (baselayerId: string) => void; + dispatchBaselayersChange: React.ActionDispatch<[action: Action]>; sourceLists?: SourceList[]; activeSourceListIds: number[]; onSelectedSourceListsChange: (e: ChangeEvent) => void; @@ -58,7 +62,7 @@ export type MapProps = { export function OpenLayersMap({ baselayersState, - onBaseLayerChange, + dispatchBaselayersChange, sourceLists = [], onSelectedSourceListsChange, activeSourceListIds, @@ -76,6 +80,7 @@ export function OpenLayersMap({ const externalSearchMarkerRef = useRef(null); const previousSearchOverlayHandlerRef = useRef<(e: MapBrowserEvent) => void | null>(null); + const previousKeyboardHandlerRef = useRef<(e: KeyboardEvent) => void>(null); const [coordinates, setCoordinates] = useState( undefined ); @@ -83,8 +88,93 @@ export function OpenLayersMap({ const [isNewBoxDrawn, setIsNewBoxDrawn] = useState(false); const [flipTiles, setFlipTiles] = useState(false); + const [backHistoryStack, setBackHistoryStack] = useState< + { id: string; flipped: boolean }[] + >([]); + const [forwardHistoryStack, setForwardHistoryStack] = useState< + { id: string; flipped: boolean }[] + >([]); + const { activeBaselayer, internalBaselayersState } = baselayersState; + /** + * Handler fires when user changes map layers. If the units of the new + * layer are the same as the active layer, then we just set a new active + * layer. If the units differ, we set new values for vmin, vmax, and cmap + * from the band's recommended values in order to prevent nonsensical + * TileLayer requests. + */ + const onBaselayerChange = useCallback( + ( + selectedBaselayerId: string, + context: 'layerMenu' | 'goBack' | 'goForward', + flipped?: boolean + ) => { + const isExternalBaselayer = selectedBaselayerId.includes('external'); + + const { activeBaselayer } = baselayersState; + + const newActiveBaselayer = isExternalBaselayer + ? EXTERNAL_BASELAYERS.find((b) => b.id === selectedBaselayerId) + : baselayersState.internalBaselayersState?.find( + (b) => b.id === Number(selectedBaselayerId) + ); + if (!newActiveBaselayer) return; + + if (context === 'goBack') { + setBackHistoryStack((prev) => prev.slice(0, -1)); + setForwardHistoryStack((prev) => + [...prev].concat({ + id: String(activeBaselayer?.id), + flipped: flipTiles, + }) + ); + } else if (context === 'goForward') { + setBackHistoryStack((prev) => + [...prev].concat({ + id: String(activeBaselayer?.id), + flipped: flipTiles, + }) + ); + setForwardHistoryStack((prev) => prev.slice(0, -1)); + } else { + setBackHistoryStack((prev) => + [...prev].concat({ + id: String(activeBaselayer?.id), + flipped: flipTiles, + }) + ); + setForwardHistoryStack([]); + } + + if (flipped !== undefined) { + setFlipTiles(flipped); + } + + dispatchBaselayersChange({ + type: CHANGE_BASELAYER, + newBaselayer: newActiveBaselayer, + }); + }, + [ + baselayersState.internalBaselayersState, + baselayersState.activeBaselayer, + backHistoryStack, + flipTiles, + setFlipTiles, + ] + ); + + const goBack = useCallback(() => { + const baselayer = backHistoryStack[backHistoryStack.length - 1]; + onBaselayerChange(baselayer.id, 'goBack', baselayer.flipped); + }, [onBaselayerChange, backHistoryStack]); + + const goForward = useCallback(() => { + const baselayer = forwardHistoryStack[forwardHistoryStack.length - 1]; + onBaselayerChange(baselayer.id, 'goForward', baselayer.flipped); + }, [onBaselayerChange, forwardHistoryStack]); + const tileLayers = useMemo(() => { return internalBaselayersState?.map((band) => { return new TileLayer({ @@ -326,6 +416,44 @@ export function OpenLayersMap({ } }, [activeBaselayer, tileLayers]); + /** + * Add keyboard support for switching baselayers + */ + useEffect(() => { + // Remove old handler if exists + if (previousKeyboardHandlerRef.current) { + document.removeEventListener( + 'keypress', + previousKeyboardHandlerRef.current + ); + } + + // Create new handler + const newHandler = (e: KeyboardEvent) => { + // Return early if target is in an input + if ((e.target as HTMLElement)?.closest('input')) { + return; + } + if (backHistoryStack.length && e.key === 'h') { + goBack(); + } + if (forwardHistoryStack.length && e.key === 'l') { + goForward(); + } + }; + + // Add new handler and update the ref + document.addEventListener('keypress', newHandler); + previousKeyboardHandlerRef.current = newHandler; + + // Remove handler when component unmounts + return () => + document.removeEventListener( + 'keypress', + previousKeyboardHandlerRef.current ?? newHandler + ); + }, [backHistoryStack, forwardHistoryStack, goBack, goForward]); + const disableToggleForNewBox = isDrawing || isNewBoxDrawn; return ( @@ -343,10 +471,10 @@ export function OpenLayersMap({ } />
-
+
+ diff --git a/src/components/icons/LeftArrowIcon.tsx b/src/components/icons/LeftArrowIcon.tsx new file mode 100644 index 0000000..55d82d2 --- /dev/null +++ b/src/components/icons/LeftArrowIcon.tsx @@ -0,0 +1,19 @@ +export function LeftArrowIcon() { + return ( + + + + + ); +} diff --git a/src/components/icons/MenuIcon.tsx b/src/components/icons/MenuIcon.tsx index cf14465..27d3b45 100644 --- a/src/components/icons/MenuIcon.tsx +++ b/src/components/icons/MenuIcon.tsx @@ -10,7 +10,7 @@ export function MenuIcon() { strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" - className="lucide lucide-menu" + className="lucide lucide-menu box-menu-icon" > diff --git a/src/components/icons/RightArrowIcon.tsx b/src/components/icons/RightArrowIcon.tsx new file mode 100644 index 0000000..b9f028c --- /dev/null +++ b/src/components/icons/RightArrowIcon.tsx @@ -0,0 +1,19 @@ +export function RightArrowIcon() { + return ( + + + + + ); +} diff --git a/src/components/layers/AddHighlightBoxLayer.tsx b/src/components/layers/AddHighlightBoxLayer.tsx index 29e59d2..2517062 100644 --- a/src/components/layers/AddHighlightBoxLayer.tsx +++ b/src/components/layers/AddHighlightBoxLayer.tsx @@ -151,14 +151,14 @@ export function AddHighlightBoxLayer({ additionalButtons={[ , ,