The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
grafana/public/app/plugins/panel/barchart/bars.ts

398 lines
13 KiB

import uPlot, { Axis, Series } from 'uplot';
import { pointWithin, Quadtree, Rect } from './quadtree';
import { distribute, SPACE_BETWEEN } from './distribute';
import { TooltipInterpolator } from '@grafana/ui/src/components/uPlot/types';
import { BarValueVisibility, ScaleDirection, ScaleOrientation } from '@grafana/ui/src/components/uPlot/config';
import { CartesianCoords2D, GrafanaTheme2 } from '@grafana/data';
import { calculateFontSize, measureText } from '@grafana/ui';
import { VizTextDisplayOptions } from '@grafana/ui/src/options/builder';
const groupDistr = SPACE_BETWEEN;
const barDistr = SPACE_BETWEEN;
// min.max font size for value label
const VALUE_MIN_FONT_SIZE = 8;
const VALUE_MAX_FONT_SIZE = 30;
// % of width/height of the bar that value should fit in when measuring size
const BAR_FONT_SIZE_RATIO = 0.65;
// distance between label and a horizontal bar
const HORIZONTAL_BAR_LABEL_OFFSET = 10;
/**
* @internal
*/
export interface BarsOptions {
xOri: ScaleOrientation;
xDir: ScaleDirection;
groupWidth: number;
barWidth: number;
showValue: BarValueVisibility;
formatValue: (seriesIdx: number, value: any) => string;
text?: VizTextDisplayOptions;
onHover?: (seriesIdx: number, valueIdx: number) => void;
onLeave?: (seriesIdx: number, valueIdx: number) => void;
}
interface LabelDescriptor extends CartesianCoords2D {
formattedValue: string;
value: number;
textAlign: CanvasTextAlign;
textBaseline: CanvasTextBaseline;
fontSize: number;
barWidth: number;
barHeight: number;
textWidth: number;
}
/**
* @internal
*/
export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
const { xOri: ori, xDir: dir, groupWidth, barWidth, formatValue, showValue } = opts;
const hasAutoValueSize = !Boolean(opts.text?.valueSize);
let qt: Quadtree;
let labelsSizing: Array<LabelDescriptor | null> = [];
const drawBars: Series.PathBuilder = (u, sidx) => {
return uPlot.orient(
u,
sidx,
(series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect) => {
const fill = new Path2D();
const stroke = new Path2D();
let numGroups = dataX.length;
let barsPerGroup = u.series.length - 1;
let y0Pos = valToPosY(0, scaleY, yDim, yOff);
const _dir = dir * (ori === 0 ? 1 : -1);
walkTwo(groupWidth, barWidth, sidx - 1, numGroups, barsPerGroup, xDim, null, (ix, x0, wid) => {
let left = Math.round(xOff + (_dir === 1 ? x0 : xDim - x0 - wid));
let barWid = Math.round(wid);
const canvas = u.root.querySelector<HTMLDivElement>('.u-over');
const bbox = canvas?.getBoundingClientRect();
if (dataY[ix] != null) {
let yPos = valToPosY(dataY[ix]!, scaleY, yDim, yOff);
let btm = Math.round(Math.max(yPos, y0Pos));
let top = Math.round(Math.min(yPos, y0Pos));
let barHgt = btm - top;
let strokeWidth = series.width || 0;
if (strokeWidth) {
rect(stroke, left + strokeWidth / 2, top + strokeWidth / 2, barWid - strokeWidth, barHgt - strokeWidth);
}
rect(fill, left, top, barWid, barHgt);
let x = ori === ScaleOrientation.Horizontal ? Math.round(left - xOff) : Math.round(top - yOff);
let y = ori === ScaleOrientation.Horizontal ? Math.round(top - yOff) : Math.round(left - xOff);
let width = ori === ScaleOrientation.Horizontal ? barWid : barHgt;
let height = ori === ScaleOrientation.Horizontal ? barHgt : barWid;
qt.add({ x, y, w: width, h: height, sidx: sidx, didx: ix });
// Collect labels sizes and placements
const value = formatValue(sidx, dataY[ix]);
let labelX = ori === ScaleOrientation.Horizontal ? Math.round(left) : Math.round(top);
let labelY = ori === ScaleOrientation.Horizontal ? Math.round(top) : Math.round(left);
let availableSpaceForText;
if (ori === ScaleOrientation.Horizontal) {
availableSpaceForText =
dataY[ix]! >= 0 ? y / devicePixelRatio : bbox!.height - (y + height) / devicePixelRatio;
} else {
availableSpaceForText =
dataY[ix]! >= 0 ? bbox!.width - (x + width) / devicePixelRatio : x / devicePixelRatio;
}
/**
* Snippet below is for debugging the available space for text. Leaving it for the future bugs...
*/
// u.ctx.beginPath();
// u.ctx.strokeStyle = '#0000ff';
// if (dataY[ix]! >= 0) {
// if (ori === ScaleOrientation.Horizontal) {
// u.ctx.moveTo(left, top - availableSpaceForText * devicePixelRatio);
// u.ctx.lineTo(left + width, top - availableSpaceForText * devicePixelRatio);
// u.ctx.lineTo(left + width, top);
// u.ctx.lineTo(left, top);
// } else {
// u.ctx.moveTo(top + width, left);
// u.ctx.lineTo(top + width + availableSpaceForText * devicePixelRatio, left);
// u.ctx.lineTo(top + width + availableSpaceForText * devicePixelRatio, left + height);
// u.ctx.lineTo(top + width, left + height);
// }
// } else {
// if (ori === ScaleOrientation.Horizontal) {
// u.ctx.moveTo(left, top + height + availableSpaceForText * devicePixelRatio);
// u.ctx.lineTo(left + width, top + height + availableSpaceForText * devicePixelRatio);
// u.ctx.lineTo(left + width, top + height);
// u.ctx.lineTo(left, top + height);
// } else {
// u.ctx.moveTo(top, left);
// u.ctx.lineTo(top - availableSpaceForText * devicePixelRatio, left);
// u.ctx.lineTo(top - availableSpaceForText * devicePixelRatio, left + height);
// u.ctx.lineTo(top, left + height);
// }
// }
// u.ctx.closePath();
// u.ctx.stroke();
let fontSize = opts.text?.valueSize ?? VALUE_MIN_FONT_SIZE;
if (hasAutoValueSize) {
const size =
ori === ScaleOrientation.Horizontal
? calculateFontSize(
value,
(width / devicePixelRatio) * BAR_FONT_SIZE_RATIO,
availableSpaceForText * BAR_FONT_SIZE_RATIO,
1
)
: calculateFontSize(
value,
availableSpaceForText,
(height * BAR_FONT_SIZE_RATIO) / devicePixelRatio,
1
);
fontSize = size > VALUE_MAX_FONT_SIZE ? VALUE_MAX_FONT_SIZE : size;
}
const textAlign = ori === ScaleOrientation.Horizontal ? 'center' : 'left';
const textBaseline = (ori === ScaleOrientation.Horizontal ? 'bottom' : 'alphabetic') as CanvasTextBaseline;
const textMeasurement = measureText(value, fontSize * devicePixelRatio);
let labelPosition: CartesianCoords2D = { x: labelX, y: labelY };
// Collect labels szes
labelsSizing.push({
formattedValue: value,
value: dataY[ix]!,
textAlign,
textBaseline,
fontSize: Math.floor(fontSize),
barWidth: width,
barHeight: height,
textWidth: textMeasurement.width,
...labelPosition,
});
} else {
labelsSizing.push(null);
}
});
return {
stroke,
fill,
};
}
);
};
// uPlot hook to draw the labels on the bar chart
const draw = (u: uPlot) => {
let minFontSize = labelsSizing.reduce((min, s) => (s && s.fontSize < min ? s.fontSize : min), Infinity);
if (minFontSize === Infinity) {
return;
}
for (let i = 0; i < labelsSizing.length; i++) {
const label = labelsSizing[i];
let x = 0,
y = 0;
if (label === null) {
continue;
}
const fontSize = hasAutoValueSize ? minFontSize : label.fontSize;
if (showValue === BarValueVisibility.Never) {
return;
}
if (showValue !== BarValueVisibility.Always) {
if (
hasAutoValueSize &&
((ori === ScaleOrientation.Horizontal && label.textWidth > label.barWidth) ||
minFontSize < VALUE_MIN_FONT_SIZE)
) {
return;
}
}
// Calculate final labels positions according to unified text size
const textMeasurement = measureText(label.formattedValue, fontSize * devicePixelRatio);
const actualLineHeight = textMeasurement.fontBoundingBoxAscent + textMeasurement.fontBoundingBoxDescent;
if (ori === ScaleOrientation.Horizontal) {
x = label.x + label.barWidth / 2;
y = label.y + (label.value >= 0 ? 0 : label.barHeight + actualLineHeight);
} else {
x =
label.x +
(label.value >= 0
? label.barWidth + HORIZONTAL_BAR_LABEL_OFFSET
: -textMeasurement.width - HORIZONTAL_BAR_LABEL_OFFSET);
y =
label.y +
(label.barHeight + textMeasurement.actualBoundingBoxAscent + textMeasurement.actualBoundingBoxDescent) / 2;
}
/**
* Snippet below is for debugging the available space for text. Leaving it for the future bugs...
*/
// u.ctx.beginPath();
// u.ctx.fillStyle = '#0000ff';
// u.ctx.arc(label.x, label.y, 10, 0, Math.PI * 2, true);
// u.ctx.closePath();
// u.ctx.fill();
u.ctx.fillStyle = theme.colors.text.primary;
u.ctx.font = `${fontSize * devicePixelRatio}px ${theme.typography.fontFamily}`;
u.ctx.textAlign = label.textAlign;
u.ctx.textBaseline = label.textBaseline;
u.ctx.fillText(label.formattedValue, x, y);
}
return false;
};
const xSplits: Axis.Splits = (u: uPlot) => {
const dim = ori === 0 ? u.bbox.width : u.bbox.height;
const _dir = dir * (ori === 0 ? 1 : -1);
let splits: number[] = [];
distribute(u.data[0].length, groupWidth, groupDistr, null, (di, leftPct, widPct) => {
let groupLftPx = (dim * leftPct) / devicePixelRatio;
let groupWidPx = (dim * widPct) / devicePixelRatio;
let groupCenterPx = groupLftPx + groupWidPx / 2;
splits.push(u.posToVal(groupCenterPx, 'x'));
});
return _dir === 1 ? splits : splits.reverse();
};
const xValues: Axis.Values = (u) => u.data[0];
let hovered: Rect | null = null;
let barMark = document.createElement('div');
barMark.classList.add('bar-mark');
barMark.style.position = 'absolute';
barMark.style.background = 'rgba(255,255,255,0.4)';
const init = (u: uPlot) => {
let over = u.root.querySelector('.u-over')! as HTMLElement;
over.style.overflow = 'hidden';
over.appendChild(barMark);
};
const drawClear = (u: uPlot) => {
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
qt.clear();
labelsSizing = [];
// clear the path cache to force drawBars() to rebuild new quadtree
u.series.forEach((s) => {
// @ts-ignore
s._paths = null;
});
};
// handle hover interaction with quadtree probing
const interpolateBarChartTooltip: TooltipInterpolator = (
updateActiveSeriesIdx,
updateActiveDatapointIdx,
updateTooltipPosition
) => {
return (u: uPlot) => {
let found: Rect | null = null;
let cx = u.cursor.left! * devicePixelRatio;
let cy = u.cursor.top! * devicePixelRatio;
qt.get(cx, cy, 1, 1, (o) => {
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
found = o;
}
});
if (found) {
// prettier-ignore
if (found !== hovered) {
barMark.style.display = '';
barMark.style.left = found!.x / devicePixelRatio + 'px';
barMark.style.top = found!.y / devicePixelRatio + 'px';
barMark.style.width = found!.w / devicePixelRatio + 'px';
barMark.style.height = found!.h / devicePixelRatio + 'px';
hovered = found;
updateActiveSeriesIdx(hovered!.sidx);
updateActiveDatapointIdx(hovered!.didx);
updateTooltipPosition();
}
} else if (hovered != null) {
updateActiveSeriesIdx(hovered!.sidx);
updateActiveDatapointIdx(hovered!.didx);
updateTooltipPosition();
hovered = null;
barMark.style.display = 'none';
} else {
updateTooltipPosition(true);
}
};
};
return {
// scale & axis opts
xValues,
xSplits,
// pathbuilders
drawBars,
draw,
// hooks
init,
drawClear,
interpolateBarChartTooltip,
};
}
type WalkTwoCb = null | ((idx: number, offPx: number, dimPx: number) => void);
function walkTwo(
groupWidth: number,
barWidth: number,
yIdx: number,
xCount: number,
yCount: number,
xDim: number,
xDraw?: WalkTwoCb,
yDraw?: WalkTwoCb
) {
distribute(xCount, groupWidth, groupDistr, null, (ix, offPct, dimPct) => {
let groupOffPx = xDim * offPct;
let groupWidPx = xDim * dimPct;
xDraw && xDraw(ix, groupOffPx, groupWidPx);
yDraw &&
distribute(yCount, barWidth, barDistr, yIdx, (iy, offPct, dimPct) => {
let barOffPx = groupWidPx * offPct;
let barWidPx = groupWidPx * dimPct;
yDraw(ix, groupOffPx + barOffPx, barWidPx);
});
});
}