/* * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ import React, { memo, useEffect, useState } from 'react'; import { DropResult, EuiButtonEmpty, EuiButtonIcon, EuiDragDropContext, EuiDraggable, EuiDroppable, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiListGroupItem, EuiPanel, EuiTitle, EuiIcon, EuiToolTip, } from '@elastic/eui'; import { I18nProvider } from '@osd/i18n/react'; import './layer_control_panel.scss'; import { isEqual } from 'lodash'; import { i18n } from '@osd/i18n'; import { MaplibreRef } from 'public/model/layersFunctions'; import { IndexPattern } from '../../../../../src/plugins/data/public'; import { AddLayerPanel } from '../add_layer_panel'; import { LayerConfigPanel } from '../layer_config'; import { MapLayerSpecification } from '../../model/mapLayerType'; import { LAYER_ICON_TYPE_MAP } from '../../../common'; import { useOpenSearchDashboards } from '../../../../../src/plugins/opensearch_dashboards_react/public'; import { MapServices } from '../../types'; import { MapState } from '../../model/mapState'; import { ConfigSchema } from '../../../common/config'; import { moveLayers, removeLayers } from '../../model/map/layer_operations'; import { DeleteLayerModal } from './delete_layer_modal'; import { HideLayer } from './hide_layer_button'; interface Props { maplibreRef: MaplibreRef; setLayers: (layers: MapLayerSpecification[]) => void; layers: MapLayerSpecification[]; layersIndexPatterns: IndexPattern[]; setLayersIndexPatterns: (indexPatterns: IndexPattern[]) => void; mapState: MapState; zoom: number; mapConfig: ConfigSchema; isReadOnlyMode: boolean; selectedLayerConfig: MapLayerSpecification | undefined; setSelectedLayerConfig: (layerConfig: MapLayerSpecification | undefined) => void; setIsUpdatingLayerRender: (isUpdatingLayerRender: boolean) => void; } export const LayerControlPanel = memo( ({ maplibreRef, setLayers, layers, zoom, mapConfig, isReadOnlyMode, selectedLayerConfig, setSelectedLayerConfig, setIsUpdatingLayerRender, }: Props) => { const { services } = useOpenSearchDashboards<MapServices>(); const [isLayerConfigVisible, setIsLayerConfigVisible] = useState(false); const [isLayerControlVisible, setIsLayerControlVisible] = useState(true); const [isNewLayer, setIsNewLayer] = useState(false); const [isDeleteLayerModalVisible, setIsDeleteLayerModalVisible] = useState(false); const [originLayerConfig, setOriginLayerConfig] = useState<MapLayerSpecification | null>(null); const [selectedDeleteLayer, setSelectedDeleteLayer] = useState< MapLayerSpecification | undefined >(); const [visibleLayers, setVisibleLayers] = useState<MapLayerSpecification[]>([]); useEffect(() => { const getCurrentVisibleLayers = () => { return layers.filter( (layer: { visibility: string; zoomRange: number[] }) => zoom >= layer.zoomRange[0] && zoom <= layer.zoomRange[1] ); }; setVisibleLayers(getCurrentVisibleLayers()); }, [layers, zoom]); const closeLayerConfigPanel = () => { setIsLayerConfigVisible(false); setTimeout(() => { maplibreRef.current?.resize(); }, 0); }; const newLayerIndex = () => { return layers?.length + 1; }; const addLayer = (layer: MapLayerSpecification) => { setLayers([...layers, layer]); }; const updateLayer = () => { if (!selectedLayerConfig) { return; } const layersClone = [...layers]; const index = layersClone.findIndex((layer) => layer.id === selectedLayerConfig.id); if (index <= -1) { layersClone.push(selectedLayerConfig); } else { layersClone[index] = { ...layersClone[index], ...selectedLayerConfig, }; } setLayers(layersClone); }; const updateLayerVisibility = (layerId: string, visibility: string) => { const layersClone = [...layers]; const index = layersClone.findIndex((layer) => layer.id === layerId); if (index > -1) { layersClone[index].visibility = String(visibility); setLayers(layersClone); } }; const removeLayer = (layerId: string) => { const layersClone = [...layers]; const index = layersClone.findIndex((layer) => layer.id === layerId); if (index > -1) { layersClone.splice(index, 1); setLayers(layersClone); } }; const hasUnsavedChanges = () => { if (!selectedLayerConfig || !originLayerConfig) { return false; } return !isEqual(originLayerConfig, selectedLayerConfig); }; const onClickLayerName = (layer: MapLayerSpecification) => { if (hasUnsavedChanges()) { services.toastNotifications.addWarning( `You have unsaved changes for ${selectedLayerConfig?.name}` ); } else { setSelectedLayerConfig(layer); setIsLayerConfigVisible(true); } }; const isLayerExists = (name: string) => { return layers.findIndex((layer) => layer.name === name) > -1; }; const beforeMaplibreLayerID = (source: number, destination: number) => { if (source > destination) { // if layer is moved below, move current layer below given destination return layers[destination].id; } const beforeIndex = destination + 1; // if layer is moved up, move current layer above destination if (beforeIndex < layers.length) { return layers[beforeIndex].id; } return undefined; }; const onDragEnd = (dropResult: DropResult) => { if (!dropResult) { return; } if (dropResult.source && dropResult.destination) { // we display list in reverse order const prevIndex = getLayerIndex(dropResult.source.index); const newIndex = getLayerIndex(dropResult.destination.index); const currentMaplibreLayerId = layers[prevIndex].id; const beforeMaplibreLayerId = beforeMaplibreLayerID(prevIndex, newIndex); moveLayers(maplibreRef.current!, currentMaplibreLayerId, beforeMaplibreLayerId); // update map layers const layersClone = [...layers]; const oldLayer = layersClone[prevIndex]; layersClone.splice(prevIndex, 1); layersClone.splice(newIndex, 0, oldLayer); setLayers(layersClone); } }; const getLayerIndex = (reversedIndex: number) => { return layers.length - reversedIndex - 1; }; const getReverseLayers = () => { const layersClone = [...layers]; return layersClone.reverse(); }; const onDeleteLayerIconClick = (layer: MapLayerSpecification) => { setSelectedDeleteLayer(layer); setIsDeleteLayerModalVisible(true); }; const onDeleteLayerConfirm = () => { if (selectedDeleteLayer) { removeLayers(maplibreRef.current!, selectedDeleteLayer.id, true); removeLayer(selectedDeleteLayer.id); setIsDeleteLayerModalVisible(false); setSelectedDeleteLayer(undefined); } }; const onCancelDeleteLayer = () => { setIsDeleteLayerModalVisible(false); setSelectedDeleteLayer(undefined); }; const getLayerTooltipContent = (layer: MapLayerSpecification) => { if (layer.visibility !== 'visible') { return i18n.translate('maps.layerControl.layerIsHidden', { defaultMessage: 'Layer is hidden', }); } if (zoom < layer.zoomRange[0] || zoom > layer.zoomRange[1]) { return i18n.translate('maps.layerControl.layerNotVisibleZoom', { defaultMessage: `Layer is hidden outside of zoom range ${layer.zoomRange[0]}–${layer.zoomRange[1]}`, }); } return ''; }; const layerIsVisible = (layer: MapLayerSpecification): boolean => { if (layer.visibility !== 'visible') { return false; } return visibleLayers.includes(layer); }; if (isReadOnlyMode) { return null; } let content; if (isLayerControlVisible) { content = ( <I18nProvider> <EuiPanel paddingSize="none" className="layerControlPanel layerControlPanel--show" data-test-subj="layerControlPanel" > <EuiFlexGroup responsive={false} justifyContent="spaceBetween" direction="column" gutterSize="none" > <EuiFlexGroup direction="row" alignItems="center"> <EuiFlexItem className="layerControlPanel__title"> <EuiTitle size="xs"> <h2>Layers</h2> </EuiTitle> </EuiFlexItem> <EuiFlexItem grow={false}> <EuiButtonEmpty size="s" iconType="menuLeft" onClick={() => setIsLayerControlVisible((visible) => !visible)} aria-label="Hide layer control" color="text" className="layerControlPanel__visButton" title="Collapse layers panel" /> </EuiFlexItem> </EuiFlexGroup> <EuiHorizontalRule margin="none" /> <EuiDragDropContext onDragEnd={onDragEnd}> <EuiDroppable droppableId="LAYERS_HANDLE_DROPPABLE_AREA" spacing="none"> {getReverseLayers().map((layer, index) => { const isLayerSelected = isLayerConfigVisible && selectedLayerConfig && selectedLayerConfig.id === layer.id; return ( <EuiDraggable spacing="none" key={layer.id} index={index} draggableId={layer.id} customDragHandle={true} > {(provided) => ( <div key={layer.id}> <EuiFlexGroup className={isLayerSelected ? 'layerControlPanel__selected' : ''} alignItems="center" gutterSize="none" direction="row" justifyContent={'flexStart'} > <EuiFlexItem className="layerControlPanel__layerTypeIcon" grow={false} > <EuiIcon size="m" type={LAYER_ICON_TYPE_MAP[layer.type]} color={layerIsVisible(layer) ? 'success' : '#DDDDDD'} /> </EuiFlexItem> <EuiFlexItem> <EuiToolTip position="top" content={getLayerTooltipContent(layer)}> <EuiListGroupItem key={layer.id} label={layer.name} color={layerIsVisible(layer) ? 'text' : 'subdued'} aria-label="layer in the map layers list" onClick={() => onClickLayerName(layer)} showToolTip={false} /> </EuiToolTip> </EuiFlexItem> <EuiFlexGroup justifyContent="flexEnd" gutterSize="none"> <HideLayer layer={layer} maplibreRef={maplibreRef} updateLayerVisibility={updateLayerVisibility} /> <EuiFlexItem grow={false} className="layerControlPanel__layerFunctionButton" > <EuiButtonIcon size="s" iconType="trash" onClick={() => onDeleteLayerIconClick(layer)} aria-label="Delete layer" color={layer.id === selectedLayerConfig?.id ? 'text' : 'danger'} title="Delete layer" disabled={layer.id === selectedLayerConfig?.id} /> </EuiFlexItem> <EuiFlexItem grow={false} className="layerControlPanel__layerFunctionButton" > <EuiButtonEmpty size="s" iconType="grab" {...provided.dragHandleProps} aria-label="Drag Handle" color="text" title="Move layer up or down" /> </EuiFlexItem> </EuiFlexGroup> </EuiFlexGroup> <EuiHorizontalRule margin="none" /> </div> )} </EuiDraggable> ); })} </EuiDroppable> </EuiDragDropContext> {isLayerConfigVisible && selectedLayerConfig && ( <LayerConfigPanel closeLayerConfigPanel={closeLayerConfigPanel} selectedLayerConfig={selectedLayerConfig} updateLayer={updateLayer} setSelectedLayerConfig={setSelectedLayerConfig} removeLayer={removeLayer} isNewLayer={isNewLayer} setIsNewLayer={setIsNewLayer} isLayerExists={isLayerExists} originLayerConfig={originLayerConfig} setOriginLayerConfig={setOriginLayerConfig} setIsUpdatingLayerRender={setIsUpdatingLayerRender} /> )} <AddLayerPanel setIsLayerConfigVisible={setIsLayerConfigVisible} setSelectedLayerConfig={setSelectedLayerConfig} IsLayerConfigVisible={isLayerConfigVisible} addLayer={addLayer} newLayerIndex={newLayerIndex()} setIsNewLayer={setIsNewLayer} mapConfig={mapConfig} layerCount={layers.length} /> {isDeleteLayerModalVisible && ( <DeleteLayerModal onCancel={onCancelDeleteLayer} onConfirm={onDeleteLayerConfirm} layerName={selectedDeleteLayer?.name!} /> )} </EuiFlexGroup> </EuiPanel> </I18nProvider> ); } else { content = ( <EuiFlexItem grow={false} className="layerControlPanel layerControlPanel--hide"> <EuiButtonIcon className="layerControlPanel__visButton" size="s" iconType="menuRight" onClick={() => setIsLayerControlVisible((visible) => !visible)} aria-label="Show layer control" title="Expand layers panel" /> </EuiFlexItem> ); } return <div className="layerControlPanel-container">{content}</div>; } );