Skip to content

Commit

Permalink
feat: added multi-layer support to map popup
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
ruanyl committed Dec 23, 2022
1 parent 04759d1 commit 644aae2
Show file tree
Hide file tree
Showing 7 changed files with 321 additions and 164 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,25 @@

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 { IndexPattern } from '../../../../../src/plugins/data/public';
import { MapState } from '../../model/mapState';
import {
createPopup,
getPopupLngLat,
groupFeaturesByLayers,
isTooltipEnabledLayer,
} from '../tooltip/create_tooltip';

interface MapContainerProps {
setLayers: (layers: MapLayerSpecification[]) => void;
Expand Down Expand Up @@ -60,6 +72,73 @@ 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) {
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 (
<div>
<EuiPanel hasShadow={false} hasBorder={false} color="transparent" className="zoombar">
Expand Down
80 changes: 80 additions & 0 deletions maps_dashboards/public/components/tooltip/create_tooltip.tsx
Original file line number Diff line number Diff line change
@@ -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<MapLayerSpecification, OSMLayerSpecification> {
return layer.type !== 'opensearch_vector_tile_map' && layer.source.showTooltips === true;
}

export function groupFeaturesByLayers(
features: MapGeoJSONFeature[],
layers: Exclude<MapLayerSpecification, OSMLayerSpecification>[]
) {
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(
<TooltipContainer
featureGroup={featureGroup}
onClose={popup.remove}
showCloseButton={showCloseButton}
showPagination={showPagination}
showLayerSelection={showLayerSelection}
/>,
div
);

return popup.setDOMContent(div);
}
87 changes: 59 additions & 28 deletions maps_dashboards/public/components/tooltip/tooltipContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>) {
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 (
<EuiPanel paddingSize={'s'} grow={true}>
<EuiPanel style={{ width: 350 }} paddingSize={'s'} grow={true}>
<EuiText className={'eui-textTruncate'} grow={true}>
<EuiFlexGroup responsive={false} direction="column" gutterSize="none">
<EuiFlexItem grow={false}>
<TooltipHeaderContent title={title} isClickEvent={isClickEvent} onClose={onClose} />
<TooltipHeaderContent
title={title}
onClose={onClose}
showCloseButton={showCloseButton}
/>
<EuiHorizontalRule margin="xs" />
</EuiFlexItem>
<EuiFlexItem grow={true}>
<TooltipTable pages={toTableRows()} isClickEvent={isClickEvent} />
<TooltipTable
tables={tables}
onLayerChange={setSelectedLayer}
showPagination={showPagination}
showLayerSelection={showLayerSelection}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiText>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import React from 'react';

interface Props {
title: string;
isClickEvent: boolean;
showCloseButton: boolean;
onClose: Function;
}

Expand All @@ -23,7 +23,7 @@ const TooltipHeaderContent = (props: Props) => {
</h4>
</EuiTextColor>
</EuiFlexItem>
{props.isClickEvent && (
{props.showCloseButton && (
<EuiFlexItem key="closeButton" grow={false}>
<EuiButtonIcon
onClick={() => {
Expand Down
Loading

0 comments on commit 644aae2

Please sign in to comment.