Geomap: implement basic tooltip support (#37318)

Co-authored-by: Bryan Uribe <buribe@hmc.edu>
This commit is contained in:
Ryan McKinley
2021-07-28 18:34:42 -07:00
committed by GitHub
parent 8b80d2256d
commit ced26bc624
7 changed files with 174 additions and 24 deletions

View File

@ -329,7 +329,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
key: '__global_', key: '__global_',
filters: { filters: {
pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => { pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => {
payload.columnIndex = dataIdx; payload.rowIndex = dataIdx;
if (x < 0 && y < 0) { if (x < 0 && y < 0) {
payload.point[xScaleUnit] = null; payload.point[xScaleUnit] = null;
payload.point[yScaleKey] = null; payload.point[yScaleKey] = null;

View File

@ -1,3 +1,4 @@
import React, { Component } from 'react';
import { import {
EventBus, EventBus,
LegacyGraphHoverEvent, LegacyGraphHoverEvent,
@ -7,8 +8,9 @@ import {
DataHoverPayload, DataHoverPayload,
BusEventWithPayload, BusEventWithPayload,
} from '@grafana/data'; } from '@grafana/data';
import React, { Component } from 'react';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { CustomScrollbar } from '@grafana/ui';
import { DataHoverView } from '../geomap/components/DataHoverView';
interface Props { interface Props {
eventBus: EventBus; eventBus: EventBus;
@ -58,12 +60,16 @@ export class CursorView extends Component<Props, State> {
if (!event) { if (!event) {
return <div>no events yet</div>; return <div>no events yet</div>;
} }
const { type, payload, origin } = event;
return ( return (
<div> <CustomScrollbar autoHeightMin="100%" autoHeightMax="100%">
<h2>Origin: {(event.origin as any)?.path}</h2> <h3>Origin: {(origin as any)?.path}</h3>
<span>Type: {event.type}</span> <span>Type: {type}</span>
<pre>{JSON.stringify(event.payload.point, null, ' ')}</pre> <pre>{JSON.stringify(payload.point, null, ' ')}</pre>
</div> {payload.data && (
<DataHoverView data={payload.data} rowIndex={payload.rowIndex} columnIndex={payload.columnIndex} />
)}
</CustomScrollbar>
); );
} }
} }

View File

@ -1,6 +1,6 @@
import React, { Component, ReactNode } from 'react'; import React, { Component, ReactNode } from 'react';
import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry, defaultBaseLayer } from './layers/registry'; import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry, defaultBaseLayer } from './layers/registry';
import { Map, View } from 'ol'; import { Map, MapBrowserEvent, View } from 'ol';
import Attribution from 'ol/control/Attribution'; import Attribution from 'ol/control/Attribution';
import Zoom from 'ol/control/Zoom'; import Zoom from 'ol/control/Zoom';
import ScaleLine from 'ol/control/ScaleLine'; import ScaleLine from 'ol/control/ScaleLine';
@ -8,19 +8,30 @@ import BaseLayer from 'ol/layer/Base';
import { defaults as interactionDefaults } from 'ol/interaction'; import { defaults as interactionDefaults } from 'ol/interaction';
import MouseWheelZoom from 'ol/interaction/MouseWheelZoom'; import MouseWheelZoom from 'ol/interaction/MouseWheelZoom';
import { PanelData, MapLayerHandler, MapLayerOptions, PanelProps, GrafanaTheme } from '@grafana/data'; import {
PanelData,
MapLayerHandler,
MapLayerOptions,
PanelProps,
GrafanaTheme,
DataHoverClearEvent,
DataHoverEvent,
DataFrame,
} from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { ControlsOptions, GeomapPanelOptions, MapViewConfig } from './types'; import { ControlsOptions, GeomapPanelOptions, MapViewConfig } from './types';
import { centerPointRegistry, MapCenterID } from './view'; import { centerPointRegistry, MapCenterID } from './view';
import { fromLonLat } from 'ol/proj'; import { fromLonLat, toLonLat } from 'ol/proj';
import { Coordinate } from 'ol/coordinate'; import { Coordinate } from 'ol/coordinate';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { stylesFactory } from '@grafana/ui'; import { Portal, stylesFactory, VizTooltipContainer } from '@grafana/ui';
import { GeomapOverlay, OverlayProps } from './GeomapOverlay'; import { GeomapOverlay, OverlayProps } from './GeomapOverlay';
import { DebugOverlay } from './components/DebugOverlay'; import { DebugOverlay } from './components/DebugOverlay';
import { getGlobalStyles } from './globalStyles'; import { getGlobalStyles } from './globalStyles';
import { Global } from '@emotion/react'; import { Global } from '@emotion/react';
import { GeomapHoverFeature, GeomapHoverPayload } from './event';
import { DataHoverView } from './components/DataHoverView';
interface MapLayerState { interface MapLayerState {
config: MapLayerOptions; config: MapLayerOptions;
@ -33,7 +44,10 @@ let sharedView: View | undefined = undefined;
export let lastGeomapPanelInstance: GeomapPanel | undefined = undefined; export let lastGeomapPanelInstance: GeomapPanel | undefined = undefined;
type Props = PanelProps<GeomapPanelOptions>; type Props = PanelProps<GeomapPanelOptions>;
interface State extends OverlayProps {} interface State extends OverlayProps {
ttip?: GeomapHoverPayload;
}
export class GeomapPanel extends Component<Props, State> { export class GeomapPanel extends Component<Props, State> {
globalCSS = getGlobalStyles(config.theme2); globalCSS = getGlobalStyles(config.theme2);
@ -43,10 +57,14 @@ export class GeomapPanel extends Component<Props, State> {
layers: MapLayerState[] = []; layers: MapLayerState[] = [];
mouseWheelZoom?: MouseWheelZoom; mouseWheelZoom?: MouseWheelZoom;
style = getStyles(config.theme); style = getStyles(config.theme);
hoverPayload: GeomapHoverPayload = { point: {}, pageX: -1, pageY: -1 };
readonly hoverEvent = new DataHoverEvent(this.hoverPayload);
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = {}; this.state = {};
} }
componentDidMount() { componentDidMount() {
lastGeomapPanelInstance = this; lastGeomapPanelInstance = this;
} }
@ -144,6 +162,53 @@ export class GeomapPanel extends Component<Props, State> {
this.initBasemap(options.basemap); this.initBasemap(options.basemap);
await this.initLayers(options.layers); await this.initLayers(options.layers);
this.forceUpdate(); // first render this.forceUpdate(); // first render
// Tooltip listener
this.map.on('pointermove', this.pointerMoveListener);
this.map.getViewport().addEventListener('mouseout', (evt) => {
this.props.eventBus.publish(new DataHoverClearEvent({ point: {} }));
});
};
pointerMoveListener = (evt: MapBrowserEvent) => {
if (!this.map) {
return;
}
const mouse = evt.originalEvent as any;
const pixel = this.map.getEventPixel(mouse);
const hover = toLonLat(this.map.getCoordinateFromPixel(pixel));
const { hoverPayload } = this;
hoverPayload.pageX = mouse.pageX;
hoverPayload.pageY = mouse.pageY;
hoverPayload.point = {
lat: hover[1],
lon: hover[0],
};
hoverPayload.data = undefined;
hoverPayload.columnIndex = undefined;
hoverPayload.rowIndex = undefined;
let ttip: GeomapHoverPayload = {} as GeomapHoverPayload;
const features: GeomapHoverFeature[] = [];
this.map.forEachFeatureAtPixel(pixel, (feature, layer, geo) => {
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'];
}
}
features.push({ feature, layer, geo });
});
this.hoverPayload.features = features.length ? features : undefined;
this.props.eventBus.publish(this.hoverEvent);
const currentTTip = this.state.ttip;
if (ttip.data !== currentTTip?.data || ttip.rowIndex !== currentTTip?.rowIndex) {
this.setState({ ttip: { ...hoverPayload } });
}
}; };
async initBasemap(cfg: MapLayerOptions) { async initBasemap(cfg: MapLayerOptions) {
@ -187,6 +252,7 @@ export class GeomapPanel extends Component<Props, State> {
const handler = await item.create(this.map!, overlay, config.theme2); const handler = await item.create(this.map!, overlay, config.theme2);
const layer = handler.init(); const layer = handler.init();
(layer as any).___handler = handler;
this.map!.addLayer(layer); this.map!.addLayer(layer);
this.layers.push({ this.layers.push({
config: overlay, config: overlay,
@ -284,13 +350,22 @@ export class GeomapPanel extends Component<Props, State> {
} }
render() { render() {
const { ttip, topRight, bottomLeft } = this.state;
return ( return (
<> <>
<Global styles={this.globalCSS} /> <Global styles={this.globalCSS} />
<div className={this.style.wrap}> <div className={this.style.wrap}>
<div className={this.style.map} ref={this.initMapRef}></div> <div className={this.style.map} ref={this.initMapRef}></div>
<GeomapOverlay {...this.state} /> <GeomapOverlay bottomLeft={bottomLeft} topRight={topRight} />
</div> </div>
<Portal>
{ttip && ttip.data && (
<VizTooltipContainer position={{ x: ttip.pageX, y: ttip.pageY }} offset={{ x: 10, y: 10 }}>
<DataHoverView {...ttip} />
</VizTooltipContainer>
)}
</Portal>
</> </>
); );
} }

View File

@ -0,0 +1,56 @@
import React, { PureComponent } from 'react';
import { stylesFactory } from '@grafana/ui';
import { DataFrame, Field, formattedValueToString, getFieldDisplayName, GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
import { config } from 'app/core/config';
export interface Props {
data?: DataFrame; // source data
rowIndex?: number; // the hover row
columnIndex?: number; // the hover column
}
export class DataHoverView extends PureComponent<Props> {
style = getStyles(config.theme2);
render() {
const { data, rowIndex, columnIndex } = this.props;
if (!data || rowIndex == null) {
return null;
}
return (
<table className={this.style.infoWrap}>
<tbody>
{data.fields.map((f, i) => (
<tr key={`${i}/${rowIndex}`} className={i === columnIndex ? this.style.highlight : ''}>
<th>{getFieldDisplayName(f, data)}:</th>
<td>{fmt(f, rowIndex)}</td>
</tr>
))}
</tbody>
</table>
);
}
}
function fmt(field: Field, row: number): string {
const v = field.values.get(row);
if (field.display) {
return formattedValueToString(field.display(v));
}
return `${v}`;
}
const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
infoWrap: css`
padding: 8px;
th {
font-weight: bold;
padding: 2px 10px 2px 0px;
}
`,
highlight: css`
background: ${theme.colors.action.hover};
`,
}));

View File

@ -22,17 +22,16 @@ export class ObservablePropsWrapper<T> extends Component<Props<T>, State<T>> {
} }
componentDidMount() { componentDidMount() {
console.log('ObservablePropsWrapper:subscribe');
this.sub = this.props.watch.subscribe({ this.sub = this.props.watch.subscribe({
next: (subProps: T) => { next: (subProps: T) => {
console.log('ObservablePropsWrapper:NEXT', subProps); //console.log('ObservablePropsWrapper:NEXT', subProps);
this.setState({ subProps }); this.setState({ subProps });
}, },
complete: () => { complete: () => {
console.log('ObservablePropsWrapper:complete'); //console.log('ObservablePropsWrapper:complete');
}, },
error: (err) => { error: (err) => {
console.log('ObservablePropsWrapper:error', err); //console.log('ObservablePropsWrapper:error', err);
}, },
}); });
} }
@ -41,12 +40,10 @@ export class ObservablePropsWrapper<T> extends Component<Props<T>, State<T>> {
if (this.sub) { if (this.sub) {
this.sub.unsubscribe(); this.sub.unsubscribe();
} }
console.log('ObservablePropsWrapper:unsubscribe');
} }
render() { render() {
const { subProps } = this.state; const { subProps } = this.state;
console.log('RENDER (wrap)', subProps);
return <this.props.child {...subProps} />; return <this.props.child {...subProps} />;
} }
} }

View File

@ -0,0 +1,16 @@
import { FeatureLike } from 'ol/Feature';
import { SimpleGeometry } from 'ol/geom';
import { Layer } from 'ol/layer';
import { DataHoverPayload } from '@grafana/data';
export interface GeomapHoverFeature {
feature: FeatureLike;
layer: Layer;
geo: SimpleGeometry;
}
export interface GeomapHoverPayload extends DataHoverPayload {
features?: GeomapHoverFeature[];
pageX: number;
pageY: number;
}

View File

@ -85,7 +85,6 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
/> />
} }
const shape = markerMakers.getIfExists(config.shape) ?? circleMarker; const shape = markerMakers.getIfExists(config.shape) ?? circleMarker;
console.log( 'CREATE Marker layer', matchers);
return { return {
init: () => vectorLayer, init: () => vectorLayer,
@ -118,8 +117,10 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
const radius = sizeDim.get(i); const radius = sizeDim.get(i);
// Create a new Feature for each point returned from dataFrameToPoints // Create a new Feature for each point returned from dataFrameToPoints
const dot = new Feature({ const dot = new Feature( info.points[i] );
geometry: info.points[i], dot.setProperties({
frame,
rowIndex: i,
}); });
dot.setStyle(shape!.make(color, fillColor, radius)); dot.setStyle(shape!.make(color, fillColor, radius));
@ -128,7 +129,6 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
// Post updates to the legend component // Post updates to the legend component
if (legend) { if (legend) {
console.log( 'UPDATE (marker layer)', colorDim);
legendProps.next({ legendProps.next({
color: colorDim, color: colorDim,
size: sizeDim, size: sizeDim,