mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
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.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.over;
|
|
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 {
|
|
cursor: {
|
|
x: false,
|
|
y: false,
|
|
},
|
|
// 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);
|
|
});
|
|
});
|
|
}
|