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(); - } - }); - } - }); - }, };