mirror of
https://github.com/grafana/grafana.git
synced 2025-09-26 05:13:57 +08:00
Geomap: implement basic tooltip support (#37318)
Co-authored-by: Bryan Uribe <buribe@hmc.edu>
This commit is contained in:
@ -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;
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
56
public/app/plugins/panel/geomap/components/DataHoverView.tsx
Normal file
56
public/app/plugins/panel/geomap/components/DataHoverView.tsx
Normal 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};
|
||||||
|
`,
|
||||||
|
}));
|
@ -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} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
16
public/app/plugins/panel/geomap/event.ts
Normal file
16
public/app/plugins/panel/geomap/event.ts
Normal 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;
|
||||||
|
}
|
@ -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,
|
||||||
|
Reference in New Issue
Block a user