From 254a25256802bd58aa7bfc2e2027fa2078323b76 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Fri, 23 Dec 2022 20:38:08 +0200 Subject: [PATCH] feat: added multi-layer support to map popup (#140) * feat: added multi-layer support to map popup 1. Abstract a reusable popup rendering logic. 2. For click-popups which need to display multi-layers information, added an event listener globally and then query features from all layers at the current position. 3. For hover-popups which show information of the current layer features, moved the popup rendering logic out from the layer render logic(in the layer control panel component). Signed-off-by: Yulong Ruan --- .../map_container/map_container.tsx | 37 ++++- .../components/tooltip/create_tooltip.tsx | 82 +++++++++ .../components/tooltip/tooltipContainer.tsx | 103 +++++++++--- .../tooltip/tooltipHeaderContent.tsx | 4 +- .../components/tooltip/tooltipTable.tsx | 155 ++++++++++++++---- .../public/model/DataLayerController.ts | 2 +- .../public/model/documentLayerFunctions.ts | 117 +++---------- 7 files changed, 343 insertions(+), 157 deletions(-) create mode 100644 maps_dashboards/public/components/tooltip/create_tooltip.tsx diff --git a/maps_dashboards/public/components/map_container/map_container.tsx b/maps_dashboards/public/components/map_container/map_container.tsx index 22f43a09..b43e300f 100644 --- a/maps_dashboards/public/components/map_container/map_container.tsx +++ b/maps_dashboards/public/components/map_container/map_container.tsx @@ -5,13 +5,15 @@ import React, { useEffect, useRef, useState } from 'react'; import { EuiPanel } from '@elastic/eui'; -import { Map as Maplibre, NavigationControl } 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, isTooltipEnabledLayer } from '../tooltip/create_tooltip'; +import { DocumentLayerFunctions } from '../../model/documentLayerFunctions'; interface MapContainerProps { setLayers: (layers: MapLayerSpecification[]) => void; @@ -60,6 +62,39 @@ export const MapContainer = ({ }); }, []); + useEffect(() => { + let clickPopup: Popup | null = null; + + // We don't want to show layer information in the popup for the map tile layer + const tooltipEnabledLayers = layers.filter(isTooltipEnabledLayer); + + function onClickMap(e: MapMouseEvent) { + // remove previous popup + clickPopup?.remove(); + + const features = maplibreRef.current?.queryRenderedFeatures(e.point); + if (features && maplibreRef.current) { + clickPopup = createPopup({ features, layers: tooltipEnabledLayers }); + clickPopup + ?.setLngLat(getPopupLngLat(features[0].geometry) ?? e.lngLat) + .addTo(maplibreRef.current); + } + } + + if (maplibreRef.current) { + maplibreRef.current.on('click', onClickMap); + for (const layer of tooltipEnabledLayers) { + DocumentLayerFunctions.addTooltip(maplibreRef.current, layer); + } + } + + return () => { + if (maplibreRef.current) { + maplibreRef.current.off('click', onClickMap); + } + }; + }, [layers]); + return (
diff --git a/maps_dashboards/public/components/tooltip/create_tooltip.tsx b/maps_dashboards/public/components/tooltip/create_tooltip.tsx new file mode 100644 index 00000000..e77bd0ca --- /dev/null +++ b/maps_dashboards/public/components/tooltip/create_tooltip.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Popup, MapGeoJSONFeature } from 'maplibre-gl'; + +import { MapLayerSpecification, DocumentLayerSpecification } from '../../model/mapLayerType'; +import { FeatureGroupItem, TooltipContainer } from './tooltipContainer'; + +type Options = { + features: MapGeoJSONFeature[]; + layers: DocumentLayerSpecification[]; + showCloseButton?: boolean; + showPagination?: boolean; + showLayerSelection?: boolean; +}; + +export function isTooltipEnabledLayer( + layer: MapLayerSpecification +): layer is DocumentLayerSpecification { + return layer.type !== 'opensearch_vector_tile_map' && layer.source.showTooltips === true; +} + +export function groupFeaturesByLayers( + features: MapGeoJSONFeature[], + layers: DocumentLayerSpecification[] +) { + const featureGroups: FeatureGroupItem[] = []; + if (layers.length > 0) { + layers.forEach((layer) => { + const layerFeatures = features.filter((f) => f.layer.source === layer.id); + if (layerFeatures.length > 0) { + featureGroups.push({ features: layerFeatures, layer }); + } + }); + } + return featureGroups; +} + +export function getPopupLngLat(geometry: GeoJSON.Geometry) { + // geometry.coordinates is different for different geometry.type, here we use the geometry.coordinates + // of a Point as the position of the popup. For other types, such as Polygon, MultiPolygon, etc, + // use mouse position should be better + if (geometry.type === 'Point') { + return [geometry.coordinates[0], geometry.coordinates[1]] as [number, number]; + } else { + return null; + } +} + +export function createPopup({ + features, + layers, + showCloseButton = true, + showPagination = true, + showLayerSelection = true, +}: Options) { + const popup = new Popup({ + closeButton: false, + closeOnClick: false, + maxWidth: 'max-content', + }); + + const featureGroup = groupFeaturesByLayers(features, layers); + + // Don't show popup if no feature + if (featureGroup.length === 0) { + return null; + } + + const div = document.createElement('div'); + ReactDOM.render( + , + div + ); + + return popup.setDOMContent(div); +} diff --git a/maps_dashboards/public/components/tooltip/tooltipContainer.tsx b/maps_dashboards/public/components/tooltip/tooltipContainer.tsx index 5d883ed2..d985f051 100644 --- a/maps_dashboards/public/components/tooltip/tooltipContainer.tsx +++ b/maps_dashboards/public/components/tooltip/tooltipContainer.tsx @@ -3,45 +3,94 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { EuiFlexItem, EuiFlexGroup, EuiPanel, EuiText, EuiHorizontalRule } from '@elastic/eui'; import { TooltipHeaderContent } from './tooltipHeaderContent'; -import { TooltipTable } from './tooltipTable'; - -export function TooltipContainer( - title: string, - features: any[], - isClickEvent: boolean, - onClose: Function -) { - const toTableRows = () => { - const rows: any[] = []; - for (const feature of features) { - rows.push(featureToTableRow(feature?.properties)); +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: FeatureGroupItem[]; + onClose: () => void; + showCloseButton?: boolean; + showPagination?: boolean; + showLayerSelection?: boolean; +} + +function featureToTableRow(properties: Record) { + const row: PageData = []; + for (const [k, v] of Object.entries(properties)) { + row.push({ + key: k, + value: `${v}`, + }); + } + return row; +} + +function toTable(featureGroupItem: FeatureGroupItem) { + const table: TableData = []; + for (const feature of featureGroupItem.features) { + if (feature?.properties) { + table.push(featureToTableRow(feature.properties)); + } + } + return { table, layer: featureGroupItem.layer }; +} + +function createTableData(featureGroups: FeatureGroupItem[]) { + return featureGroups.map(toTable); +} + +export function TooltipContainer({ + featureGroup, + onClose, + showCloseButton = true, + showPagination = true, + showLayerSelection = true, +}: TooltipProps) { + const [selectedLayerIndexes, setSelectedLayerIndexes] = useState([0]); + const tables = useMemo(() => createTableData(featureGroup), [featureGroup]); + + const title = useMemo(() => { + if (selectedLayerIndexes.includes(ALL_LAYERS)) { + return 'All layers'; + } + if (selectedLayerIndexes.length === 1) { + return tables[selectedLayerIndexes[0]].layer.name; } - return rows; - }; - const featureToTableRow = (properties) => { - const rows: any[] = []; - for (const [k, v] of Object.entries(properties)) { - rows.push({ - key: k, - value: `${v}`, - }); + if (selectedLayerIndexes.length > 1) { + return `${tables[selectedLayerIndexes[0]].layer.name}, +${tables.length - 1}`; } - return rows; - }; + return ''; + }, [selectedLayerIndexes, tables]); + return ( - + - + - + diff --git a/maps_dashboards/public/components/tooltip/tooltipHeaderContent.tsx b/maps_dashboards/public/components/tooltip/tooltipHeaderContent.tsx index 9d97993b..7373b38f 100644 --- a/maps_dashboards/public/components/tooltip/tooltipHeaderContent.tsx +++ b/maps_dashboards/public/components/tooltip/tooltipHeaderContent.tsx @@ -9,7 +9,7 @@ import React from 'react'; interface Props { title: string; - isClickEvent: boolean; + showCloseButton: boolean; onClose: Function; } @@ -23,7 +23,7 @@ const TooltipHeaderContent = (props: Props) => { - {props.isClickEvent && ( + {props.showCloseButton && ( { diff --git a/maps_dashboards/public/components/tooltip/tooltipTable.tsx b/maps_dashboards/public/components/tooltip/tooltipTable.tsx index 1a82ae63..06ed6f2a 100644 --- a/maps_dashboards/public/components/tooltip/tooltipTable.tsx +++ b/maps_dashboards/public/components/tooltip/tooltipTable.tsx @@ -3,16 +3,63 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { EuiBasicTable, EuiFlexGroup, EuiFlexItem, EuiPagination, EuiText } from '@elastic/eui'; -import React, { useState, Fragment } from 'react'; +import { + EuiBasicTable, + EuiComboBox, + EuiComboBoxOptionOption, + EuiFlexGroup, + EuiFlexItem, + EuiPagination, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import React, { useState, Fragment, useCallback, useEffect, useMemo } from 'react'; +import { DocumentLayerSpecification } from '../../model/mapLayerType'; + +export type RowData = { + key: string; + value: string; +}; +export type PageData = RowData[]; +export type TableData = PageData[]; +type Table = { table: TableData; layer: DocumentLayerSpecification }; + +export const ALL_LAYERS = -1; interface Props { - pages: any[]; - isClickEvent: boolean; + tables: Table[]; + onLayerChange?: (layerIndexes: number[]) => void; + showPagination?: boolean; + showLayerSelection?: boolean; +} + +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 merged; } -const TooltipTable = (props: Props) => { - const [activePage, setActivePage] = useState(0); +const TooltipTable = ({ + tables, + onLayerChange, + showPagination = true, + showLayerSelection = true, +}: Props) => { + const [selectedLayers, setSelectedLayers] = useState[]>([ + { + label: tables[0]?.layer.name ?? '', + value: 0, + key: '0', + }, + ]); + const [activePage, setActivePage] = useState(0); const columns = [ { field: 'key', @@ -28,6 +75,11 @@ const TooltipTable = (props: Props) => { }, ]; + useEffect(() => { + // When selected layer changed, reset the active page to the first page + setActivePage(0); + }, [selectedLayers]); + const getRowProps = (item) => { const { id } = item; return { @@ -36,6 +88,45 @@ const TooltipTable = (props: Props) => { }; }; + 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(selections.map((s) => s.value ?? 0)); + } + }, + [tables] + ); + + 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 tableItems = useMemo( + () => + mergeTables( + tables, + selectedLayers.map((l) => l.value ?? 0) + ), + [tables, selectedLayers] + ); + const pageItems = tableItems[activePage]; + const getCellProps = (item, column) => { const { id } = item; const { field } = column; @@ -45,33 +136,14 @@ const TooltipTable = (props: Props) => { textOnly: true, }; }; - const buildMessage = (count: number) => { - return ( - - {1} of {count} - - ); - }; - - const buildPagination = (count: number) => { - return ( - setActivePage(pageIndex)} - compressed - /> - ); - }; return ( - + { /> - + + + {showLayerSelection && ( + + + placeholder="Select a layer" + selectedOptions={selectedLayers} + options={options} + onChange={handleLayerChange} + /> + + )} - {props.isClickEvent - ? buildPagination(props.pages.length) - : buildMessage(props.pages.length)} + {showPagination ? ( + + ) : ( + + {1} of {tableItems.length} + + )} diff --git a/maps_dashboards/public/model/DataLayerController.ts b/maps_dashboards/public/model/DataLayerController.ts index 34aeec84..0a511f70 100644 --- a/maps_dashboards/public/model/DataLayerController.ts +++ b/maps_dashboards/public/model/DataLayerController.ts @@ -63,7 +63,7 @@ export const doDataLayerRender = async ( if (isCompleteResponse(response)) { const dataSource = response.rawResponse.hits.hits; layersFunctionMap[layer.type].render(maplibreRef, layer, dataSource); - layersFunctionMap[layer.type].addTooltip(maplibreRef, layer); + // layersFunctionMap[layer.type].addTooltip(maplibreRef, layer); search$.unsubscribe(); } else { notifications.toasts.addWarning('An error has occurred when query dataSource'); diff --git a/maps_dashboards/public/model/documentLayerFunctions.ts b/maps_dashboards/public/model/documentLayerFunctions.ts index c7d74b66..548c6209 100644 --- a/maps_dashboards/public/model/documentLayerFunctions.ts +++ b/maps_dashboards/public/model/documentLayerFunctions.ts @@ -3,11 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Map as Maplibre, Popup, LngLatLike } from 'maplibre-gl'; -import ReactDOM from 'react-dom'; +import { Map as Maplibre, Popup, MapGeoJSONFeature } from 'maplibre-gl'; +import { createPopup, getPopupLngLat } from '../components/tooltip/create_tooltip'; import { DocumentLayerSpecification } from './mapLayerType'; -import { TooltipContainer } from '../components/tooltip/tooltipContainer'; -import { LAYER_VISIBILITY } from '../../common'; interface MaplibreRef { current: Maplibre | null; @@ -341,52 +339,11 @@ const updateLayerConfig = ( } }; -const clickPopup = new Popup({ - closeButton: false, - closeOnClick: false, - maxWidth: 'max-content', -}); -const getClickPopup = () => { - return clickPopup; -}; - -const hoverPopup = new Popup({ - closeButton: false, - closeOnClick: false, - maxWidth: 'max-content', -}); -export const getHoverPopup = () => { - return hoverPopup; -}; - -export const buildPopup = ( - popup: Popup, - features: any[], - title: string, - coordinates: LngLatLike, - isClickEvent: boolean -) => { - const div = document.createElement('div'); - ReactDOM.render( - TooltipContainer(title, features, isClickEvent, () => { - getClickPopup().remove(); - }), - div - ); - return popup.setDOMContent(div).setLngLat(coordinates); -}; - -const getCoordinates = (e: any): any => { - const coordinates = e.features[0].geometry.coordinates.slice(); +let layerPopup: Popup | null = null; - // Ensure that if the map is zoomed out such that multiple - // copies of the feature are visible, the popup appears - // over the copy being pointed to. - while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { - coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; - } - return coordinates; -}; +function getPopup() { + return layerPopup; +} export const DocumentLayerFunctions = { render: (maplibreRef: MaplibreRef, layerConfig: DocumentLayerSpecification, data: any) => { @@ -412,54 +369,24 @@ export const DocumentLayerFunctions = { } }); }, - addTooltip: (maplibreRef: MaplibreRef, layerConfig: DocumentLayerSpecification) => { - const layers = getCurrentStyleLayers(maplibreRef); - layers.forEach((layer) => { - if (layer.id.includes(layerConfig.id)) { - maplibreRef.current?.on('click', layer.id, (e: any) => { - if ( - maplibreRef.current && - layerConfig.visibility === LAYER_VISIBILITY.VISIBLE && - layerConfig.source.showTooltips === true - ) { - // remove click pop up if previously opened click popup is not closed by user. - getClickPopup()?.remove(); - if (maplibreRef.current) { - buildPopup( - getClickPopup(), - e.features, - layerConfig.name, - getCoordinates(e), - true - ).addTo(maplibreRef.current); - } - } - }); - // on hover - maplibreRef.current?.on('mouseenter', layer.id, (e: any) => { - if ( - maplibreRef.current && - layerConfig.visibility === LAYER_VISIBILITY.VISIBLE && - layerConfig.source.showTooltips - ) { - maplibreRef.current.getCanvas().style.cursor = 'pointer'; - buildPopup( - getHoverPopup(), - e.features, - layerConfig.name, - getCoordinates(e), - false - ).addTo(maplibreRef.current); - } - }); - // on leave - maplibreRef.current?.on('mouseleave', layer.id, function () { - if (maplibreRef.current) { - maplibreRef.current.getCanvas().style.cursor = ''; - getHoverPopup()?.remove(); - } + 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 = ''; + }); }, };