Geomap: Improve tooltip UX and fix data links (#44740)

This commit is contained in:
Nathan Marrs 2022-02-01 18:17:39 -08:00 committed by GitHub
parent 04d93751b8
commit b2b584f611
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 348 additions and 102 deletions

View File

@ -62,7 +62,7 @@ export interface MapLayerOptions<TConfig = any> {
// Layer opacity (0-1)
opacity?: number;
//Check tooltip
// Check tooltip
tooltip?: boolean;
}

View File

@ -13,6 +13,7 @@ export interface VizTooltipContainerProps extends HTMLAttributes<HTMLDivElement>
position: { x: number; y: number };
offset: { x: number; y: number };
children?: React.ReactNode;
allowPointerEvents?: boolean;
}
/**
@ -22,6 +23,7 @@ export const VizTooltipContainer: React.FC<VizTooltipContainerProps> = ({
position: { x: positionX, y: positionY },
offset: { x: offsetX, y: offsetY },
children,
allowPointerEvents,
className,
...otherProps
}) => {
@ -90,7 +92,7 @@ export const VizTooltipContainer: React.FC<VizTooltipContainerProps> = ({
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',

View File

@ -6,11 +6,14 @@ import { GrafanaTheme2 } from '@grafana/data';
type Props = {
onClick: () => void;
'aria-label'?: string;
style?: React.CSSProperties;
};
export const CloseButton: React.FC<Props> = ({ onClick, 'aria-label': ariaLabel }) => {
export const CloseButton: React.FC<Props> = ({ onClick, 'aria-label': ariaLabel, style }) => {
const styles = useStyles2(getStyles);
return <IconButton aria-label={ariaLabel ?? 'Close'} className={styles} name="times" onClick={onClick} />;
return (
<IconButton aria-label={ariaLabel ?? 'Close'} className={styles} name="times" onClick={onClick} style={style} />
);
};
const getStyles = (theme: GrafanaTheme2) =>

View File

@ -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<GeomapPanelOptions>;
interface State extends OverlayProps {
ttip?: GeomapHoverPayload;
ttipOpen: boolean;
}
export interface GeomapLayerActions {
@ -77,7 +78,7 @@ export class GeomapPanel extends Component<Props, State> {
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<Props, State> {
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<Props, State> {
};
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<UIEvent>) => {
if (this.pointerMoveListener(evt)) {
evt.preventDefault();
evt.stopPropagation();
this.setState({ ttipOpen: true });
}
};
pointerMoveListener = (evt: MapBrowserEvent<UIEvent>) => {
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<Props, State> {
hoverPayload.data = undefined;
hoverPayload.columnIndex = undefined;
hoverPayload.rowIndex = undefined;
hoverPayload.feature = undefined;
hoverPayload.layers = undefined;
const layers: GeomapLayerHover[] = [];
const layerLookup = new Map<MapLayerState, GeomapLayerHover>();
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<Props, State> {
},
}
);
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<boolean> => {
@ -558,7 +577,7 @@ export class GeomapPanel extends Component<Props, State> {
}
render() {
const { ttip, topRight, bottomLeft } = this.state;
const { ttip, ttipOpen, topRight, bottomLeft } = this.state;
return (
<>
@ -567,13 +586,7 @@ export class GeomapPanel extends Component<Props, State> {
<div className={this.style.map} ref={this.initMapRef}></div>
<GeomapOverlay bottomLeft={bottomLeft} topRight={topRight} />
</div>
<Portal>
{ttip && (ttip.data || ttip.feature) && (
<VizTooltipContainer position={{ x: ttip.pageX, y: ttip.pageY }} offset={{ x: 10, y: 10 }}>
<DataHoverView {...ttip} />
</VizTooltipContainer>
)}
</Portal>
<GeomapTooltip ttip={ttip} isOpen={ttipOpen} onClose={this.tooltipPopupClosed} />
</>
);
}

View File

@ -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<HTMLElement>();
const { overlayProps } = useOverlay({ onClose, isDismissable: true, isOpen }, ref);
return (
<>
{ttip && ttip.layers && (
<VizTooltipContainer position={{ x: ttip.pageX, y: ttip.pageY }} offset={{ x: 10, y: 10 }} allowPointerEvents>
<section ref={ref} {...overlayProps}>
<ComplexDataHoverView {...ttip} isOpen={isOpen} onClose={onClose} />
</section>
</VizTooltipContainer>
)}
</>
);
};

View File

@ -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<number>(0);
if (!layers) {
return null;
}
return (
<>
{isOpen && <CloseButton style={{ zIndex: 1 }} onClick={onClose} />}
<DataHoverTabs layers={layers} setActiveTabIndex={setActiveTabIndex} activeTabIndex={activeTabIndex} />
<DataHoverRows layers={layers} activeTabIndex={activeTabIndex} />
</>
);
};

View File

@ -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 <DataHoverView data={data} rowIndex={rowIndex} />;
};

View File

@ -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<string | number, boolean>());
const updateRowMap = (key: string | number, value: boolean) => {
setRowMap(new Map(rowMap.set(key, value)));
};
return (
<TabContent>
{layers.map(
(geomapLayer, index) =>
index === activeTabIndex && (
<div key={geomapLayer.layer.getName()}>
<div>
{geomapLayer.features.map((feature, idx) => {
const key = feature.getId() ?? idx;
const shouldDisplayCollapse = geomapLayer.features.length > 1;
return shouldDisplayCollapse ? (
<Collapse
key={key}
collapsible
label={generateLabel(feature, idx)}
isOpen={rowMap.get(key)}
onToggle={() => {
updateRowMap(key, !rowMap.get(key));
}}
className={styles.collapsibleRow}
>
<DataHoverRow feature={feature} />
</Collapse>
) : (
<DataHoverRow key={key} feature={feature} />
);
})}
</div>
</div>
)
)}
</TabContent>
);
};
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;
`,
});

View File

@ -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<SetStateAction<number>>;
activeTabIndex: number;
};
export const DataHoverTabs = ({ layers, setActiveTabIndex, activeTabIndex }: Props) => {
return (
<TabsBar>
{layers &&
layers.map((g, index) => (
<Tab
key={index}
label={g.layer.getName()}
active={index === activeTabIndex}
counter={g.features.length > 1 ? g.features.length : null}
onChangeTab={() => {
setActiveTabIndex(index);
}}
/>
))}
</TabsBar>
);
};

View File

@ -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<Props> {
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 (
<table className={this.style.infoWrap}>
<tbody>
{displayValues.map((v, i) => (
<tr key={`${i}/${rowIndex}`} className={i === columnIndex ? this.style.highlight : ''}>
<th>{v[0]}:</th>
<td>{v[2]}</td>
</tr>
))}
</tbody>
</table>
);
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<LinkModel<Field>> = [];
const linkLookup = new Set<string>();
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 (
<table className={styles.infoWrap}>
<tbody>
{displayValues.map((v, i) => (
<tr key={`${i}/${rowIndex}`} className={i === columnIndex ? styles.highlight : ''}>
<th>{v[0]}:</th>
<td>{v[2]}</td>
</tr>
))}
{links.length > 0 && (
<tr>
<td colSpan={2}>
<VerticalGroup>
{links.map((link, i) => (
<LinkButton
key={i}
icon={'external-link-alt'}
target={link.target}
href={link.href}
onClick={link.onClick}
fill="text"
style={{ width: '100%' }}
>
{link.title}
</LinkButton>
))}
</VerticalGroup>
</td>
</tr>
)}
</tbody>
</table>
);
};
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};
`,
}));
});

View File

@ -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;
}