diff --git a/src/components/src/map/split-map-button.tsx b/src/components/src/map/split-map-button.tsx index 73d1342d69..702b9f5f21 100644 --- a/src/components/src/map/split-map-button.tsx +++ b/src/components/src/map/split-map-button.tsx @@ -3,14 +3,15 @@ import React, {ComponentType, useCallback, useMemo, useState, useRef, useEffect} from 'react'; import classnames from 'classnames'; -import styled from 'styled-components'; import {MapControlButton} from '../common/styled-components'; import {Delete, Split} from '../common/icons'; +import ToolbarItem from '../common/toolbar-item'; import MapControlTooltipFactory from './map-control-tooltip'; +import MapControlToolbarFactory from './map-control-toolbar'; import {MapControlItem, MapControls, MapState} from '@kepler.gl/types'; import {MapSplitMode} from '@kepler.gl/constants'; -SplitMapButtonFactory.deps = [MapControlTooltipFactory]; +SplitMapButtonFactory.deps = [MapControlTooltipFactory, MapControlToolbarFactory]; interface SplitMapButtonIcons { delete: ComponentType; @@ -22,39 +23,13 @@ export type SplitMapButtonProps = { mapIndex: number; onToggleSplitMap: (index?: number) => void; onSetMapSplitMode?: (payload: {mapSplitMode: MapSplitMode}) => void; + onToggleMapControl?: (control: string) => void; actionIcons: SplitMapButtonIcons; readOnly: boolean; mapControls: MapControls; mapState?: MapState; }; -const StyledSplitModeMenu = styled.div` - position: absolute; - top: 0; - left: -160px; - background: ${props => props.theme.dropdownListBgd || '#3A414C'}; - border-radius: 4px; - box-shadow: 0 6px 12px 0 rgba(0, 0, 0, 0.16); - padding: 4px 0; - z-index: 1000; - min-width: 140px; -`; - -const StyledMenuItem = styled.div<{$active?: boolean}>` - padding: 8px 16px; - color: ${props => - props.$active - ? props.theme.activeColor || '#1FBAD6' - : props.theme.textColor || '#A0A7B4'}; - cursor: pointer; - font-size: 12px; - font-weight: ${props => (props.$active ? 500 : 400)}; - &:hover { - background: ${props => props.theme.dropdownListHighlightBg || '#4B5464'}; - color: ${props => props.theme.textColorHl || '#FFFFFF'}; - } -`; - const SwipeCompareIcon: React.FC<{height?: string}> = ({height = '18px'}) => ( = ({height = '18px'}) => ( ); const SPLIT_MODE_OPTIONS = [ - {id: MapSplitMode.SINGLE_MAP, label: 'Single'}, - {id: MapSplitMode.DUAL_MAP, label: 'Dual'}, - {id: MapSplitMode.SWIPE_COMPARE, label: 'Swipe'} + {id: MapSplitMode.SINGLE_MAP, label: 'tooltip.singleView'}, + {id: MapSplitMode.DUAL_MAP, label: 'tooltip.dualView'}, + {id: MapSplitMode.SWIPE_COMPARE, label: 'tooltip.swipeView'} ]; -function SplitMapButtonFactory(MapControlTooltip) { +function SplitMapButtonFactory( + MapControlTooltip: ReturnType, + MapControlToolbar: ReturnType +) { const defaultActionIcons = { delete: Delete, split: Split @@ -86,27 +64,56 @@ function SplitMapButtonFactory(MapControlTooltip) { mapIndex, onToggleSplitMap, onSetMapSplitMode, + onToggleMapControl, actionIcons = defaultActionIcons, mapControls, readOnly, mapState }) => { const splitMap = mapControls?.splitMap || ({} as MapControlItem); - const [menuOpen, setMenuOpen] = useState(false); const menuRef = useRef(null); + // The split-mode menu open state is normally kept in redux + // (mapControls.splitMap.active) so it participates in the "only one + // map-control menu open at a time" logic in toggleMapControlUpdater. + // If a consumer supplies onSetMapSplitMode without onToggleMapControl, we + // fall back to local component state so the menu still opens/closes. + const useReduxMenuState = Boolean(onToggleMapControl); + const [localMenuOpen, setLocalMenuOpen] = useState(false); + + const toggleMenu = useCallback(() => { + if (useReduxMenuState) { + onToggleMapControl?.('splitMap'); + } else { + setLocalMenuOpen(prev => !prev); + } + }, [useReduxMenuState, onToggleMapControl]); + + const closeMenu = useCallback(() => { + if (useReduxMenuState) { + if (splitMap.active) { + onToggleMapControl?.('splitMap'); + } + } else { + setLocalMenuOpen(false); + } + }, [useReduxMenuState, onToggleMapControl, splitMap.active]); + + const menuOpen = + Boolean(onSetMapSplitMode) && (useReduxMenuState ? Boolean(splitMap.active) : localMenuOpen); + const currentMode = mapState?.mapSplitMode || MapSplitMode.SINGLE_MAP; const onClick = useCallback( event => { event.preventDefault(); if (onSetMapSplitMode) { - setMenuOpen(prev => !prev); + toggleMenu(); } else { onToggleSplitMap(isSplit ? mapIndex : undefined); } }, - [isSplit, mapIndex, onToggleSplitMap, onSetMapSplitMode] + [isSplit, mapIndex, onToggleSplitMap, onSetMapSplitMode, toggleMenu] ); const handleModeSelect = useCallback( @@ -114,15 +121,15 @@ function SplitMapButtonFactory(MapControlTooltip) { if (onSetMapSplitMode) { onSetMapSplitMode({mapSplitMode: mode as MapSplitMode}); } - setMenuOpen(false); + closeMenu(); }, - [onSetMapSplitMode] + [onSetMapSplitMode, closeMenu] ); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setMenuOpen(false); + closeMenu(); } }; if (menuOpen) { @@ -131,7 +138,7 @@ function SplitMapButtonFactory(MapControlTooltip) { return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [menuOpen]); + }, [menuOpen, closeMenu]); const isVisible = useMemo(() => splitMap.show && readOnly !== true, [splitMap.show, readOnly]); @@ -139,7 +146,19 @@ function SplitMapButtonFactory(MapControlTooltip) { return null; } return isVisible ? ( -
+
+ {menuOpen && onSetMapSplitMode ? ( + + {SPLIT_MODE_OPTIONS.map(option => ( + handleModeSelect(option.id)} + label={option.label} + active={currentMode === option.id} + /> + ))} + + ) : null} - {menuOpen && onSetMapSplitMode && ( - - {SPLIT_MODE_OPTIONS.map(option => ( - handleModeSelect(option.id)} - > - {option.label} - - ))} - - )}
) : null; }; diff --git a/src/localization/src/translations/ca.ts b/src/localization/src/translations/ca.ts index 1e187cf3ee..d1a6e53b6f 100644 --- a/src/localization/src/translations/ca.ts +++ b/src/localization/src/translations/ca.ts @@ -314,6 +314,9 @@ export default { closePanel: 'Tanca panel actual', switchToDualView: 'Canvia a la vista de mapa dual', selectSplitMode: "Selecciona el mode de vista del mapa", + singleView: "Únic", + dualView: "Doble", + swipeView: "Comparar", showLegend: 'mostra llegenda', disable3DMap: 'Desactiva mapa 3D', DrawOnMap: 'Dibuixa al mapa', diff --git a/src/localization/src/translations/cn.ts b/src/localization/src/translations/cn.ts index 63a45c0a81..ec47b2541d 100644 --- a/src/localization/src/translations/cn.ts +++ b/src/localization/src/translations/cn.ts @@ -301,6 +301,9 @@ export default { closePanel: '关闭当前面板', switchToDualView: '切换到双地图视图', selectSplitMode: '选择地图视图模式', + singleView: '单个', + dualView: '双屏', + swipeView: '对比', showLegend: '显示图例', disable3DMap: '禁用 3D 地图', DrawOnMap: '在地图上绘制', diff --git a/src/localization/src/translations/en.ts b/src/localization/src/translations/en.ts index 84aefb01c4..60e203a7e1 100644 --- a/src/localization/src/translations/en.ts +++ b/src/localization/src/translations/en.ts @@ -356,6 +356,9 @@ export default { closePanel: 'Close current panel', switchToDualView: 'Switch to dual map view', selectSplitMode: 'Select map view mode', + singleView: 'Single', + dualView: 'Dual', + swipeView: 'Swipe', showLegend: 'Show legend', disable3DMap: 'Disable 3D Map', globeMap: 'Globe Map', diff --git a/src/localization/src/translations/es.ts b/src/localization/src/translations/es.ts index cb4928db11..f3b9155229 100644 --- a/src/localization/src/translations/es.ts +++ b/src/localization/src/translations/es.ts @@ -315,6 +315,9 @@ export default { closePanel: 'Cerrar el panel actual', switchToDualView: 'Cambiar a la vista de mapa dual', selectSplitMode: 'Seleccionar modo de vista del mapa', + singleView: 'Único', + dualView: 'Doble', + swipeView: 'Comparar', showLegend: 'Mostrar leyenda', disable3DMap: 'Desactivar mapa 3D', DrawOnMap: 'Dibujar en el mapa', diff --git a/src/localization/src/translations/fi.ts b/src/localization/src/translations/fi.ts index b111c7d756..05c7dbe8f7 100644 --- a/src/localization/src/translations/fi.ts +++ b/src/localization/src/translations/fi.ts @@ -313,6 +313,9 @@ export default { closePanel: 'Sulje paneeli', switchToDualView: 'Vaihda kaksoiskarrtanäkymään', selectSplitMode: 'Valitse karttanäkymätila', + singleView: 'Yksi', + dualView: 'Kaksois', + swipeView: 'Vertaa', showLegend: 'Näytä selite', disable3DMap: 'Poistu 3D-näkymästä', DrawOnMap: 'Piirrä kartalle', diff --git a/src/localization/src/translations/ja.ts b/src/localization/src/translations/ja.ts index f3beb3f3f9..ede08a0105 100644 --- a/src/localization/src/translations/ja.ts +++ b/src/localization/src/translations/ja.ts @@ -310,6 +310,9 @@ export default { closePanel: 'このパネルを閉じる', switchToDualView: 'デュアルビューに切り替え', selectSplitMode: 'マップ表示モードを選択', + singleView: 'シングル', + dualView: 'デュアル', + swipeView: 'スワイプ', showLegend: '凡例を表示', disable3DMap: '3D地図を無効化', DrawOnMap: '地図上に図形を描画', diff --git a/src/localization/src/translations/pt.ts b/src/localization/src/translations/pt.ts index 9034cd8464..dd87a35dd9 100644 --- a/src/localization/src/translations/pt.ts +++ b/src/localization/src/translations/pt.ts @@ -315,6 +315,9 @@ export default { closePanel: 'Fechar painel atual', switchToDualView: 'Trocar para visualização dupla de mapa', selectSplitMode: 'Selecionar modo de visualização do mapa', + singleView: 'Único', + dualView: 'Duplo', + swipeView: 'Comparar', showLegend: 'mostrar legenda', disable3DMap: 'Desabilitar Mapa 3D', DrawOnMap: 'Desenhar no mapa', diff --git a/src/localization/src/translations/ru.ts b/src/localization/src/translations/ru.ts index ffeadce4f1..0675598455 100644 --- a/src/localization/src/translations/ru.ts +++ b/src/localization/src/translations/ru.ts @@ -312,6 +312,9 @@ export default { closePanel: 'Закрыть текущую панель', switchToDualView: 'Перейти в режим двойной карты', selectSplitMode: 'Выбрать режим отображения карты', + singleView: 'Одна', + dualView: 'Двойная', + swipeView: 'Сравнение', showLegend: 'Показать легенду', disable3DMap: 'Отключить 3D Карту', DrawOnMap: 'Рисовать на карте', diff --git a/src/reducers/src/ui-state-updaters.ts b/src/reducers/src/ui-state-updaters.ts index 34705d191a..877e45e902 100644 --- a/src/reducers/src/ui-state-updaters.ts +++ b/src/reducers/src/ui-state-updaters.ts @@ -387,6 +387,16 @@ export const toggleSidePanelCloseButtonUpdater = ( isSidePanelCloseButtonVisible: show }); +// Map control dropdowns/menus that overlap each other visually and therefore +// should be mutually exclusive: opening one closes all the others. +// (split/view-mode menu, top/3d/globe menu, polygon draw tool, language menu) +export const MUTUALLY_EXCLUSIVE_MAP_CONTROLS: string[] = [ + MAP_CONTROLS.splitMap, + MAP_CONTROLS.toggle3d, + MAP_CONTROLS.mapDraw, + MAP_CONTROLS.mapLocale +]; + /** * Toggle active map control panel * @memberof uiStateUpdaters @@ -410,15 +420,6 @@ export const toggleMapControlUpdater = ( ? MAP_CONTROLS.effect : null; - // To to toggle the mapDraw and mapLocal dropdowns - // We have to deactivate the other active dropdown - const dropdownToDeactivate = - panelId === MAP_CONTROLS.mapDraw - ? MAP_CONTROLS.mapLocale - : panelId === MAP_CONTROLS.mapLocale - ? MAP_CONTROLS.mapDraw - : null; - // If we need to deactivate a competing panel and it's currently active if (panelToDeactivate && state.mapControls[panelToDeactivate]?.active) { updatedState = { @@ -433,18 +434,22 @@ export const toggleMapControlUpdater = ( }; } - // If we need to deactivate a competing dropdown and it's currently active - if (dropdownToDeactivate && state.mapControls[dropdownToDeactivate]?.active) { - updatedState = { - ...state, - mapControls: { - ...updatedState.mapControls, - [dropdownToDeactivate]: { - ...updatedState.mapControls[dropdownToDeactivate], - active: false - } + // The overlapping map control menus should be mutually exclusive: when one of + // them is being opened, deactivate every other one that is currently active + // so their dropdowns never overlap on screen. + const isOpening = !updatedState.mapControls[panelId]?.active; + if (isOpening && MUTUALLY_EXCLUSIVE_MAP_CONTROLS.includes(panelId)) { + const nextMapControls = {...updatedState.mapControls}; + let didDeactivate = false; + MUTUALLY_EXCLUSIVE_MAP_CONTROLS.forEach(controlId => { + if (controlId !== panelId && nextMapControls[controlId]?.active) { + nextMapControls[controlId] = {...nextMapControls[controlId], active: false}; + didDeactivate = true; } - }; + }); + if (didDeactivate) { + updatedState = {...updatedState, mapControls: nextMapControls}; + } } return {