diff --git a/maps_dashboards/public/components/map_container/map_container.tsx b/maps_dashboards/public/components/map_container/map_container.tsx index 55189b85..b43e300f 100644 --- a/maps_dashboards/public/components/map_container/map_container.tsx +++ b/maps_dashboards/public/components/map_container/map_container.tsx @@ -5,25 +5,15 @@ import React, { useEffect, useRef, useState } from 'react'; import { EuiPanel } from '@elastic/eui'; -import { - Map as Maplibre, - MapLayerMouseEvent, - MapMouseEvent, - NavigationControl, - Popup, -} from 'maplibre-gl'; +import { Map as Maplibre, MapMouseEvent, NavigationControl, Popup } from 'maplibre-gl'; import { LayerControlPanel } from '../layer_control_panel'; import './map_container.scss'; import { MAP_INITIAL_STATE, MAP_GLYPHS } from '../../../common'; import { MapLayerSpecification } from '../../model/mapLayerType'; import { IndexPattern } from '../../../../../src/plugins/data/public'; import { MapState } from '../../model/mapState'; -import { - createPopup, - getPopupLngLat, - groupFeaturesByLayers, - isTooltipEnabledLayer, -} from '../tooltip/create_tooltip'; +import { createPopup, getPopupLngLat, isTooltipEnabledLayer } from '../tooltip/create_tooltip'; +import { DocumentLayerFunctions } from '../../model/documentLayerFunctions'; interface MapContainerProps { setLayers: (layers: MapLayerSpecification[]) => void; @@ -84,57 +74,23 @@ export const MapContainer = ({ const features = maplibreRef.current?.queryRenderedFeatures(e.point); if (features && maplibreRef.current) { - const featureGroup = groupFeaturesByLayers(features, tooltipEnabledLayers); - clickPopup = createPopup({ featureGroup }); + clickPopup = createPopup({ features, layers: tooltipEnabledLayers }); clickPopup ?.setLngLat(getPopupLngLat(features[0].geometry) ?? e.lngLat) .addTo(maplibreRef.current); } } - let hoverPopup: Popup | null = null; - - function onMouseEnter(e: MapLayerMouseEvent) { - hoverPopup?.remove(); - - if (maplibreRef.current) { - maplibreRef.current.getCanvas().style.cursor = 'pointer'; - if (e.features) { - hoverPopup = createPopup({ - featureGroup: [e.features], - showCloseButton: false, - showPagination: false, - showLayerSelection: false, - }); - hoverPopup - ?.setLngLat(getPopupLngLat(e.features[0].geometry) ?? e.lngLat) - .addTo(maplibreRef.current); - } - } - } - - function onMouseLeave(e: MapLayerMouseEvent) { - hoverPopup?.remove(); - if (maplibreRef.current) { - maplibreRef.current.getCanvas().style.cursor = ''; - } - } - if (maplibreRef.current) { maplibreRef.current.on('click', onClickMap); - tooltipEnabledLayers.forEach((l) => { - maplibreRef.current?.on('mouseenter', l.id, onMouseEnter); - maplibreRef.current?.on('mouseleave', l.id, onMouseLeave); - }); + for (const layer of tooltipEnabledLayers) { + DocumentLayerFunctions.addTooltip(maplibreRef.current, layer); + } } return () => { if (maplibreRef.current) { maplibreRef.current.off('click', onClickMap); - tooltipEnabledLayers.forEach((l) => { - maplibreRef.current?.off('mouseenter', l.id, onMouseEnter); - maplibreRef.current?.off('mouseleave', l.id, onMouseLeave); - }); } }; }, [layers]); diff --git a/maps_dashboards/public/components/tooltip/create_tooltip.tsx b/maps_dashboards/public/components/tooltip/create_tooltip.tsx index b1fb31f3..e77bd0ca 100644 --- a/maps_dashboards/public/components/tooltip/create_tooltip.tsx +++ b/maps_dashboards/public/components/tooltip/create_tooltip.tsx @@ -2,11 +2,12 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Popup, MapGeoJSONFeature } from 'maplibre-gl'; -import { MapLayerSpecification, OSMLayerSpecification } from '../../model/mapLayerType'; -import { TooltipContainer } from './tooltipContainer'; +import { MapLayerSpecification, DocumentLayerSpecification } from '../../model/mapLayerType'; +import { FeatureGroupItem, TooltipContainer } from './tooltipContainer'; type Options = { - featureGroup: GeoJSON.Feature[][]; + features: MapGeoJSONFeature[]; + layers: DocumentLayerSpecification[]; showCloseButton?: boolean; showPagination?: boolean; showLayerSelection?: boolean; @@ -14,24 +15,22 @@ type Options = { export function isTooltipEnabledLayer( layer: MapLayerSpecification -): layer is Exclude { +): layer is DocumentLayerSpecification { return layer.type !== 'opensearch_vector_tile_map' && layer.source.showTooltips === true; } export function groupFeaturesByLayers( features: MapGeoJSONFeature[], - layers: Exclude[] + layers: DocumentLayerSpecification[] ) { - const featureGroups: MapGeoJSONFeature[][] = []; + const featureGroups: FeatureGroupItem[] = []; if (layers.length > 0) { - layers.forEach((l) => { - const layerFeatures = features.filter((f) => f.layer.source === l.id); + layers.forEach((layer) => { + const layerFeatures = features.filter((f) => f.layer.source === layer.id); if (layerFeatures.length > 0) { - featureGroups.push(layerFeatures); + featureGroups.push({ features: layerFeatures, layer }); } }); - } else { - featureGroups.push(features); } return featureGroups; } @@ -48,7 +47,8 @@ export function getPopupLngLat(geometry: GeoJSON.Geometry) { } export function createPopup({ - featureGroup, + features, + layers, showCloseButton = true, showPagination = true, showLayerSelection = true, @@ -59,6 +59,8 @@ export function createPopup({ maxWidth: 'max-content', }); + const featureGroup = groupFeaturesByLayers(features, layers); + // Don't show popup if no feature if (featureGroup.length === 0) { return null; diff --git a/maps_dashboards/public/components/tooltip/tooltipContainer.tsx b/maps_dashboards/public/components/tooltip/tooltipContainer.tsx index d9898557..d985f051 100644 --- a/maps_dashboards/public/components/tooltip/tooltipContainer.tsx +++ b/maps_dashboards/public/components/tooltip/tooltipContainer.tsx @@ -7,10 +7,17 @@ import React, { useMemo, useState } from 'react'; import { EuiFlexItem, EuiFlexGroup, EuiPanel, EuiText, EuiHorizontalRule } from '@elastic/eui'; import { TooltipHeaderContent } from './tooltipHeaderContent'; -import { PageData, TableData, TooltipTable } from './tooltipTable'; +import { ALL_LAYERS, PageData, TableData, TooltipTable } from './tooltipTable'; +import { MapGeoJSONFeature } from 'maplibre-gl'; +import { DocumentLayerSpecification } from '../../model/mapLayerType'; + +export type FeatureGroupItem = { + features: MapGeoJSONFeature[]; + layer: DocumentLayerSpecification; +}; interface TooltipProps { - featureGroup: GeoJSON.Feature[][]; + featureGroup: FeatureGroupItem[]; onClose: () => void; showCloseButton?: boolean; showPagination?: boolean; @@ -28,17 +35,17 @@ function featureToTableRow(properties: Record) { return row; } -function toTable(features: GeoJSON.Feature[]) { +function toTable(featureGroupItem: FeatureGroupItem) { const table: TableData = []; - for (const feature of features) { + for (const feature of featureGroupItem.features) { if (feature?.properties) { table.push(featureToTableRow(feature.properties)); } } - return table; + return { table, layer: featureGroupItem.layer }; } -function createTableData(featureGroups: GeoJSON.Feature[][]) { +function createTableData(featureGroups: FeatureGroupItem[]) { return featureGroups.map(toTable); } @@ -49,10 +56,21 @@ export function TooltipContainer({ showPagination = true, showLayerSelection = true, }: TooltipProps) { - const [selectedLayer, setSelectedLayer] = useState(0); + const [selectedLayerIndexes, setSelectedLayerIndexes] = useState([0]); const tables = useMemo(() => createTableData(featureGroup), [featureGroup]); - const title = selectedLayer >= 0 ? `layer-${selectedLayer + 1}` : 'All layers'; + const title = useMemo(() => { + if (selectedLayerIndexes.includes(ALL_LAYERS)) { + return 'All layers'; + } + if (selectedLayerIndexes.length === 1) { + return tables[selectedLayerIndexes[0]].layer.name; + } + if (selectedLayerIndexes.length > 1) { + return `${tables[selectedLayerIndexes[0]].layer.name}, +${tables.length - 1}`; + } + return ''; + }, [selectedLayerIndexes, tables]); return ( @@ -69,7 +87,7 @@ export function TooltipContainer({ diff --git a/maps_dashboards/public/components/tooltip/tooltipTable.tsx b/maps_dashboards/public/components/tooltip/tooltipTable.tsx index 3a6a821c..06ed6f2a 100644 --- a/maps_dashboards/public/components/tooltip/tooltipTable.tsx +++ b/maps_dashboards/public/components/tooltip/tooltipTable.tsx @@ -13,7 +13,8 @@ import { EuiSpacer, EuiText, } from '@elastic/eui'; -import React, { useState, Fragment, useCallback } from 'react'; +import React, { useState, Fragment, useCallback, useEffect, useMemo } from 'react'; +import { DocumentLayerSpecification } from '../../model/mapLayerType'; export type RowData = { key: string; @@ -21,21 +22,28 @@ export type RowData = { }; export type PageData = RowData[]; export type TableData = PageData[]; +type Table = { table: TableData; layer: DocumentLayerSpecification }; export const ALL_LAYERS = -1; interface Props { - tables: TableData[]; - onLayerChange?: (layer: number) => void; + tables: Table[]; + onLayerChange?: (layerIndexes: number[]) => void; showPagination?: boolean; showLayerSelection?: boolean; } -function getLayerLabel(layerIndex: number) { - if (layerIndex >= 0) { - return `layer-${layerIndex + 1}`; +function mergeTables(tables: Table[], selectedIndex: number[]) { + const merged: TableData = []; + const allSelected = selectedIndex.includes(ALL_LAYERS); + + for (let i = 0; i < tables.length; i++) { + if (allSelected || selectedIndex.includes(i)) { + merged.push(...tables[i].table); + } } - return 'All layers'; + + return merged; } const TooltipTable = ({ @@ -44,8 +52,14 @@ const TooltipTable = ({ showPagination = true, showLayerSelection = true, }: Props) => { - const [selectedLayer, setSelectedLayer] = useState(0); - const [activePages, setActivePages] = useState>({}); + const [selectedLayers, setSelectedLayers] = useState[]>([ + { + label: tables[0]?.layer.name ?? '', + value: 0, + key: '0', + }, + ]); + const [activePage, setActivePage] = useState(0); const columns = [ { field: 'key', @@ -61,6 +75,11 @@ const TooltipTable = ({ }, ]; + useEffect(() => { + // When selected layer changed, reset the active page to the first page + setActivePage(0); + }, [selectedLayers]); + const getRowProps = (item) => { const { id } = item; return { @@ -69,35 +88,43 @@ const TooltipTable = ({ }; }; - const handleLayerChange = useCallback((data: EuiComboBoxOptionOption[]) => { - if (data.length > 0) { - const layer = data[0]?.value ?? 0; - setSelectedLayer(layer); + const handleLayerChange = useCallback( + (layerSelections: EuiComboBoxOptionOption[]) => { + if (tables.length === 0) { + return; + } + + let selections = layerSelections; + + // when cleared selections, automatically select the first layer: value = 0 + if (layerSelections.length === 0) { + selections = [{ label: tables[0]?.layer.name, value: 0, key: '0' }]; + } + + setSelectedLayers(selections); if (onLayerChange) { - onLayerChange(layer); + onLayerChange(selections.map((s) => s.value ?? 0)); } - } - }, []); - - const onSelectPage = useCallback( - (pageIndex) => { - setActivePages((state) => { - const newState = { ...state }; - newState[selectedLayer] = pageIndex; - return newState; - }); }, - [selectedLayer] + [tables] ); - const options = [{ label: 'All layers', value: ALL_LAYERS }]; - tables.forEach((_, i) => { - options.push({ label: `layer-${i + 1}`, value: i }); - }); + const options = useMemo(() => { + const layerOptions = [{ label: 'All layers', value: ALL_LAYERS, key: '-1' }]; + tables.forEach(({ layer }, i) => { + layerOptions.push({ label: layer.name, value: i, key: `${i}` }); + }); + return layerOptions; + }, [tables]); - const selectedOptions = [{ label: getLayerLabel(selectedLayer), value: selectedLayer }]; - const activePage = activePages[selectedLayer] ?? 0; - const tableItems = selectedLayer >= 0 ? tables[selectedLayer] : tables.flat(); + const tableItems = useMemo( + () => + mergeTables( + tables, + selectedLayers.map((l) => l.value ?? 0) + ), + [tables, selectedLayers] + ); const pageItems = tableItems[activePage]; const getCellProps = (item, column) => { @@ -128,10 +155,9 @@ const TooltipTable = ({ {showLayerSelection && ( - placeholder="Select a layer" - singleSelection={{ asPlainText: true }} - selectedOptions={selectedOptions} + selectedOptions={selectedLayers} options={options} onChange={handleLayerChange} /> @@ -143,7 +169,7 @@ const TooltipTable = ({ aria-label="Compressed pagination" pageCount={tableItems.length} activePage={activePage} - onPageClick={onSelectPage} + onPageClick={setActivePage} compressed /> ) : ( diff --git a/maps_dashboards/public/model/documentLayerFunctions.ts b/maps_dashboards/public/model/documentLayerFunctions.ts index 46ef67e9..548c6209 100644 --- a/maps_dashboards/public/model/documentLayerFunctions.ts +++ b/maps_dashboards/public/model/documentLayerFunctions.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Map as Maplibre } from 'maplibre-gl'; +import { Map as Maplibre, Popup, MapGeoJSONFeature } from 'maplibre-gl'; +import { createPopup, getPopupLngLat } from '../components/tooltip/create_tooltip'; import { DocumentLayerSpecification } from './mapLayerType'; interface MaplibreRef { @@ -338,6 +339,12 @@ const updateLayerConfig = ( } }; +let layerPopup: Popup | null = null; + +function getPopup() { + return layerPopup; +} + export const DocumentLayerFunctions = { render: (maplibreRef: MaplibreRef, layerConfig: DocumentLayerSpecification, data: any) => { if (layerExistInMbSource(layerConfig.id, maplibreRef)) { @@ -362,4 +369,24 @@ export const DocumentLayerFunctions = { } }); }, + addTooltip: (map: Maplibre, layerConfig: DocumentLayerSpecification) => { + map.on('mouseenter', layerConfig.id, (e) => { + getPopup()?.remove(); + map.getCanvas().style.cursor = 'pointer'; + if (e.features) { + layerPopup = createPopup({ + features: (e.features ?? []) as MapGeoJSONFeature[], + layers: [layerConfig], + showCloseButton: false, + showPagination: false, + showLayerSelection: false, + }); + layerPopup?.setLngLat(getPopupLngLat(e.features[0].geometry) ?? e.lngLat).addTo(map); + } + }); + map.on('mouseleave', layerConfig.id, () => { + getPopup()?.remove(); + map.getCanvas().style.cursor = ''; + }); + }, };