BarChartPanel: Adds support for Tooltip in BarChartPanel (#33938)

* Adds support for Tooltip in BarChartPanel

* Revert some formatting

* Remove BarChart story
This commit is contained in:
Dominik Prokop 2021-05-11 19:24:23 +02:00 committed by GitHub
parent 60c32dc96a
commit bf2c45db01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 263 additions and 417 deletions

View File

@ -1,66 +0,0 @@
import { toDataFrame, FieldType, VizOrientation } from '@grafana/data';
import React from 'react';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { BarChart } from './BarChart';
import { LegendDisplayMode } from '../VizLegend/models.gen';
import { prepDataForStorybook } from '../../utils/storybook/data';
import { useTheme2 } from '../../themes';
import { select } from '@storybook/addon-knobs';
import { BarChartOptions, BarValueVisibility } from './types';
import { StackingMode } from '../uPlot/config';
export default {
title: 'Visualizations/BarChart',
component: BarChart,
decorators: [withCenteredStory],
parameters: {
docs: {},
},
};
const getKnobs = () => {
return {
legendPlacement: select(
'Legend placement',
{
bottom: 'bottom',
right: 'right',
},
'bottom'
),
orientation: select(
'Bar orientation',
{
vertical: VizOrientation.Vertical,
horizontal: VizOrientation.Horizontal,
},
VizOrientation.Vertical
),
};
};
export const Basic: React.FC = () => {
const { legendPlacement, orientation } = getKnobs();
const theme = useTheme2();
const frame = toDataFrame({
fields: [
{ name: 'x', type: FieldType.string, values: ['group 1', 'group 2'] },
{ name: 'a', type: FieldType.number, values: [10, 20] },
{ name: 'b', type: FieldType.number, values: [30, 10] },
],
});
const data = prepDataForStorybook([frame], theme);
const options: BarChartOptions = {
orientation: orientation,
legend: { displayMode: LegendDisplayMode.List, placement: legendPlacement, calcs: [] },
stacking: StackingMode.None,
showValue: BarValueVisibility.Always,
barWidth: 0.97,
groupWidth: 0.7,
};
return <BarChart data={data} width={600} height={400} {...options} />;
};

View File

@ -1,100 +1,52 @@
import React from 'react'; import React from 'react';
import { AlignedData } from 'uplot';
import { DataFrame, TimeRange } from '@grafana/data'; import { DataFrame, TimeRange } from '@grafana/data';
import { VizLayout } from '../VizLayout/VizLayout';
import { Themeable2 } from '../../types';
import { UPlotChart } from '../uPlot/Plot';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { GraphNGLegendEvent } from '../GraphNG/types';
import { BarChartOptions } from './types'; import { BarChartOptions } from './types';
import { withTheme2 } from '../../themes/ThemeContext'; import { withTheme2 } from '../../themes/ThemeContext';
import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
import { pluginLog, preparePlotData } from '../uPlot/utils';
import { LegendDisplayMode } from '../VizLegend/models.gen'; import { LegendDisplayMode } from '../VizLegend/models.gen';
import { PlotLegend } from '../uPlot/PlotLegend'; import { PlotLegend } from '../uPlot/PlotLegend';
import { GraphNG, GraphNGProps } from '../GraphNG/GraphNG';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
/** /**
* @alpha * @alpha
*/ */
export interface BarChartProps extends Themeable2, BarChartOptions { export interface BarChartProps
height: number; extends BarChartOptions,
width: number; Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'> {}
data: DataFrame[];
structureRev?: number; // a number that will change when the data[] structure changes
onLegendClick?: (event: GraphNGLegendEvent) => void;
}
interface BarChartState { const propsToDiff: string[] = ['orientation', 'barWidth', 'groupWidth', 'showValue'];
data: AlignedData;
alignedDataFrame: DataFrame;
config?: UPlotConfigBuilder;
}
class UnthemedBarChart extends React.Component<BarChartProps, BarChartState> { class UnthemedBarChart extends React.Component<BarChartProps> {
constructor(props: BarChartProps) { prepConfig = (alignedFrame: DataFrame, getTimeRange: () => TimeRange) => {
super(props); const { eventBus } = this.context;
const alignedDataFrame = preparePlotFrame(props.data); const { theme, timeZone, orientation, barWidth, showValue, groupWidth, stacking, legend, tooltip } = this.props;
if (!alignedDataFrame) { return preparePlotConfigBuilder({
return; frame: alignedFrame,
} getTimeRange,
const data = preparePlotData(alignedDataFrame); theme,
const config = preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props); timeZone,
this.state = { eventBus,
alignedDataFrame, orientation,
data, barWidth,
config, showValue,
groupWidth,
stacking,
legend,
tooltip,
});
}; };
}
componentDidUpdate(prevProps: BarChartProps) { renderLegend = (config: UPlotConfigBuilder) => {
const { data, orientation, groupWidth, barWidth, showValue, structureRev } = this.props; const { legend, onLegendClick, frames } = this.props;
const { alignedDataFrame } = this.state;
let shouldConfigUpdate = false;
let stateUpdate = {} as BarChartState;
if (
this.state.config === undefined ||
orientation !== prevProps.orientation ||
groupWidth !== prevProps.groupWidth ||
barWidth !== prevProps.barWidth ||
showValue !== prevProps.showValue
) {
shouldConfigUpdate = true;
}
if (data !== prevProps.data || shouldConfigUpdate) {
const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev;
const alignedData = preparePlotFrame(data);
if (!alignedData) {
return;
}
stateUpdate = {
alignedDataFrame: alignedData,
data: preparePlotData(alignedData),
};
if (shouldConfigUpdate || hasStructureChanged) {
pluginLog('BarChart', false, 'updating config');
const builder = preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props);
stateUpdate = { ...stateUpdate, config: builder };
}
}
if (Object.keys(stateUpdate).length > 0) {
this.setState(stateUpdate);
}
}
renderLegend() {
const { legend, onLegendClick, data } = this.props;
const { config } = this.state;
if (!config || legend.displayMode === LegendDisplayMode.Hidden) { if (!config || legend.displayMode === LegendDisplayMode.Hidden) {
return; return;
} }
return ( return (
<PlotLegend <PlotLegend
data={data} data={frames}
config={config} config={config}
onLegendClick={onLegendClick} onLegendClick={onLegendClick}
maxHeight="35%" maxHeight="35%"
@ -102,31 +54,21 @@ class UnthemedBarChart extends React.Component<BarChartProps, BarChartState> {
{...legend} {...legend}
/> />
); );
} };
render() { render() {
const { width, height } = this.props;
const { config, data } = this.state;
if (!config) {
return null;
}
return ( return (
<VizLayout width={width} height={height} legend={this.renderLegend()}> <GraphNG
{(vizWidth: number, vizHeight: number) => ( {...this.props}
<UPlotChart frames={this.props.frames}
data={data} prepConfig={this.prepConfig}
config={config} propsToDiff={propsToDiff}
width={vizWidth} preparePlotFrame={preparePlotFrame}
height={vizHeight} renderLegend={this.renderLegend as any}
timeRange={({ from: 1, to: 1 } as unknown) as TimeRange} // HACK
/> />
)}
</VizLayout>
); );
} }
} }
export const BarChart = withTheme2(UnthemedBarChart); export const BarChart = withTheme2(UnthemedBarChart);
BarChart.displayName = 'GraphNG'; BarChart.displayName = 'BarChart';

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GraphNG utils preparePlotConfigBuilder orientation 1`] = ` exports[`BarChart utils preparePlotConfigBuilder orientation 1`] = `
Object { Object {
"axes": Array [ "axes": Array [
Object { Object {
@ -61,13 +61,10 @@ Object {
}, },
"points": Object { "points": Object {
"fill": [Function], "fill": [Function],
"show": false,
"size": [Function], "size": [Function],
"stroke": [Function], "stroke": [Function],
"width": [Function], "width": [Function],
}, },
"x": false,
"y": false,
}, },
"hooks": Object { "hooks": Object {
"drawClear": Array [ "drawClear": Array [
@ -76,9 +73,6 @@ Object {
"init": Array [ "init": Array [
[Function], [Function],
], ],
"setCursor": Array [
[Function],
],
}, },
"scales": Object { "scales": Object {
"m/s": Object { "m/s": Object {
@ -100,9 +94,7 @@ Object {
"time": false, "time": false,
}, },
}, },
"select": Object { "select": undefined,
"show": false,
},
"series": Array [ "series": Array [
Object {}, Object {},
Object { Object {
@ -126,7 +118,7 @@ Object {
} }
`; `;
exports[`GraphNG utils preparePlotConfigBuilder orientation 2`] = ` exports[`BarChart utils preparePlotConfigBuilder orientation 2`] = `
Object { Object {
"axes": Array [ "axes": Array [
Object { Object {
@ -187,13 +179,10 @@ Object {
}, },
"points": Object { "points": Object {
"fill": [Function], "fill": [Function],
"show": false,
"size": [Function], "size": [Function],
"stroke": [Function], "stroke": [Function],
"width": [Function], "width": [Function],
}, },
"x": false,
"y": false,
}, },
"hooks": Object { "hooks": Object {
"drawClear": Array [ "drawClear": Array [
@ -202,9 +191,6 @@ Object {
"init": Array [ "init": Array [
[Function], [Function],
], ],
"setCursor": Array [
[Function],
],
}, },
"scales": Object { "scales": Object {
"m/s": Object { "m/s": Object {
@ -226,9 +212,7 @@ Object {
"time": false, "time": false,
}, },
}, },
"select": Object { "select": undefined,
"show": false,
},
"series": Array [ "series": Array [
Object {}, Object {},
Object { Object {
@ -252,7 +236,7 @@ Object {
} }
`; `;
exports[`GraphNG utils preparePlotConfigBuilder orientation 3`] = ` exports[`BarChart utils preparePlotConfigBuilder orientation 3`] = `
Object { Object {
"axes": Array [ "axes": Array [
Object { Object {
@ -313,13 +297,10 @@ Object {
}, },
"points": Object { "points": Object {
"fill": [Function], "fill": [Function],
"show": false,
"size": [Function], "size": [Function],
"stroke": [Function], "stroke": [Function],
"width": [Function], "width": [Function],
}, },
"x": false,
"y": false,
}, },
"hooks": Object { "hooks": Object {
"drawClear": Array [ "drawClear": Array [
@ -328,9 +309,6 @@ Object {
"init": Array [ "init": Array [
[Function], [Function],
], ],
"setCursor": Array [
[Function],
],
}, },
"scales": Object { "scales": Object {
"m/s": Object { "m/s": Object {
@ -352,9 +330,7 @@ Object {
"time": false, "time": false,
}, },
}, },
"select": Object { "select": undefined,
"show": false,
},
"series": Array [ "series": Array [
Object {}, Object {},
Object { Object {
@ -378,7 +354,7 @@ Object {
} }
`; `;
exports[`GraphNG utils preparePlotConfigBuilder stacking 1`] = ` exports[`BarChart utils preparePlotConfigBuilder stacking 1`] = `
Object { Object {
"axes": Array [ "axes": Array [
Object { Object {
@ -439,13 +415,10 @@ Object {
}, },
"points": Object { "points": Object {
"fill": [Function], "fill": [Function],
"show": false,
"size": [Function], "size": [Function],
"stroke": [Function], "stroke": [Function],
"width": [Function], "width": [Function],
}, },
"x": false,
"y": false,
}, },
"hooks": Object { "hooks": Object {
"drawClear": Array [ "drawClear": Array [
@ -454,9 +427,6 @@ Object {
"init": Array [ "init": Array [
[Function], [Function],
], ],
"setCursor": Array [
[Function],
],
}, },
"scales": Object { "scales": Object {
"m/s": Object { "m/s": Object {
@ -478,9 +448,7 @@ Object {
"time": false, "time": false,
}, },
}, },
"select": Object { "select": undefined,
"show": false,
},
"series": Array [ "series": Array [
Object {}, Object {},
Object { Object {
@ -504,7 +472,7 @@ Object {
} }
`; `;
exports[`GraphNG utils preparePlotConfigBuilder stacking 2`] = ` exports[`BarChart utils preparePlotConfigBuilder stacking 2`] = `
Object { Object {
"axes": Array [ "axes": Array [
Object { Object {
@ -565,13 +533,10 @@ Object {
}, },
"points": Object { "points": Object {
"fill": [Function], "fill": [Function],
"show": false,
"size": [Function], "size": [Function],
"stroke": [Function], "stroke": [Function],
"width": [Function], "width": [Function],
}, },
"x": false,
"y": false,
}, },
"hooks": Object { "hooks": Object {
"drawClear": Array [ "drawClear": Array [
@ -580,9 +545,6 @@ Object {
"init": Array [ "init": Array [
[Function], [Function],
], ],
"setCursor": Array [
[Function],
],
}, },
"scales": Object { "scales": Object {
"m/s": Object { "m/s": Object {
@ -604,9 +566,7 @@ Object {
"time": false, "time": false,
}, },
}, },
"select": Object { "select": undefined,
"show": false,
},
"series": Array [ "series": Array [
Object {}, Object {},
Object { Object {
@ -630,7 +590,7 @@ Object {
} }
`; `;
exports[`GraphNG utils preparePlotConfigBuilder stacking 3`] = ` exports[`BarChart utils preparePlotConfigBuilder stacking 3`] = `
Object { Object {
"axes": Array [ "axes": Array [
Object { Object {
@ -691,13 +651,10 @@ Object {
}, },
"points": Object { "points": Object {
"fill": [Function], "fill": [Function],
"show": false,
"size": [Function], "size": [Function],
"stroke": [Function], "stroke": [Function],
"width": [Function], "width": [Function],
}, },
"x": false,
"y": false,
}, },
"hooks": Object { "hooks": Object {
"drawClear": Array [ "drawClear": Array [
@ -706,9 +663,6 @@ Object {
"init": Array [ "init": Array [
[Function], [Function],
], ],
"setCursor": Array [
[Function],
],
}, },
"scales": Object { "scales": Object {
"m/s": Object { "m/s": Object {
@ -730,9 +684,7 @@ Object {
"time": false, "time": false,
}, },
}, },
"select": Object { "select": undefined,
"show": false,
},
"series": Array [ "series": Array [
Object {}, Object {},
Object { Object {
@ -756,7 +708,7 @@ Object {
} }
`; `;
exports[`GraphNG utils preparePlotConfigBuilder value visibility 1`] = ` exports[`BarChart utils preparePlotConfigBuilder value visibility 1`] = `
Object { Object {
"axes": Array [ "axes": Array [
Object { Object {
@ -817,13 +769,10 @@ Object {
}, },
"points": Object { "points": Object {
"fill": [Function], "fill": [Function],
"show": false,
"size": [Function], "size": [Function],
"stroke": [Function], "stroke": [Function],
"width": [Function], "width": [Function],
}, },
"x": false,
"y": false,
}, },
"hooks": Object { "hooks": Object {
"drawClear": Array [ "drawClear": Array [
@ -832,9 +781,6 @@ Object {
"init": Array [ "init": Array [
[Function], [Function],
], ],
"setCursor": Array [
[Function],
],
}, },
"scales": Object { "scales": Object {
"m/s": Object { "m/s": Object {
@ -856,9 +802,7 @@ Object {
"time": false, "time": false,
}, },
}, },
"select": Object { "select": undefined,
"show": false,
},
"series": Array [ "series": Array [
Object {}, Object {},
Object { Object {
@ -882,7 +826,7 @@ Object {
} }
`; `;
exports[`GraphNG utils preparePlotConfigBuilder value visibility 2`] = ` exports[`BarChart utils preparePlotConfigBuilder value visibility 2`] = `
Object { Object {
"axes": Array [ "axes": Array [
Object { Object {
@ -943,13 +887,10 @@ Object {
}, },
"points": Object { "points": Object {
"fill": [Function], "fill": [Function],
"show": false,
"size": [Function], "size": [Function],
"stroke": [Function], "stroke": [Function],
"width": [Function], "width": [Function],
}, },
"x": false,
"y": false,
}, },
"hooks": Object { "hooks": Object {
"drawClear": Array [ "drawClear": Array [
@ -958,9 +899,6 @@ Object {
"init": Array [ "init": Array [
[Function], [Function],
], ],
"setCursor": Array [
[Function],
],
}, },
"scales": Object { "scales": Object {
"m/s": Object { "m/s": Object {
@ -982,9 +920,7 @@ Object {
"time": false, "time": false,
}, },
}, },
"select": Object { "select": undefined,
"show": false,
},
"series": Array [ "series": Array [
Object {}, Object {},
Object { Object {

View File

@ -1,13 +1,12 @@
import uPlot, { Axis, Series, Cursor, Select } from 'uplot'; import uPlot, { Axis, Series } from 'uplot';
import { Quadtree, Rect, pointWithin } from './quadtree'; import { Quadtree, Rect, pointWithin } from './quadtree';
import { distribute, SPACE_BETWEEN } from './distribute'; import { distribute, SPACE_BETWEEN } from './distribute';
import { TooltipInterpolator } from '../uPlot/types';
const pxRatio = devicePixelRatio; import { ScaleDirection, ScaleOrientation } from '../uPlot/config';
const groupDistr = SPACE_BETWEEN; const groupDistr = SPACE_BETWEEN;
const barDistr = SPACE_BETWEEN; const barDistr = SPACE_BETWEEN;
const font = Math.round(10 * devicePixelRatio) + 'px Arial';
const font = Math.round(10 * pxRatio) + 'px Arial';
type WalkTwoCb = null | ((idx: number, offPx: number, dimPx: number) => void); type WalkTwoCb = null | ((idx: number, offPx: number, dimPx: number) => void);
@ -41,8 +40,8 @@ function walkTwo(
* @internal * @internal
*/ */
export interface BarsOptions { export interface BarsOptions {
xOri: 1 | 0; xOri: ScaleOrientation;
xDir: 1 | -1; xDir: ScaleDirection;
groupWidth: number; groupWidth: number;
barWidth: number; barWidth: number;
formatValue?: (seriesIdx: number, value: any) => string; formatValue?: (seriesIdx: number, value: any) => string;
@ -54,11 +53,11 @@ export interface BarsOptions {
* @internal * @internal
*/ */
export function getConfig(opts: BarsOptions) { export function getConfig(opts: BarsOptions) {
const { xOri: ori, xDir: dir, groupWidth, barWidth, formatValue, onHover, onLeave } = opts; const { xOri: ori, xDir: dir, groupWidth, barWidth, formatValue } = opts;
let qt: Quadtree; let qt: Quadtree;
const drawBars: Series.PathBuilder = (u, sidx, i0, i1) => { const drawBars: Series.PathBuilder = (u, sidx) => {
return uPlot.orient( return uPlot.orient(
u, u,
sidx, sidx,
@ -112,29 +111,14 @@ export function getConfig(opts: BarsOptions) {
const drawPoints: Series.Points.Show = const drawPoints: Series.Points.Show =
formatValue == null formatValue == null
? false ? false
: (u, sidx, i0, i1) => { : (u, sidx) => {
u.ctx.font = font; u.ctx.font = font;
u.ctx.fillStyle = 'white'; u.ctx.fillStyle = 'white';
uPlot.orient( uPlot.orient(
u, u,
sidx, sidx,
( (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim) => {
series,
dataX,
dataY,
scaleX,
scaleY,
valToPosX,
valToPosY,
xOff,
yOff,
xDim,
yDim,
moveTo,
lineTo,
rect
) => {
let numGroups = dataX.length; let numGroups = dataX.length;
let barsPerGroup = u.series.length - 1; let barsPerGroup = u.series.length - 1;
@ -148,13 +132,11 @@ export function getConfig(opts: BarsOptions) {
if (dataY[ix] != null) { if (dataY[ix] != null) {
let yPos = valToPosY(dataY[ix]!, scaleY, yDim, yOff); let yPos = valToPosY(dataY[ix]!, scaleY, yDim, yOff);
/* eslint-disable no-multi-spaces */
let x = ori === 0 ? Math.round(lft + barWid / 2) : Math.round(yPos); let x = ori === 0 ? Math.round(lft + barWid / 2) : Math.round(yPos);
let y = ori === 0 ? Math.round(yPos) : Math.round(lft + barWid / 2); let y = ori === 0 ? Math.round(yPos) : Math.round(lft + barWid / 2);
u.ctx.textAlign = ori === 0 ? 'center' : dataY[ix]! >= 0 ? 'left' : 'right'; u.ctx.textAlign = ori === 0 ? 'center' : dataY[ix]! >= 0 ? 'left' : 'right';
u.ctx.textBaseline = ori === 1 ? 'middle' : dataY[ix]! >= 0 ? 'bottom' : 'top'; u.ctx.textBaseline = ori === 1 ? 'middle' : dataY[ix]! >= 0 ? 'bottom' : 'top';
/* eslint-enable */
u.ctx.fillText(formatValue(sidx, dataY[ix]), x, y); u.ctx.fillText(formatValue(sidx, dataY[ix]), x, y);
} }
@ -165,23 +147,15 @@ export function getConfig(opts: BarsOptions) {
return false; return false;
}; };
/* const xSplits: Axis.Splits = (u: uPlot) => {
const yRange: Scale.Range = (u, dataMin, dataMax) => {
// @ts-ignore
let [min, max] = uPlot.rangeNum(0, dataMax, 0.05, true);
return [0, max];
};
*/
const xSplits: Axis.Splits = (u: uPlot, axisIdx: number) => {
const dim = ori === 0 ? u.bbox.width : u.bbox.height; const dim = ori === 0 ? u.bbox.width : u.bbox.height;
const _dir = dir * (ori === 0 ? 1 : -1); const _dir = dir * (ori === 0 ? 1 : -1);
let splits: number[] = []; let splits: number[] = [];
distribute(u.data[0].length, groupWidth, groupDistr, null, (di, lftPct, widPct) => { distribute(u.data[0].length, groupWidth, groupDistr, null, (di, lftPct, widPct) => {
let groupLftPx = (dim * lftPct) / pxRatio; let groupLftPx = (dim * lftPct) / devicePixelRatio;
let groupWidPx = (dim * widPct) / pxRatio; let groupWidPx = (dim * widPct) / devicePixelRatio;
let groupCenterPx = groupLftPx + groupWidPx / 2; let groupCenterPx = groupLftPx + groupWidPx / 2;
@ -191,7 +165,6 @@ export function getConfig(opts: BarsOptions) {
return _dir === 1 ? splits : splits.reverse(); return _dir === 1 ? splits : splits.reverse();
}; };
// @ts-ignore
const xValues: Axis.Values = (u) => u.data[0]; const xValues: Axis.Values = (u) => u.data[0];
let hovered: Rect | null = null; let hovered: Rect | null = null;
@ -201,21 +174,6 @@ export function getConfig(opts: BarsOptions) {
barMark.style.position = 'absolute'; barMark.style.position = 'absolute';
barMark.style.background = 'rgba(255,255,255,0.4)'; barMark.style.background = 'rgba(255,255,255,0.4)';
// hide crosshair cursor & hover points
const cursor: Cursor = {
x: false,
y: false,
points: {
show: false,
},
};
// disable selection
// uPlot types do not export the Select interface prior to 1.6.4
const select: Partial<Select> = {
show: false,
};
const init = (u: uPlot) => { const init = (u: uPlot) => {
let over = u.root.querySelector('.u-over')! as HTMLElement; let over = u.root.querySelector('.u-over')! as HTMLElement;
over.style.overflow = 'hidden'; over.style.overflow = 'hidden';
@ -235,10 +193,15 @@ export function getConfig(opts: BarsOptions) {
}; };
// handle hover interaction with quadtree probing // handle hover interaction with quadtree probing
const setCursor = (u: uPlot) => { const interpolateBarChartTooltip: TooltipInterpolator = (
updateActiveSeriesIdx,
updateActiveDatapointIdx,
updateTooltipPosition
) => {
return (u: uPlot) => {
let found: Rect | null = null; let found: Rect | null = null;
let cx = u.cursor.left! * pxRatio; let cx = u.cursor.left! * devicePixelRatio;
let cy = u.cursor.top! * pxRatio; let cy = u.cursor.top! * devicePixelRatio;
qt.get(cx, cy, 1, 1, (o) => { qt.get(cx, cy, 1, 1, (o) => {
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
@ -249,36 +212,30 @@ export function getConfig(opts: BarsOptions) {
if (found) { if (found) {
// prettier-ignore // prettier-ignore
if (found !== hovered) { if (found !== hovered) {
/* eslint-disable no-multi-spaces */
barMark.style.display = ''; barMark.style.display = '';
barMark.style.left = found!.x / pxRatio + 'px'; barMark.style.left = found!.x / devicePixelRatio + 'px';
barMark.style.top = found!.y / pxRatio + 'px'; barMark.style.top = found!.y / devicePixelRatio + 'px';
barMark.style.width = found!.w / pxRatio + 'px'; barMark.style.width = found!.w / devicePixelRatio + 'px';
barMark.style.height = found!.h / pxRatio + 'px'; barMark.style.height = found!.h / devicePixelRatio + 'px';
hovered = found; hovered = found;
/* eslint-enable */ updateActiveSeriesIdx(hovered!.sidx);
updateActiveDatapointIdx(hovered!.didx);
if (onHover != null) { updateTooltipPosition();
onHover(hovered!.sidx, hovered!.didx);
}
} }
} else if (hovered != null) { } else if (hovered != null) {
if (onLeave != null) { updateActiveSeriesIdx(hovered!.sidx);
onLeave(hovered!.sidx, hovered!.didx); updateActiveDatapointIdx(hovered!.didx);
} updateTooltipPosition();
hovered = null; hovered = null;
barMark.style.display = 'none'; barMark.style.display = 'none';
} else {
updateTooltipPosition(true);
} }
}; };
};
return { return {
// cursor & select opts
cursor,
select,
// scale & axis opts // scale & axis opts
// yRange,
xValues, xValues,
xSplits, xSplits,
@ -289,6 +246,6 @@ export function getConfig(opts: BarsOptions) {
// hooks // hooks
init, init,
drawClear, drawClear,
setCursor, interpolateBarChartTooltip,
}; };
} }

View File

@ -1,6 +1,6 @@
import { VizOrientation } from '@grafana/data'; import { VizOrientation } from '@grafana/data';
import { AxisConfig, GraphGradientMode, HideableFieldConfig, StackingMode } from '../uPlot/config'; import { AxisConfig, GraphGradientMode, HideableFieldConfig, StackingMode } from '../uPlot/config';
import { VizLegendOptions } from '../VizLegend/models.gen'; import { OptionsWithLegend, OptionsWithTooltip } from '../../options';
/** /**
* @alpha * @alpha
@ -14,9 +14,8 @@ export enum BarValueVisibility {
/** /**
* @alpha * @alpha
*/ */
export interface BarChartOptions { export interface BarChartOptions extends OptionsWithLegend, OptionsWithTooltip {
orientation: VizOrientation; orientation: VizOrientation;
legend: VizLegendOptions;
stacking: StackingMode; stacking: StackingMode;
showValue: BarValueVisibility; showValue: BarValueVisibility;
barWidth: number; barWidth: number;

View File

@ -1,8 +1,18 @@
import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
import { createTheme, FieldConfig, FieldType, MutableDataFrame, VizOrientation } from '@grafana/data'; import {
createTheme,
DefaultTimeZone,
EventBusSrv,
FieldConfig,
FieldType,
getDefaultTimeRange,
MutableDataFrame,
VizOrientation,
} from '@grafana/data';
import { BarChartFieldConfig, BarChartOptions, BarValueVisibility } from './types'; import { BarChartFieldConfig, BarChartOptions, BarValueVisibility } from './types';
import { GraphGradientMode, StackingMode } from '../uPlot/config'; import { GraphGradientMode, StackingMode } from '../uPlot/config';
import { LegendDisplayMode } from '../VizLegend/models.gen'; import { LegendDisplayMode } from '../VizLegend/models.gen';
import { TooltipDisplayMode } from '../VizTooltip';
function mockDataFrame() { function mockDataFrame() {
const df1 = new MutableDataFrame({ const df1 = new MutableDataFrame({
@ -59,7 +69,7 @@ jest.mock('@grafana/data', () => ({
DefaultTimeZone: 'utc', DefaultTimeZone: 'utc',
})); }));
describe('GraphNG utils', () => { describe('BarChart utils', () => {
describe('preparePlotConfigBuilder', () => { describe('preparePlotConfigBuilder', () => {
const frame = mockDataFrame(); const frame = mockDataFrame();
@ -74,30 +84,48 @@ describe('GraphNG utils', () => {
calcs: [], calcs: [],
}, },
stacking: StackingMode.None, stacking: StackingMode.None,
tooltip: {
mode: TooltipDisplayMode.None,
},
}; };
it.each([VizOrientation.Auto, VizOrientation.Horizontal, VizOrientation.Vertical])('orientation', (v) => { it.each([VizOrientation.Auto, VizOrientation.Horizontal, VizOrientation.Vertical])('orientation', (v) => {
const result = preparePlotConfigBuilder(frame!, createTheme(), { const result = preparePlotConfigBuilder({
...config, ...config,
orientation: v, orientation: v,
frame: frame!,
theme: createTheme(),
timeZone: DefaultTimeZone,
getTimeRange: getDefaultTimeRange,
eventBus: new EventBusSrv(),
}).getConfig(); }).getConfig();
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
it.each([BarValueVisibility.Always, BarValueVisibility.Auto])('value visibility', (v) => { it.each([BarValueVisibility.Always, BarValueVisibility.Auto])('value visibility', (v) => {
expect( expect(
preparePlotConfigBuilder(frame!, createTheme(), { preparePlotConfigBuilder({
...config, ...config,
showValue: v, showValue: v,
frame: frame!,
theme: createTheme(),
timeZone: DefaultTimeZone,
getTimeRange: getDefaultTimeRange,
eventBus: new EventBusSrv(),
}).getConfig() }).getConfig()
).toMatchSnapshot(); ).toMatchSnapshot();
}); });
it.each([StackingMode.None, StackingMode.Percent, StackingMode.Normal])('stacking', (v) => { it.each([StackingMode.None, StackingMode.Percent, StackingMode.Normal])('stacking', (v) => {
expect( expect(
preparePlotConfigBuilder(frame!, createTheme(), { preparePlotConfigBuilder({
...config, ...config,
stacking: v, stacking: v,
frame: frame!,
theme: createTheme(),
timeZone: DefaultTimeZone,
getTimeRange: getDefaultTimeRange,
eventBus: new EventBusSrv(),
}).getConfig() }).getConfig()
).toMatchSnapshot(); ).toMatchSnapshot();
}); });

View File

@ -6,7 +6,6 @@ import {
getFieldColorModeForField, getFieldColorModeForField,
getFieldDisplayName, getFieldDisplayName,
getFieldSeriesColor, getFieldSeriesColor,
GrafanaTheme2,
MutableDataFrame, MutableDataFrame,
VizOrientation, VizOrientation,
} from '@grafana/data'; } from '@grafana/data';
@ -14,76 +13,79 @@ import { BarChartFieldConfig, BarChartOptions, BarValueVisibility, defaultBarCha
import { AxisPlacement, ScaleDirection, ScaleOrientation } from '../uPlot/config'; import { AxisPlacement, ScaleDirection, ScaleOrientation } from '../uPlot/config';
import { BarsOptions, getConfig } from './bars'; import { BarsOptions, getConfig } from './bars';
import { FIXED_UNIT } from '../GraphNG/GraphNG'; import { FIXED_UNIT } from '../GraphNG/GraphNG';
import { Select } from 'uplot';
import { ScaleDistribution } from '../uPlot/models.gen'; import { ScaleDistribution } from '../uPlot/models.gen';
import { PrepConfigOpts } from '../GraphNG/utils';
type PrepConfig = (opts: PrepConfigOpts<BarChartOptions>) => UPlotConfigBuilder;
/** @alpha */ /** @alpha */
export function preparePlotConfigBuilder( function getBarCharScaleOrientation(orientation: VizOrientation) {
data: DataFrame,
theme: GrafanaTheme2,
{ orientation, showValue, groupWidth, barWidth }: BarChartOptions
) {
const builder = new UPlotConfigBuilder();
// bar orientation -> x scale orientation & direction
let xOri = ScaleOrientation.Vertical;
let xDir = ScaleDirection.Down;
let yOri = ScaleOrientation.Horizontal;
let yDir = ScaleDirection.Right;
if (orientation === VizOrientation.Vertical) { if (orientation === VizOrientation.Vertical) {
xOri = ScaleOrientation.Horizontal; return {
xDir = ScaleDirection.Right; xOri: ScaleOrientation.Horizontal,
yOri = ScaleOrientation.Vertical; xDir: ScaleDirection.Right,
yDir = ScaleDirection.Up; yOri: ScaleOrientation.Vertical,
yDir: ScaleDirection.Up,
};
} }
const formatValue = return {
showValue !== BarValueVisibility.Never xOri: ScaleOrientation.Vertical,
? (seriesIdx: number, value: any) => formattedValueToString(data.fields[seriesIdx].display!(value)) xDir: ScaleDirection.Down,
: undefined; yOri: ScaleOrientation.Horizontal,
yDir: ScaleDirection.Right,
};
}
export const preparePlotConfigBuilder: PrepConfig = ({
frame,
theme,
orientation,
showValue,
groupWidth,
barWidth,
}) => {
const builder = new UPlotConfigBuilder();
const defaultValueFormatter = (seriesIdx: number, value: any) =>
formattedValueToString(frame.fields[seriesIdx].display!(value));
// bar orientation -> x scale orientation & direction
const vizOrientation = getBarCharScaleOrientation(orientation);
const formatValue = showValue !== BarValueVisibility.Never ? defaultValueFormatter : undefined;
// Use bar width when only one field // Use bar width when only one field
if (data.fields.length === 2) { if (frame.fields.length === 2) {
groupWidth = barWidth; groupWidth = barWidth;
barWidth = 1; barWidth = 1;
} }
const opts: BarsOptions = { const opts: BarsOptions = {
xOri, xOri: vizOrientation.xOri,
xDir, xDir: vizOrientation.xDir,
groupWidth, groupWidth,
barWidth, barWidth,
formatValue, formatValue,
onHover: (seriesIdx: number, valueIdx: number) => {
console.log('hover', { seriesIdx, valueIdx });
},
onLeave: (seriesIdx: number, valueIdx: number) => {
console.log('leave', { seriesIdx, valueIdx });
},
}; };
const config = getConfig(opts); const config = getConfig(opts);
builder.addHook('init', config.init); builder.addHook('init', config.init);
builder.addHook('drawClear', config.drawClear); builder.addHook('drawClear', config.drawClear);
builder.addHook('setCursor', config.setCursor); builder.setTooltipInterpolator(config.interpolateBarChartTooltip);
builder.setCursor(config.cursor);
builder.setSelect(config.select as Select);
builder.addScale({ builder.addScale({
scaleKey: 'x', scaleKey: 'x',
isTime: false, isTime: false,
distribution: ScaleDistribution.Ordinal, distribution: ScaleDistribution.Ordinal,
orientation: xOri, orientation: vizOrientation.xOri,
direction: xDir, direction: vizOrientation.xDir,
}); });
builder.addAxis({ builder.addAxis({
scaleKey: 'x', scaleKey: 'x',
isTime: false, isTime: false,
placement: xOri === 0 ? AxisPlacement.Bottom : AxisPlacement.Left, placement: vizOrientation.xOri === 0 ? AxisPlacement.Bottom : AxisPlacement.Left,
splits: config.xSplits, splits: config.xSplits,
values: config.xValues, values: config.xValues,
grid: false, grid: false,
@ -95,8 +97,8 @@ export function preparePlotConfigBuilder(
let seriesIndex = 0; let seriesIndex = 0;
// iterate the y values // iterate the y values
for (let i = 1; i < data.fields.length; i++) { for (let i = 1; i < frame.fields.length; i++) {
const field = data.fields[i]; const field = frame.fields[i];
field.state!.seriesIndex = seriesIndex++; field.state!.seriesIndex = seriesIndex++;
@ -127,7 +129,7 @@ export function preparePlotConfigBuilder(
fieldIndex: i, fieldIndex: i,
frameIndex: 0, frameIndex: 0,
}, },
fieldName: getFieldDisplayName(field, data), fieldName: getFieldDisplayName(field, frame),
hideInLegend: customConfig.hideFrom?.legend, hideInLegend: customConfig.hideFrom?.legend,
}); });
@ -138,8 +140,8 @@ export function preparePlotConfigBuilder(
max: field.config.max, max: field.config.max,
softMin: customConfig.axisSoftMin, softMin: customConfig.axisSoftMin,
softMax: customConfig.axisSoftMax, softMax: customConfig.axisSoftMax,
orientation: yOri, orientation: vizOrientation.yOri,
direction: yDir, direction: vizOrientation.yDir,
}); });
if (customConfig.axisPlacement !== AxisPlacement.Hidden) { if (customConfig.axisPlacement !== AxisPlacement.Hidden) {
@ -147,7 +149,7 @@ export function preparePlotConfigBuilder(
if (!placement || placement === AxisPlacement.Auto) { if (!placement || placement === AxisPlacement.Auto) {
placement = AxisPlacement.Left; placement = AxisPlacement.Left;
} }
if (xOri === 1) { if (vizOrientation.xOri === 1) {
if (placement === AxisPlacement.Left) { if (placement === AxisPlacement.Left) {
placement = AxisPlacement.Bottom; placement = AxisPlacement.Bottom;
} }
@ -168,7 +170,7 @@ export function preparePlotConfigBuilder(
} }
return builder; return builder;
} };
/** @internal */ /** @internal */
export function preparePlotFrame(data: DataFrame[]) { export function preparePlotFrame(data: DataFrame[]) {

View File

@ -11,7 +11,7 @@ import {
TimeRange, TimeRange,
TimeZone, TimeZone,
} from '@grafana/data'; } from '@grafana/data';
import { preparePlotFrame } from './utils'; import { preparePlotFrame as defaultPreparePlotFrame } from './utils';
import { VizLegendOptions } from '../VizLegend/models.gen'; import { VizLegendOptions } from '../VizLegend/models.gen';
import { PanelContext, PanelContextRoot } from '../PanelChrome/PanelContext'; import { PanelContext, PanelContextRoot } from '../PanelChrome/PanelContext';
@ -38,9 +38,9 @@ export interface GraphNGProps extends Themeable2 {
fields?: XYFieldMatchers; // default will assume timeseries data fields?: XYFieldMatchers; // default will assume timeseries data
onLegendClick?: (event: GraphNGLegendEvent) => void; onLegendClick?: (event: GraphNGLegendEvent) => void;
children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode; children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode;
prepConfig: (alignedFrame: DataFrame, getTimeRange: () => TimeRange) => UPlotConfigBuilder; prepConfig: (alignedFrame: DataFrame, getTimeRange: () => TimeRange) => UPlotConfigBuilder;
propsToDiff?: string[]; propsToDiff?: string[];
preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame;
renderLegend: (config: UPlotConfigBuilder) => React.ReactElement; renderLegend: (config: UPlotConfigBuilder) => React.ReactElement;
} }
@ -84,9 +84,11 @@ export class GraphNG extends React.Component<GraphNGProps, GraphNGState> {
prepState(props: GraphNGProps, withConfig = true) { prepState(props: GraphNGProps, withConfig = true) {
let state: GraphNGState = null as any; let state: GraphNGState = null as any;
const { frames, fields } = props; const { frames, fields, preparePlotFrame } = props;
const alignedFrame = preparePlotFrame( const preparePlotFrameFn = preparePlotFrame || defaultPreparePlotFrame;
const alignedFrame = preparePlotFrameFn(
frames, frames,
fields || { fields || {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),

View File

@ -1,4 +1,4 @@
import { PlotConfig } from '../types'; import { PlotConfig, TooltipInterpolator } from '../types';
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder'; import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder'; import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder'; import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
@ -23,6 +23,11 @@ export class UPlotConfigBuilder {
private tz: string | undefined = undefined; private tz: string | undefined = undefined;
// to prevent more than one threshold per scale // to prevent more than one threshold per scale
private thresholds: Record<string, UPlotThresholdOptions> = {}; private thresholds: Record<string, UPlotThresholdOptions> = {};
/**
* Custom handler for closest datapoint and series lookup. Technicaly returns uPlots setCursor hook
* that sets tooltips state.
*/
tooltipInterpolator: TooltipInterpolator | undefined = undefined;
constructor(timeZone: TimeZone = DefaultTimeZone) { constructor(timeZone: TimeZone = DefaultTimeZone) {
this.tz = getTimeZoneInfo(timeZone, Date.now())?.ianaName; this.tz = getTimeZoneInfo(timeZone, Date.now())?.ianaName;
@ -116,6 +121,10 @@ export class UPlotConfigBuilder {
this.bands.push(band); this.bands.push(band);
} }
setTooltipInterpolator(interpolator: TooltipInterpolator) {
this.tooltipInterpolator = interpolator;
}
getConfig() { getConfig() {
const config: PlotConfig = { series: [{}] }; const config: PlotConfig = { series: [{}] };
config.axes = this.ensureNonOverlappingAxes(Object.values(this.axes)).map((a) => a.getConfig()); config.axes = this.ensureNonOverlappingAxes(Object.values(this.axes)).map((a) => a.getConfig());

View File

@ -38,7 +38,6 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
const plotCtx = usePlotContext(); const plotCtx = usePlotContext();
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null); const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null); const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
const [coords, setCoords] = useState<CartesianCoords2D | null>(null); const [coords, setCoords] = useState<CartesianCoords2D | null>(null);
const pluginId = `TooltipPlugin`; const pluginId = `TooltipPlugin`;
@ -50,6 +49,28 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
// Add uPlot hooks to the config, or re-add when the config changed // Add uPlot hooks to the config, or re-add when the config changed
useLayoutEffect(() => { useLayoutEffect(() => {
if (config.tooltipInterpolator) {
// Custom toolitp positioning
config.addHook('setCursor', (u) => {
config.tooltipInterpolator!(setFocusedSeriesIdx, setFocusedPointIdx, (clear) => {
if (clear) {
setCoords(null);
return;
}
const bbox = plotCtx.getCanvasBoundingBox();
if (!bbox) {
return;
}
const { x, y } = positionTooltip(u, bbox);
if (x !== undefined && y !== undefined) {
setCoords({ x, y });
}
})(u);
});
} else {
// default series/datapoint idx retireval
config.addHook('setCursor', (u) => { config.addHook('setCursor', (u) => {
const bbox = plotCtx.getCanvasBoundingBox(); const bbox = plotCtx.getCanvasBoundingBox();
if (!bbox) { if (!bbox) {
@ -70,7 +91,8 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
config.addHook('setSeries', (_, idx) => { config.addHook('setSeries', (_, idx) => {
setFocusedSeriesIdx(idx); setFocusedSeriesIdx(idx);
}); });
}, [plotCtx, config]); }
}, [plotCtx, config, setFocusedPointIdx, setFocusedSeriesIdx, setCoords]);
const plotInstance = plotCtx.plot; const plotInstance = plotCtx.plot;
if (!plotInstance || focusedPointIdx === null) { if (!plotInstance || focusedPointIdx === null) {

View File

@ -27,3 +27,9 @@ export abstract class PlotConfigBuilder<P, T> {
constructor(public props: P) {} constructor(public props: P) {}
abstract getConfig(): T; abstract getConfig(): T;
} }
export type TooltipInterpolator = (
updateActiveSeriesIdx: (sIdx: number | null) => void,
updateActiveDatapointIdx: (dIdx: number | null) => void,
updateTooltipPosition: (clear?: boolean) => void
) => (u: uPlot) => void;

View File

@ -1,6 +1,6 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { FieldType, PanelProps, VizOrientation } from '@grafana/data'; import { FieldType, PanelProps, TimeRange, VizOrientation } from '@grafana/data';
import { BarChart, BarChartOptions, GraphNGLegendEvent } from '@grafana/ui'; import { BarChart, BarChartOptions, GraphNGLegendEvent, TooltipPlugin } from '@grafana/ui';
import { hideSeriesConfigFactory } from '../timeseries/overrides/hideSeriesConfigFactory'; import { hideSeriesConfigFactory } from '../timeseries/overrides/hideSeriesConfigFactory';
interface Props extends PanelProps<BarChartOptions> {} interface Props extends PanelProps<BarChartOptions> {}
@ -14,6 +14,7 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
width, width,
height, height,
fieldConfig, fieldConfig,
timeZone,
onFieldConfigChange, onFieldConfigChange,
}) => { }) => {
const orientation = useMemo(() => { const orientation = useMemo(() => {
@ -57,13 +58,19 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
return ( return (
<BarChart <BarChart
data={data.series} frames={data.series}
timeZone={timeZone}
timeRange={({ from: 1, to: 1 } as unknown) as TimeRange} // HACK
structureRev={data.structureRev} structureRev={data.structureRev}
width={width} width={width}
height={height} height={height}
onLegendClick={onLegendClick} onLegendClick={onLegendClick}
{...options} {...options}
orientation={orientation} orientation={orientation}
/> >
{(config, alignedFrame) => {
return <TooltipPlugin data={alignedFrame} config={config} mode={options.tooltip.mode} timeZone={timeZone} />;
}}
</BarChart>
); );
}; };

View File

@ -15,6 +15,7 @@ import {
graphFieldOptions, graphFieldOptions,
commonOptionsBuilder, commonOptionsBuilder,
} from '@grafana/ui'; } from '@grafana/ui';
import { defaultBarChartFieldConfig } from '@grafana/ui/src/components/BarChart/types'; import { defaultBarChartFieldConfig } from '@grafana/ui/src/components/BarChart/types';
export const plugin = new PanelPlugin<BarChartOptions, BarChartFieldConfig>(BarChartPanel) export const plugin = new PanelPlugin<BarChartOptions, BarChartFieldConfig>(BarChartPanel)
@ -119,6 +120,7 @@ export const plugin = new PanelPlugin<BarChartOptions, BarChartFieldConfig>(BarC
}, },
}); });
commonOptionsBuilder.addTooltipOptions(builder);
commonOptionsBuilder.addLegendOptions(builder); commonOptionsBuilder.addLegendOptions(builder);
}); });