From 7f1c7415f478a0f2a05c05596f059a0bd57d97bb Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Thu, 22 Dec 2022 16:20:09 +0800 Subject: [PATCH] feat: added multi-layer support to map popup refactor map popup rendering logic, instead of adding event listener to each layer for popup, this commit changed to add an event listener globally and then query features of the current position Signed-off-by: Yulong Ruan --- .../layer_control_panel.tsx | 2 +- .../map_container/map_container.tsx | 81 ++++++++++- .../components/tooltip/create_tooltip.tsx | 80 +++++++++++ .../components/tooltip/tooltipContainer.tsx | 87 ++++++++---- .../tooltip/tooltipHeaderContent.tsx | 4 +- .../components/tooltip/tooltipTable.tsx | 129 +++++++++++++----- .../public/model/documentLayerFunctions.ts | 102 +------------- 7 files changed, 321 insertions(+), 164 deletions(-) create mode 100644 maps_dashboards/public/components/tooltip/create_tooltip.tsx diff --git a/maps_dashboards/public/components/layer_control_panel/layer_control_panel.tsx b/maps_dashboards/public/components/layer_control_panel/layer_control_panel.tsx index c73963c0..c6866700 100644 --- a/maps_dashboards/public/components/layer_control_panel/layer_control_panel.tsx +++ b/maps_dashboards/public/components/layer_control_panel/layer_control_panel.tsx @@ -104,7 +104,7 @@ const LayerControlPanel = memo(({ maplibreRef, setLayers, layers }: Props) => { 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/components/map_container/map_container.tsx b/maps_dashboards/public/components/map_container/map_container.tsx index 8bb88570..d066e01a 100644 --- a/maps_dashboards/public/components/map_container/map_container.tsx +++ b/maps_dashboards/public/components/map_container/map_container.tsx @@ -5,11 +5,23 @@ import React, { useEffect, useRef, useState } from 'react'; import { EuiPanel } from '@elastic/eui'; -import { Map as Maplibre, NavigationControl } from 'maplibre-gl'; +import { + Map as Maplibre, + MapLayerMouseEvent, + 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 { + createPopup, + getPopupLngLat, + groupFeaturesByLayers, + isTooltipEnabledLayer, +} from '../tooltip/create_tooltip'; interface MapContainerProps { mapIdFromUrl: string; @@ -49,6 +61,73 @@ export const MapContainer = ({ mapIdFromUrl, setLayers, layers }: MapContainerPr }); }, []); + 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) { + const featureGroup = groupFeaturesByLayers(features, tooltipEnabledLayers); + clickPopup = createPopup({ featureGroup }); + 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); + }); + } + + 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]); + 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..b1fb31f3 --- /dev/null +++ b/maps_dashboards/public/components/tooltip/create_tooltip.tsx @@ -0,0 +1,80 @@ +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'; + +type Options = { + featureGroup: GeoJSON.Feature[][]; + showCloseButton?: boolean; + showPagination?: boolean; + showLayerSelection?: boolean; +}; + +export function isTooltipEnabledLayer( + layer: MapLayerSpecification +): layer is Exclude { + return layer.type !== 'opensearch_vector_tile_map' && layer.source.showTooltips === true; +} + +export function groupFeaturesByLayers( + features: MapGeoJSONFeature[], + layers: Exclude[] +) { + const featureGroups: MapGeoJSONFeature[][] = []; + if (layers.length > 0) { + layers.forEach((l) => { + const layerFeatures = features.filter((f) => f.layer.source === l.id); + if (layerFeatures.length > 0) { + featureGroups.push(layerFeatures); + } + }); + } else { + featureGroups.push(features); + } + 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({ + featureGroup, + showCloseButton = true, + showPagination = true, + showLayerSelection = true, +}: Options) { + const popup = new Popup({ + closeButton: false, + closeOnClick: false, + maxWidth: 'max-content', + }); + + // 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..d9898557 100644 --- a/maps_dashboards/public/components/tooltip/tooltipContainer.tsx +++ b/maps_dashboards/public/components/tooltip/tooltipContainer.tsx @@ -3,45 +3,76 @@ * 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)); - } - return rows; - }; - const featureToTableRow = (properties) => { - const rows: any[] = []; - for (const [k, v] of Object.entries(properties)) { - rows.push({ - key: k, - value: `${v}`, - }); +import { PageData, TableData, TooltipTable } from './tooltipTable'; + +interface TooltipProps { + featureGroup: GeoJSON.Feature[][]; + 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(features: GeoJSON.Feature[]) { + const table: TableData = []; + for (const feature of features) { + if (feature?.properties) { + table.push(featureToTableRow(feature.properties)); } - return rows; - }; + } + return table; +} + +function createTableData(featureGroups: GeoJSON.Feature[][]) { + return featureGroups.map(toTable); +} + +export function TooltipContainer({ + featureGroup, + onClose, + showCloseButton = true, + showPagination = true, + showLayerSelection = true, +}: TooltipProps) { + const [selectedLayer, setSelectedLayer] = useState(0); + const tables = useMemo(() => createTableData(featureGroup), [featureGroup]); + + const title = selectedLayer >= 0 ? `layer-${selectedLayer + 1}` : 'All layers'; + 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..3a6a821c 100644 --- a/maps_dashboards/public/components/tooltip/tooltipTable.tsx +++ b/maps_dashboards/public/components/tooltip/tooltipTable.tsx @@ -3,16 +3,49 @@ * 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 } from 'react'; + +export type RowData = { + key: string; + value: string; +}; +export type PageData = RowData[]; +export type TableData = PageData[]; + +export const ALL_LAYERS = -1; interface Props { - pages: any[]; - isClickEvent: boolean; + tables: TableData[]; + onLayerChange?: (layer: number) => void; + showPagination?: boolean; + showLayerSelection?: boolean; +} + +function getLayerLabel(layerIndex: number) { + if (layerIndex >= 0) { + return `layer-${layerIndex + 1}`; + } + return 'All layers'; } -const TooltipTable = (props: Props) => { - const [activePage, setActivePage] = useState(0); +const TooltipTable = ({ + tables, + onLayerChange, + showPagination = true, + showLayerSelection = true, +}: Props) => { + const [selectedLayer, setSelectedLayer] = useState(0); + const [activePages, setActivePages] = useState>({}); const columns = [ { field: 'key', @@ -36,6 +69,37 @@ const TooltipTable = (props: Props) => { }; }; + const handleLayerChange = useCallback((data: EuiComboBoxOptionOption[]) => { + if (data.length > 0) { + const layer = data[0]?.value ?? 0; + setSelectedLayer(layer); + if (onLayerChange) { + onLayerChange(layer); + } + } + }, []); + + const onSelectPage = useCallback( + (pageIndex) => { + setActivePages((state) => { + const newState = { ...state }; + newState[selectedLayer] = pageIndex; + return newState; + }); + }, + [selectedLayer] + ); + + const options = [{ label: 'All layers', value: ALL_LAYERS }]; + tables.forEach((_, i) => { + options.push({ label: `layer-${i + 1}`, value: i }); + }); + + const selectedOptions = [{ label: getLayerLabel(selectedLayer), value: selectedLayer }]; + const activePage = activePages[selectedLayer] ?? 0; + const tableItems = selectedLayer >= 0 ? tables[selectedLayer] : tables.flat(); + const pageItems = tableItems[activePage]; + const getCellProps = (item, column) => { const { id } = item; const { field } = column; @@ -45,33 +109,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 && ( + + + + )} - {props.isClickEvent - ? buildPagination(props.pages.length) - : buildMessage(props.pages.length)} + {showPagination ? ( + + ) : ( + + {1} of {tableItems.length} + + )} diff --git a/maps_dashboards/public/model/documentLayerFunctions.ts b/maps_dashboards/public/model/documentLayerFunctions.ts index c7d74b66..46ef67e9 100644 --- a/maps_dashboards/public/model/documentLayerFunctions.ts +++ b/maps_dashboards/public/model/documentLayerFunctions.ts @@ -3,11 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Map as Maplibre, Popup, LngLatLike } from 'maplibre-gl'; -import ReactDOM from 'react-dom'; +import { Map as Maplibre } from 'maplibre-gl'; import { DocumentLayerSpecification } from './mapLayerType'; -import { TooltipContainer } from '../components/tooltip/tooltipContainer'; -import { LAYER_VISIBILITY } from '../../common'; interface MaplibreRef { current: Maplibre | null; @@ -341,53 +338,6 @@ 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(); - - // 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; -}; - export const DocumentLayerFunctions = { render: (maplibreRef: MaplibreRef, layerConfig: DocumentLayerSpecification, data: any) => { if (layerExistInMbSource(layerConfig.id, maplibreRef)) { @@ -412,54 +362,4 @@ 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(); - } - }); - } - }); - }, };