From b2b584f6113d55c23a2c447ec9b7eb80510faf4f Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Tue, 1 Feb 2022 18:17:39 -0800 Subject: [PATCH] Geomap: Improve tooltip UX and fix data links (#44740) --- packages/grafana-data/src/geo/layer.ts | 2 +- .../VizTooltip/VizTooltipContainer.tsx | 4 +- .../components/CloseButton/CloseButton.tsx | 7 +- .../app/plugins/panel/geomap/GeomapPanel.tsx | 73 ++++++---- .../plugins/panel/geomap/GeomapTooltip.tsx | 29 ++++ .../components/ComplexDataHoverView.tsx | 28 ++++ .../panel/geomap/components/DataHoverRow.tsx | 27 ++++ .../panel/geomap/components/DataHoverRows.tsx | 103 ++++++++++++++ .../panel/geomap/components/DataHoverTabs.tsx | 29 ++++ .../panel/geomap/components/DataHoverView.tsx | 132 ++++++++++-------- public/app/plugins/panel/geomap/event.ts | 16 +-- 11 files changed, 348 insertions(+), 102 deletions(-) create mode 100644 public/app/plugins/panel/geomap/GeomapTooltip.tsx create mode 100644 public/app/plugins/panel/geomap/components/ComplexDataHoverView.tsx create mode 100644 public/app/plugins/panel/geomap/components/DataHoverRow.tsx create mode 100644 public/app/plugins/panel/geomap/components/DataHoverRows.tsx create mode 100644 public/app/plugins/panel/geomap/components/DataHoverTabs.tsx diff --git a/packages/grafana-data/src/geo/layer.ts b/packages/grafana-data/src/geo/layer.ts index 080f0035a4d..c9ccce81d31 100644 --- a/packages/grafana-data/src/geo/layer.ts +++ b/packages/grafana-data/src/geo/layer.ts @@ -62,7 +62,7 @@ export interface MapLayerOptions { // Layer opacity (0-1) opacity?: number; - //Check tooltip + // Check tooltip tooltip?: boolean; } diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx index a27464f0c86..48dad2789eb 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx @@ -13,6 +13,7 @@ export interface VizTooltipContainerProps extends HTMLAttributes position: { x: number; y: number }; offset: { x: number; y: number }; children?: React.ReactNode; + allowPointerEvents?: boolean; } /** @@ -22,6 +23,7 @@ export const VizTooltipContainer: React.FC = ({ position: { x: positionX, y: positionY }, offset: { x: offsetX, y: offsetY }, children, + allowPointerEvents, className, ...otherProps }) => { @@ -90,7 +92,7 @@ export const VizTooltipContainer: React.FC = ({ left: 0, // disabling pointer-events is to prevent the tooltip from flickering when moving left to right // see e.g. https://github.com/grafana/grafana/pull/33609 - pointerEvents: 'none', + pointerEvents: allowPointerEvents ? 'auto' : 'none', top: 0, transform: `translate(${placement.x}px, ${placement.y}px)`, transition: 'transform ease-out 0.1s', diff --git a/public/app/core/components/CloseButton/CloseButton.tsx b/public/app/core/components/CloseButton/CloseButton.tsx index fc66c1a6c9a..d9cd78f1783 100644 --- a/public/app/core/components/CloseButton/CloseButton.tsx +++ b/public/app/core/components/CloseButton/CloseButton.tsx @@ -6,11 +6,14 @@ import { GrafanaTheme2 } from '@grafana/data'; type Props = { onClick: () => void; 'aria-label'?: string; + style?: React.CSSProperties; }; -export const CloseButton: React.FC = ({ onClick, 'aria-label': ariaLabel }) => { +export const CloseButton: React.FC = ({ onClick, 'aria-label': ariaLabel, style }) => { const styles = useStyles2(getStyles); - return ; + return ( + + ); }; const getStyles = (theme: GrafanaTheme2) => diff --git a/public/app/plugins/panel/geomap/GeomapPanel.tsx b/public/app/plugins/panel/geomap/GeomapPanel.tsx index eece27a87e1..244bfea89dd 100644 --- a/public/app/plugins/panel/geomap/GeomapPanel.tsx +++ b/public/app/plugins/panel/geomap/GeomapPanel.tsx @@ -23,17 +23,17 @@ import { centerPointRegistry, MapCenterID } from './view'; import { fromLonLat, toLonLat } from 'ol/proj'; import { Coordinate } from 'ol/coordinate'; import { css } from '@emotion/css'; -import { PanelContext, PanelContextRoot, Portal, stylesFactory, VizTooltipContainer } from '@grafana/ui'; +import { PanelContext, PanelContextRoot, stylesFactory } from '@grafana/ui'; import { GeomapOverlay, OverlayProps } from './GeomapOverlay'; import { DebugOverlay } from './components/DebugOverlay'; import { getGlobalStyles } from './globalStyles'; import { Global } from '@emotion/react'; -import { GeomapHoverFeature, GeomapHoverPayload } from './event'; -import { DataHoverView } from './components/DataHoverView'; +import { GeomapHoverPayload, GeomapLayerHover } from './event'; import { Subscription } from 'rxjs'; import { PanelEditExitedEvent } from 'app/types/events'; import { defaultMarkersConfig, MARKERS_LAYER_ID } from './layers/data/markersLayer'; import { cloneDeep } from 'lodash'; +import { GeomapTooltip } from './GeomapTooltip'; // Allows multiple panels to share the same view instance let sharedView: View | undefined = undefined; @@ -41,6 +41,7 @@ let sharedView: View | undefined = undefined; type Props = PanelProps; interface State extends OverlayProps { ttip?: GeomapHoverPayload; + ttipOpen: boolean; } export interface GeomapLayerActions { @@ -77,7 +78,7 @@ export class GeomapPanel extends Component { constructor(props: Props) { super(props); - this.state = {}; + this.state = { ttipOpen: false }; this.subs.add( this.props.eventBus.subscribe(PanelEditExitedEvent, (evt) => { if (this.mapDiv && this.props.id === evt.payload) { @@ -288,6 +289,7 @@ export class GeomapPanel extends Component { this.forceUpdate(); // first render // Tooltip listener + this.map.on('singleclick', this.pointerClickListener); this.map.on('pointermove', this.pointerMoveListener); this.map.getViewport().addEventListener('mouseout', (evt) => { this.props.eventBus.publish(new DataHoverClearEvent()); @@ -305,14 +307,26 @@ export class GeomapPanel extends Component { }; clearTooltip = () => { - if (this.state.ttip) { - this.setState({ ttip: undefined }); + if (this.state.ttip && !this.state.ttipOpen) { + this.tooltipPopupClosed(); + } + }; + + tooltipPopupClosed = () => { + this.setState({ ttipOpen: false, ttip: undefined }); + }; + + pointerClickListener = (evt: MapBrowserEvent) => { + if (this.pointerMoveListener(evt)) { + evt.preventDefault(); + evt.stopPropagation(); + this.setState({ ttipOpen: true }); } }; pointerMoveListener = (evt: MapBrowserEvent) => { - if (!this.map) { - return; + if (!this.map || this.state.ttipOpen) { + return false; } const mouse = evt.originalEvent as any; const pixel = this.map.getEventPixel(mouse); @@ -328,27 +342,38 @@ export class GeomapPanel extends Component { hoverPayload.data = undefined; hoverPayload.columnIndex = undefined; hoverPayload.rowIndex = undefined; - hoverPayload.feature = undefined; + hoverPayload.layers = undefined; + + const layers: GeomapLayerHover[] = []; + const layerLookup = new Map(); let ttip: GeomapHoverPayload = {} as GeomapHoverPayload; - const features: GeomapHoverFeature[] = []; this.map.forEachFeatureAtPixel( pixel, (feature, layer, geo) => { //match hover layer to layer in layers //check if the layer show tooltip is enabled //then also pass the list of tooltip fields if exists + //this is used as the generic hover event if (!hoverPayload.data) { const props = feature.getProperties(); const frame = props['frame']; if (frame) { hoverPayload.data = ttip.data = frame as DataFrame; hoverPayload.rowIndex = ttip.rowIndex = props['rowIndex']; - } else { - hoverPayload.feature = ttip.feature = feature; } } - features.push({ feature, layer, geo }); + + const s: MapLayerState = (layer as any).__state; + if (s) { + let h = layerLookup.get(s); + if (!h) { + h = { layer: s, features: [] }; + layerLookup.set(s, h); + layers.push(h); + } + h.features.push(feature); + } }, { layerFilter: (l) => { @@ -357,17 +382,11 @@ export class GeomapPanel extends Component { }, } ); - this.hoverPayload.features = features.length ? features : undefined; + this.hoverPayload.layers = layers.length ? layers : undefined; this.props.eventBus.publish(this.hoverEvent); - const currentTTip = this.state.ttip; - if ( - ttip.data !== currentTTip?.data || - ttip.rowIndex !== currentTTip?.rowIndex || - ttip.feature !== currentTTip?.feature - ) { - this.setState({ ttip: { ...hoverPayload } }); - } + this.setState({ ttip: { ...hoverPayload } }); + return layers.length ? true : false; }; private updateLayer = async (uid: string, newOptions: MapLayerOptions): Promise => { @@ -558,7 +577,7 @@ export class GeomapPanel extends Component { } render() { - const { ttip, topRight, bottomLeft } = this.state; + const { ttip, ttipOpen, topRight, bottomLeft } = this.state; return ( <> @@ -567,13 +586,7 @@ export class GeomapPanel extends Component {
- - {ttip && (ttip.data || ttip.feature) && ( - - - - )} - + ); } diff --git a/public/app/plugins/panel/geomap/GeomapTooltip.tsx b/public/app/plugins/panel/geomap/GeomapTooltip.tsx new file mode 100644 index 00000000000..652b9023424 --- /dev/null +++ b/public/app/plugins/panel/geomap/GeomapTooltip.tsx @@ -0,0 +1,29 @@ +import React, { createRef } from 'react'; +import { VizTooltipContainer } from '@grafana/ui'; +import { useOverlay } from '@react-aria/overlays'; + +import { ComplexDataHoverView } from './components/ComplexDataHoverView'; +import { GeomapHoverPayload } from './event'; + +interface Props { + ttip?: GeomapHoverPayload; + isOpen: boolean; + onClose: () => void; +} + +export const GeomapTooltip = ({ ttip, onClose, isOpen }: Props) => { + const ref = createRef(); + const { overlayProps } = useOverlay({ onClose, isDismissable: true, isOpen }, ref); + + return ( + <> + {ttip && ttip.layers && ( + +
+ +
+
+ )} + + ); +}; diff --git a/public/app/plugins/panel/geomap/components/ComplexDataHoverView.tsx b/public/app/plugins/panel/geomap/components/ComplexDataHoverView.tsx new file mode 100644 index 00000000000..75ea827ec2c --- /dev/null +++ b/public/app/plugins/panel/geomap/components/ComplexDataHoverView.tsx @@ -0,0 +1,28 @@ +import React, { useState } from 'react'; + +import { GeomapLayerHover } from '../event'; +import { DataHoverTabs } from './DataHoverTabs'; +import { DataHoverRows } from './DataHoverRows'; +import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; + +export interface Props { + layers?: GeomapLayerHover[]; + isOpen: boolean; + onClose: () => void; +} + +export const ComplexDataHoverView = ({ layers, onClose, isOpen }: Props) => { + const [activeTabIndex, setActiveTabIndex] = useState(0); + + if (!layers) { + return null; + } + + return ( + <> + {isOpen && } + + + + ); +}; diff --git a/public/app/plugins/panel/geomap/components/DataHoverRow.tsx b/public/app/plugins/panel/geomap/components/DataHoverRow.tsx new file mode 100644 index 00000000000..ebb1d8b8499 --- /dev/null +++ b/public/app/plugins/panel/geomap/components/DataHoverRow.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { FeatureLike } from 'ol/Feature'; +import { ArrayDataFrame, DataFrame } from '@grafana/data'; + +import { DataHoverView } from './DataHoverView'; + +type Props = { + feature?: FeatureLike; +}; + +export const DataHoverRow = ({ feature }: Props) => { + let data: DataFrame; + let rowIndex = 0; + if (!feature) { + return null; + } + + data = feature.get('frame'); + if (data) { + rowIndex = feature.get('rowIndex'); + } else { + const { geometry, ...properties } = feature.getProperties(); + data = new ArrayDataFrame([properties]); + } + + return ; +}; diff --git a/public/app/plugins/panel/geomap/components/DataHoverRows.tsx b/public/app/plugins/panel/geomap/components/DataHoverRows.tsx new file mode 100644 index 00000000000..0a234a392c4 --- /dev/null +++ b/public/app/plugins/panel/geomap/components/DataHoverRows.tsx @@ -0,0 +1,103 @@ +import React, { useState } from 'react'; +import { Collapse, TabContent, useStyles2 } from '@grafana/ui'; +import { DataFrame, FieldType, getFieldDisplayName, GrafanaTheme2 } from '@grafana/data'; +import { css } from '@emotion/css'; +import { FeatureLike } from 'ol/Feature'; + +import { GeomapLayerHover } from '../event'; +import { DataHoverRow } from './DataHoverRow'; +import { isString } from 'lodash'; + +type Props = { + layers: GeomapLayerHover[]; + activeTabIndex: number; +}; + +export const DataHoverRows = ({ layers, activeTabIndex }: Props) => { + const styles = useStyles2(getStyles); + const [rowMap, setRowMap] = useState(new Map()); + + const updateRowMap = (key: string | number, value: boolean) => { + setRowMap(new Map(rowMap.set(key, value))); + }; + + return ( + + {layers.map( + (geomapLayer, index) => + index === activeTabIndex && ( +
+
+ {geomapLayer.features.map((feature, idx) => { + const key = feature.getId() ?? idx; + const shouldDisplayCollapse = geomapLayer.features.length > 1; + + return shouldDisplayCollapse ? ( + { + updateRowMap(key, !rowMap.get(key)); + }} + className={styles.collapsibleRow} + > + + + ) : ( + + ); + })} +
+
+ ) + )} +
+ ); +}; + +export const generateLabel = (feature: FeatureLike, idx: number): string => { + const names = ['Name', 'name', 'Title', 'ID', 'id']; + let props = feature.getProperties(); + let first = ''; + const frame = feature.get('frame') as DataFrame; + if (frame) { + const rowIndex = feature.get('rowIndex'); + for (const f of frame.fields) { + if (f.type === FieldType.string) { + const k = getFieldDisplayName(f, frame); + if (!first) { + first = k; + } + props[k] = f.values.get(rowIndex); + } + } + } + + for (let k of names) { + const v = props[k]; + if (v) { + return v; + } + } + + if (first) { + return `${first}: ${props[first]}`; + } + + for (let k of Object.keys(props)) { + const v = props[k]; + if (isString(v)) { + return `${k}: ${v}`; + } + } + + return `Match: ${idx + 1}`; +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + collapsibleRow: css` + margin-bottom: 0px; + `, +}); diff --git a/public/app/plugins/panel/geomap/components/DataHoverTabs.tsx b/public/app/plugins/panel/geomap/components/DataHoverTabs.tsx new file mode 100644 index 00000000000..8ad828f2496 --- /dev/null +++ b/public/app/plugins/panel/geomap/components/DataHoverTabs.tsx @@ -0,0 +1,29 @@ +import React, { Dispatch, SetStateAction } from 'react'; +import { Tab, TabsBar } from '@grafana/ui'; + +import { GeomapLayerHover } from '../event'; + +type Props = { + layers?: GeomapLayerHover[]; + setActiveTabIndex: Dispatch>; + activeTabIndex: number; +}; + +export const DataHoverTabs = ({ layers, setActiveTabIndex, activeTabIndex }: Props) => { + return ( + + {layers && + layers.map((g, index) => ( + 1 ? g.features.length : null} + onChangeTab={() => { + setActiveTabIndex(index); + }} + /> + ))} + + ); +}; diff --git a/public/app/plugins/panel/geomap/components/DataHoverView.tsx b/public/app/plugins/panel/geomap/components/DataHoverView.tsx index cdc5f1efdcf..5f6977b3231 100644 --- a/public/app/plugins/panel/geomap/components/DataHoverView.tsx +++ b/public/app/plugins/panel/geomap/components/DataHoverView.tsx @@ -1,85 +1,97 @@ -import React, { PureComponent } from 'react'; -import { stylesFactory } from '@grafana/ui'; +import React from 'react'; +import { LinkButton, useStyles2, VerticalGroup } from '@grafana/ui'; import { - ArrayDataFrame, arrayUtils, DataFrame, Field, formattedValueToString, getFieldDisplayName, GrafanaTheme2, + LinkModel, } from '@grafana/data'; import { css } from '@emotion/css'; -import { config } from 'app/core/config'; -import { FeatureLike } from 'ol/Feature'; import { SortOrder } from '@grafana/schema'; export interface Props { data?: DataFrame; // source data - feature?: FeatureLike; rowIndex?: number | null; // the hover row columnIndex?: number | null; // the hover column sortOrder?: SortOrder; } -export class DataHoverView extends PureComponent { - style = getStyles(config.theme2); +export const DataHoverView = ({ data, rowIndex, columnIndex, sortOrder }: Props) => { + const styles = useStyles2(getStyles); - render() { - const { feature, columnIndex, sortOrder } = this.props; - let { data, rowIndex } = this.props; - if (feature) { - const { geometry, ...properties } = feature.getProperties(); - data = new ArrayDataFrame([properties]); - rowIndex = 0; - } - - if (!data || rowIndex == null) { - return null; - } - - const displayValues: Array<[string, any, string]> = []; - const visibleFields = data.fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.tooltip)); - - if (visibleFields.length === 0) { - return null; - } - for (let i = 0; i < visibleFields.length; i++) { - displayValues.push([ - getFieldDisplayName(visibleFields[i], data), - visibleFields[i].values.get(rowIndex!), - fmt(visibleFields[i], rowIndex), - ]); - } - - if (sortOrder && sortOrder !== SortOrder.None) { - displayValues.sort((a, b) => arrayUtils.sortValues(sortOrder)(a[1], b[1])); - } - - return ( - - - {displayValues.map((v, i) => ( - - - - - ))} - -
{v[0]}:{v[2]}
- ); + if (!data || rowIndex == null) { + return null; } -} -function fmt(field: Field, row: number): string { - const v = field.values.get(row); - if (field.display) { - return formattedValueToString(field.display(v)); + const visibleFields = data.fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.tooltip)); + + if (visibleFields.length === 0) { + return null; } - return `${v}`; -} -const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ + const displayValues: Array<[string, any, string]> = []; + const links: Array> = []; + const linkLookup = new Set(); + + for (const f of visibleFields) { + const v = f.values.get(rowIndex); + const disp = f.display ? f.display(v) : { text: `${v}`, numeric: +v }; + if (f.getLinks) { + f.getLinks({ calculatedValue: disp, valueRowIndex: rowIndex }).forEach((link) => { + const key = `${link.title}/${link.href}`; + if (!linkLookup.has(key)) { + links.push(link); + linkLookup.add(key); + } + }); + } + + displayValues.push([getFieldDisplayName(f, data), v, formattedValueToString(disp)]); + } + + if (sortOrder && sortOrder !== SortOrder.None) { + displayValues.sort((a, b) => arrayUtils.sortValues(sortOrder)(a[1], b[1])); + } + + return ( + + + {displayValues.map((v, i) => ( + + + + + ))} + {links.length > 0 && ( + + + + )} + +
{v[0]}:{v[2]}
+ + {links.map((link, i) => ( + + {link.title} + + ))} + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ infoWrap: css` padding: 8px; th { @@ -90,4 +102,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ highlight: css` background: ${theme.colors.action.hover}; `, -})); +}); diff --git a/public/app/plugins/panel/geomap/event.ts b/public/app/plugins/panel/geomap/event.ts index 6cf30ac87fb..c93478d30fc 100644 --- a/public/app/plugins/panel/geomap/event.ts +++ b/public/app/plugins/panel/geomap/event.ts @@ -1,17 +1,17 @@ import { FeatureLike } from 'ol/Feature'; -import { SimpleGeometry } from 'ol/geom'; import { DataHoverPayload } from '@grafana/data'; -import BaseLayer from 'ol/layer/Base'; +import { MapLayerState } from './types'; -export interface GeomapHoverFeature { - feature: FeatureLike; - layer: BaseLayer; - geo: SimpleGeometry; +export interface GeomapLayerHover { + layer: MapLayerState; + features: FeatureLike[]; } export interface GeomapHoverPayload extends DataHoverPayload { - features?: GeomapHoverFeature[]; - feature?: FeatureLike; + // List of layers + layers?: GeomapLayerHover[]; + + // Global mouse coordinates for the hover layer pageX: number; pageY: number; }