123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616 |
- import uPlot, { Axis, AlignedData, Scale } from 'uplot';
- import { DataFrame, GrafanaTheme2 } from '@grafana/data';
- import { alpha } from '@grafana/data/src/themes/colorManipulator';
- import {
- StackingMode,
- VisibilityMode,
- ScaleDirection,
- ScaleOrientation,
- VizTextDisplayOptions,
- VizLegendOptions,
- } from '@grafana/schema';
- import { measureText, PlotTooltipInterpolator } from '@grafana/ui';
- import { formatTime } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
- import { preparePlotData2, StackingGroup } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
- import { distribute, SPACE_BETWEEN } from './distribute';
- import { intersects, pointWithin, Quadtree, Rect } from './quadtree';
- 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 bar in % of bar width
- const LABEL_OFFSET_FACTOR_VT = 0.1;
- const LABEL_OFFSET_FACTOR_HZ = 0.15;
- // max distance
- const LABEL_OFFSET_MAX_VT = 5;
- const LABEL_OFFSET_MAX_HZ = 10;
- // text baseline middle runs through the middle of lowercase letters
- // since bar values are numbers and uppercase-like, we want the middle of uppercase
- // this is a cheap fudge factor that skips expensive and inconsistent cross-browser measuring
- const MIDDLE_BASELINE_SHIFT = 0.1;
- /**
- * @internal
- */
- export interface BarsOptions {
- xOri: ScaleOrientation;
- xDir: ScaleDirection;
- groupWidth: number;
- barWidth: number;
- barRadius: number;
- showValue: VisibilityMode;
- stacking: StackingMode;
- rawValue: (seriesIdx: number, valueIdx: number) => number | null;
- getColor?: (seriesIdx: number, valueIdx: number, value: any) => string | null;
- fillOpacity?: number;
- formatValue: (seriesIdx: number, value: any) => string;
- text?: VizTextDisplayOptions;
- onHover?: (seriesIdx: number, valueIdx: number) => void;
- onLeave?: (seriesIdx: number, valueIdx: number) => void;
- legend?: VizLegendOptions;
- xSpacing?: number;
- xTimeAuto?: boolean;
- }
- /**
- * @internal
- */
- interface ValueLabelTable {
- [index: number]: ValueLabelArray;
- }
- /**
- * @internal
- */
- interface ValueLabelArray {
- [index: number]: ValueLabel;
- }
- /**
- * @internal
- */
- interface ValueLabel {
- text: string;
- value: number | null;
- hidden: boolean;
- bbox?: Rect;
- textMetrics?: TextMetrics;
- x?: number;
- y?: number;
- }
- /**
- * @internal
- */
- function calculateFontSizeWithMetrics(
- text: string,
- width: number,
- height: number,
- lineHeight: number,
- maxSize?: number
- ) {
- // calculate width in 14px
- const textSize = measureText(text, 14);
- // how much bigger than 14px can we make it while staying within our width constraints
- const fontSizeBasedOnWidth = (width / (textSize.width + 2)) * 14;
- const fontSizeBasedOnHeight = height / lineHeight;
- // final fontSize
- const optimalSize = Math.min(fontSizeBasedOnHeight, fontSizeBasedOnWidth);
- return {
- fontSize: Math.min(optimalSize, maxSize ?? optimalSize),
- textMetrics: textSize,
- };
- }
- /**
- * @internal
- */
- export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
- const { xOri, xDir: dir, rawValue, getColor, formatValue, fillOpacity = 1, showValue, xSpacing = 0 } = opts;
- const isXHorizontal = xOri === ScaleOrientation.Horizontal;
- const hasAutoValueSize = !Boolean(opts.text?.valueSize);
- const isStacked = opts.stacking !== StackingMode.None;
- const pctStacked = opts.stacking === StackingMode.Percent;
- let { groupWidth, barWidth, barRadius = 0 } = opts;
- if (isStacked) {
- [groupWidth, barWidth] = [barWidth, groupWidth];
- }
- let qt: Quadtree;
- let hRect: Rect | null;
- const xSplits: Axis.Splits = (u: uPlot) => {
- const dim = isXHorizontal ? u.bbox.width : u.bbox.height;
- const _dir = dir * (isXHorizontal ? 1 : -1);
- let dataLen = u.data[0].length;
- let lastIdx = dataLen - 1;
- let skipMod = 0;
- if (xSpacing !== 0) {
- let cssDim = dim / devicePixelRatio;
- let maxTicks = Math.abs(Math.floor(cssDim / xSpacing));
- skipMod = dataLen < maxTicks ? 0 : Math.ceil(dataLen / maxTicks);
- }
- let splits: number[] = [];
- // for distr: 2 scales, the splits array should contain indices into data[0] rather than values
- u.data[0].forEach((v, i) => {
- let shouldSkip = skipMod !== 0 && (xSpacing > 0 ? i : lastIdx - i) % skipMod > 0;
- if (!shouldSkip) {
- splits.push(i);
- }
- });
- return _dir === 1 ? splits : splits.reverse();
- };
- // the splits passed into here are data[0] values looked up by the indices returned from splits()
- const xValues: Axis.Values = (u, splits, axisIdx, foundSpace, foundIncr) => {
- if (opts.xTimeAuto) {
- // bit of a hack:
- // temporarily set x scale range to temporal (as expected by formatTime()) rather than ordinal
- let xScale = u.scales.x;
- let oMin = xScale.min;
- let oMax = xScale.max;
- xScale.min = u.data[0][0];
- xScale.max = u.data[0][u.data[0].length - 1];
- let vals = formatTime(u, splits, axisIdx, foundSpace, foundIncr);
- // revert
- xScale.min = oMin;
- xScale.max = oMax;
- return vals;
- }
- return splits.map((v) => formatValue(0, v));
- };
- // this expands the distr: 2 scale so that the indicies of each data[0] land at the proper justified positions
- const xRange: Scale.Range = (u, min, max) => {
- min = 0;
- max = Math.max(1, u.data[0].length - 1);
- let pctOffset = 0;
- // how far in is the first tick in % of full dimension
- distribute(u.data[0].length, groupWidth, groupDistr, 0, (di, lftPct, widPct) => {
- pctOffset = lftPct + widPct / 2;
- });
- // expand scale range by equal amounts on both ends
- let rn = max - min;
- if (pctOffset === 0.5) {
- min -= rn;
- } else {
- let upScale = 1 / (1 - pctOffset * 2);
- let offset = (upScale * rn - rn) / 2;
- min -= offset;
- max += offset;
- }
- return [min, max];
- };
- let distrTwo = (groupCount: number, barCount: number) => {
- let out = Array.from({ length: barCount }, () => ({
- offs: Array(groupCount).fill(0),
- size: Array(groupCount).fill(0),
- }));
- distribute(groupCount, groupWidth, groupDistr, null, (groupIdx, groupOffPct, groupDimPct) => {
- distribute(barCount, barWidth, barDistr, null, (barIdx, barOffPct, barDimPct) => {
- out[barIdx].offs[groupIdx] = groupOffPct + groupDimPct * barOffPct;
- out[barIdx].size[groupIdx] = groupDimPct * barDimPct;
- });
- });
- return out;
- };
- let distrOne = (groupCount: number, barCount: number) => {
- let out = Array.from({ length: barCount }, () => ({
- offs: Array(groupCount).fill(0),
- size: Array(groupCount).fill(0),
- }));
- distribute(groupCount, groupWidth, groupDistr, null, (groupIdx, groupOffPct, groupDimPct) => {
- distribute(barCount, barWidth, barDistr, null, (barIdx, barOffPct, barDimPct) => {
- out[barIdx].offs[groupIdx] = groupOffPct;
- out[barIdx].size[groupIdx] = groupDimPct;
- });
- });
- return out;
- };
- const LABEL_OFFSET_FACTOR = isXHorizontal ? LABEL_OFFSET_FACTOR_VT : LABEL_OFFSET_FACTOR_HZ;
- const LABEL_OFFSET_MAX = isXHorizontal ? LABEL_OFFSET_MAX_VT : LABEL_OFFSET_MAX_HZ;
- let barsPctLayout: Array<null | { offs: number[]; size: number[] }> = [];
- let barsColors: Array<null | { fill: Array<string | null>; stroke: Array<string | null> }> = [];
- let scaleFactor = 1;
- let labels: ValueLabelTable;
- let fontSize = opts.text?.valueSize ?? VALUE_MAX_FONT_SIZE;
- let labelOffset = LABEL_OFFSET_MAX;
- // minimum available space for labels between bar end and plotting area bound (in canvas pixels)
- let vSpace = Infinity;
- let hSpace = Infinity;
- let useMappedColors = getColor != null;
- let mappedColorDisp = useMappedColors
- ? {
- fill: {
- unit: 3,
- values: (u: uPlot, seriesIdx: number) => barsColors[seriesIdx]!.fill,
- },
- stroke: {
- unit: 3,
- values: (u: uPlot, seriesIdx: number) => barsColors[seriesIdx]!.stroke,
- },
- }
- : {};
- let barsBuilder = uPlot.paths.bars!({
- radius: barRadius,
- disp: {
- x0: {
- unit: 2,
- values: (u, seriesIdx) => barsPctLayout[seriesIdx]!.offs,
- },
- size: {
- unit: 2,
- values: (u, seriesIdx) => barsPctLayout[seriesIdx]!.size,
- },
- ...mappedColorDisp,
- },
- // collect rendered bar geometry
- each: (u, seriesIdx, dataIdx, lft, top, wid, hgt) => {
- // we get back raw canvas coords (included axes & padding)
- // translate to the plotting area origin
- lft -= u.bbox.left;
- top -= u.bbox.top;
- let val = u.data[seriesIdx][dataIdx]!;
- // accum min space abvailable for labels
- if (isXHorizontal) {
- vSpace = Math.min(vSpace, val < 0 ? u.bbox.height - (top + hgt) : top);
- hSpace = wid;
- } else {
- vSpace = hgt;
- hSpace = Math.min(hSpace, val < 0 ? lft : u.bbox.width - (lft + wid));
- }
- let barRect = { x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx };
- qt.add(barRect);
- if (showValue !== VisibilityMode.Never) {
- const raw = rawValue(seriesIdx, dataIdx)!;
- let divider = 1;
- if (pctStacked && alignedTotals![seriesIdx][dataIdx]!) {
- divider = alignedTotals![seriesIdx][dataIdx]!;
- }
- const v = divider === 0 ? 0 : raw / divider;
- // Format Values and calculate label offsets
- const text = formatValue(seriesIdx, v);
- labelOffset = Math.min(labelOffset, Math.round(LABEL_OFFSET_FACTOR * (isXHorizontal ? wid : hgt)));
- if (labels[dataIdx] === undefined) {
- labels[dataIdx] = {};
- }
- labels[dataIdx][seriesIdx] = { text: text, value: rawValue(seriesIdx, dataIdx), hidden: false };
- // Calculate font size when it's set to be automatic
- if (hasAutoValueSize) {
- const { fontSize: calculatedSize, textMetrics } = calculateFontSizeWithMetrics(
- labels[dataIdx][seriesIdx].text,
- hSpace * (isXHorizontal ? BAR_FONT_SIZE_RATIO : 1) - (isXHorizontal ? 0 : labelOffset),
- vSpace * (isXHorizontal ? 1 : BAR_FONT_SIZE_RATIO) - (isXHorizontal ? labelOffset : 0),
- 1
- );
- // Save text metrics
- labels[dataIdx][seriesIdx].textMetrics = textMetrics;
- // Retrieve the new font size and use it
- let autoFontSize = Math.round(Math.min(fontSize, VALUE_MAX_FONT_SIZE, calculatedSize));
- // Calculate the scaling factor for bouding boxes
- // Take into account the fact that calculateFontSize
- // uses 14px measurement so we need to adjust the scale factor
- scaleFactor = (autoFontSize / fontSize) * (autoFontSize / 14);
- // Update the end font-size
- fontSize = autoFontSize;
- } else {
- labels[dataIdx][seriesIdx].textMetrics = measureText(labels[dataIdx][seriesIdx].text, fontSize);
- }
- let middleShift = isXHorizontal ? 0 : -Math.round(MIDDLE_BASELINE_SHIFT * fontSize);
- let value = rawValue(seriesIdx, dataIdx);
- if (value != null) {
- // Calculate final co-ordinates for text position
- const x =
- u.bbox.left + (isXHorizontal ? lft + wid / 2 : value < 0 ? lft - labelOffset : lft + wid + labelOffset);
- const y =
- u.bbox.top +
- (isXHorizontal ? (value < 0 ? top + hgt + labelOffset : top - labelOffset) : top + hgt / 2 - middleShift);
- // Retrieve textMetrics with necessary default values
- // These _shouldn't_ be undefined at this point
- // but they _could_ be.
- const {
- textMetrics = {
- width: 1,
- actualBoundingBoxAscent: 1,
- actualBoundingBoxDescent: 1,
- },
- } = labels[dataIdx][seriesIdx];
- // Adjust bounding boxes based on text scale
- // factor and orientation (which changes the baseline)
- let xAdjust = 0,
- yAdjust = 0;
- if (isXHorizontal) {
- // Adjust for baseline which is "top" in this case
- xAdjust = (textMetrics.width * scaleFactor) / 2;
- // yAdjust only matters when when the value isn't negative
- yAdjust =
- value > 0
- ? (textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) * scaleFactor
- : 0;
- } else {
- // Adjust from the baseline which is "middle" in this case
- yAdjust = ((textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) * scaleFactor) / 2;
- // Adjust for baseline being "right" in the x direction
- xAdjust = value < 0 ? textMetrics.width * scaleFactor : 0;
- }
- // Construct final bounding box for the label text
- labels[dataIdx][seriesIdx].x = x;
- labels[dataIdx][seriesIdx].y = y;
- labels[dataIdx][seriesIdx].bbox = {
- x: x - xAdjust,
- y: y - yAdjust,
- w: textMetrics.width * scaleFactor,
- h: (textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) * scaleFactor,
- };
- }
- }
- },
- });
- const init = (u: uPlot) => {
- let over = u.over;
- over.style.overflow = 'hidden';
- u.root.querySelectorAll('.u-cursor-pt').forEach((el) => {
- (el as HTMLElement).style.borderRadius = '0';
- });
- };
- const cursor: uPlot.Cursor = {
- x: false,
- y: false,
- drag: {
- x: false,
- y: false,
- },
- dataIdx: (u, seriesIdx) => {
- if (seriesIdx === 1) {
- hRect = 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)) {
- hRect = o;
- }
- });
- }
- return hRect && seriesIdx === hRect.sidx ? hRect.didx : null;
- },
- points: {
- fill: 'rgba(255,255,255,0.4)',
- bbox: (u, seriesIdx) => {
- let isHovered = hRect && seriesIdx === hRect.sidx;
- return {
- left: isHovered ? hRect!.x / devicePixelRatio : -10,
- top: isHovered ? hRect!.y / devicePixelRatio : -10,
- width: isHovered ? hRect!.w / devicePixelRatio : 0,
- height: isHovered ? hRect!.h / devicePixelRatio : 0,
- };
- },
- },
- };
- // Build bars
- const drawClear = (u: uPlot) => {
- qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
- qt.clear();
- // clear the path cache to force drawBars() to rebuild new quadtree
- u.series.forEach((s) => {
- // @ts-ignore
- s._paths = null;
- });
- if (isStacked) {
- //barsPctLayout = [null as any].concat(distrOne(u.data.length - 1, u.data[0].length));
- barsPctLayout = [null as any].concat(distrOne(u.data[0].length, u.data.length - 1));
- } else {
- barsPctLayout = [null as any].concat(distrTwo(u.data[0].length, u.data.length - 1));
- }
- if (useMappedColors) {
- barsColors = [null];
- // map per-bar colors
- for (let i = 1; i < u.data.length; i++) {
- let colors = (u.data[i] as Array<number | null>).map((value, valueIdx) => {
- if (value != null) {
- return getColor!(i, valueIdx, value);
- }
- return null;
- });
- barsColors.push({
- fill: fillOpacity < 1 ? colors.map((c) => (c != null ? alpha(c, fillOpacity) : null)) : colors,
- stroke: colors,
- });
- }
- }
- labels = {};
- fontSize = opts.text?.valueSize ?? VALUE_MAX_FONT_SIZE;
- labelOffset = LABEL_OFFSET_MAX;
- vSpace = hSpace = Infinity;
- };
- // uPlot hook to draw the labels on the bar chart.
- const draw = (u: uPlot) => {
- if (showValue === VisibilityMode.Never || fontSize < VALUE_MIN_FONT_SIZE) {
- return;
- }
- u.ctx.save();
- u.ctx.fillStyle = theme.colors.text.primary;
- u.ctx.font = `${fontSize}px ${theme.typography.fontFamily}`;
- let curAlign: CanvasTextAlign | undefined = undefined,
- curBaseline: CanvasTextBaseline | undefined = undefined;
- for (const didx in labels) {
- // exclude first label from overlap testing
- let first = true;
- for (const sidx in labels[didx]) {
- const label = labels[didx][sidx];
- const { text, value, x = 0, y = 0 } = label;
- let align: CanvasTextAlign = isXHorizontal ? 'center' : value !== null && value < 0 ? 'right' : 'left';
- let baseline: CanvasTextBaseline = isXHorizontal
- ? value !== null && value < 0
- ? 'top'
- : 'alphabetic'
- : 'middle';
- if (align !== curAlign) {
- u.ctx.textAlign = curAlign = align;
- }
- if (baseline !== curBaseline) {
- u.ctx.textBaseline = curBaseline = baseline;
- }
- if (showValue === VisibilityMode.Always) {
- u.ctx.fillText(text, x, y);
- } else if (showValue === VisibilityMode.Auto) {
- let { bbox } = label;
- let intersectsLabel = false;
- if (bbox == null) {
- intersectsLabel = true;
- label.hidden = true;
- } else if (!first) {
- // Test for any collisions
- for (const subsidx in labels[didx]) {
- if (subsidx === sidx) {
- continue;
- }
- const label2 = labels[didx][subsidx];
- const { bbox: bbox2, hidden } = label2;
- if (!hidden && bbox2 && intersects(bbox, bbox2)) {
- intersectsLabel = true;
- label.hidden = true;
- break;
- }
- }
- }
- first = false;
- !intersectsLabel && u.ctx.fillText(text, x, y);
- }
- }
- }
- u.ctx.restore();
- };
- // handle hover interaction with quadtree probing
- const interpolateTooltip: PlotTooltipInterpolator = (
- updateActiveSeriesIdx,
- updateActiveDatapointIdx,
- updateTooltipPosition,
- u
- ) => {
- if (hRect) {
- updateActiveSeriesIdx(hRect.sidx);
- updateActiveDatapointIdx(hRect.didx);
- updateTooltipPosition();
- } else {
- updateTooltipPosition(true);
- }
- };
- let alignedTotals: AlignedData | null = null;
- function prepData(frames: DataFrame[], stackingGroups: StackingGroup[]) {
- alignedTotals = null;
- return preparePlotData2(frames[0], stackingGroups, ({ totals }) => {
- alignedTotals = totals;
- });
- }
- return {
- cursor,
- // scale & axis opts
- xRange,
- xValues,
- xSplits,
- barsBuilder,
- // hooks
- init,
- drawClear,
- draw,
- interpolateTooltip,
- prepData,
- };
- }
|