Hide Barchart Value Labels for Automatic Display When Overlapping (#44809)

* Implement skip list for hiding value labels when Auto is selected

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
pull/45568/head
Kyle Cunningham 3 years ago committed by GitHub
parent e29c38ae27
commit 7a619cbe4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 248
      public/app/plugins/panel/barchart/bars.ts
  2. 9
      public/app/plugins/panel/barchart/quadtree.ts

@ -1,8 +1,8 @@
import uPlot, { Axis, AlignedData, Scale } from 'uplot';
import { pointWithin, Quadtree, Rect } from './quadtree';
import { intersects, pointWithin, Quadtree, Rect } from './quadtree';
import { distribute, SPACE_BETWEEN } from './distribute';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { calculateFontSize, PlotTooltipInterpolator } from '@grafana/ui';
import { measureText, PlotTooltipInterpolator } from '@grafana/ui';
import {
StackingMode,
VisibilityMode,
@ -57,6 +57,57 @@ export interface BarsOptions {
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
*/
@ -191,9 +242,15 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
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 barRects: Rect[] = [];
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;
@ -235,7 +292,6 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
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);
@ -247,7 +303,101 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
let barRect = { x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx };
qt.add(barRect);
barRects.push(barRect);
if (showValue !== VisibilityMode.Never) {
// Format Values and calculate label offsets
const text = formatValue(
seriesIdx,
rawValue(seriesIdx, dataIdx)! / (pctStacked ? alignedTotals![seriesIdx][dataIdx]! : 1)
);
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,
};
}
}
},
});
@ -260,7 +410,6 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
// 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
@ -296,66 +445,35 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
}
}
barRects.length = 0;
labels = {};
fontSize = opts.text?.valueSize ?? VALUE_MAX_FONT_SIZE;
labelOffset = LABEL_OFFSET_MAX;
vSpace = hSpace = Infinity;
};
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;
// uPlot hook to draw the labels on the bar chart.
const draw = (u: uPlot) => {
if (showValue === VisibilityMode.Never) {
if (showValue === VisibilityMode.Never || fontSize < VALUE_MIN_FONT_SIZE) {
return;
}
// pre-cache formatted labels
let texts = Array(barRects.length);
let labelOffset = LABEL_OFFSET_MAX;
barRects.forEach((r, i) => {
texts[i] = formatValue(r.sidx, rawValue(r.sidx, r.didx)! / (pctStacked ? alignedTotals![r.sidx][r.didx]! : 1));
labelOffset = Math.min(labelOffset, Math.round(LABEL_OFFSET_FACTOR * (isXHorizontal ? r.w : r.h)));
});
let fontSize = opts.text?.valueSize ?? VALUE_MAX_FONT_SIZE;
if (hasAutoValueSize) {
for (let i = 0; i < barRects.length; i++) {
fontSize = Math.round(
Math.min(
fontSize,
VALUE_MAX_FONT_SIZE,
calculateFontSize(
texts[i],
hSpace * (isXHorizontal ? BAR_FONT_SIZE_RATIO : 1) - (isXHorizontal ? 0 : labelOffset),
vSpace * (isXHorizontal ? 1 : BAR_FONT_SIZE_RATIO) - (isXHorizontal ? labelOffset : 0),
1
)
)
);
if (fontSize < VALUE_MIN_FONT_SIZE && showValue !== VisibilityMode.Always) {
return;
}
}
}
u.ctx.save();
u.ctx.fillStyle = theme.colors.text.primary;
u.ctx.font = `${fontSize}px ${theme.typography.fontFamily}`;
let middleShift = isXHorizontal ? 0 : -Math.round(MIDDLE_BASELINE_SHIFT * fontSize);
let curAlign: CanvasTextAlign, curBaseline: CanvasTextBaseline;
let curAlign: CanvasTextAlign | undefined = undefined,
curBaseline: CanvasTextBaseline | undefined = undefined;
barRects.forEach((r, i) => {
let value = rawValue(r.sidx, r.didx);
let text = texts[i];
for (const didx in labels) {
for (const sidx in labels[didx]) {
const { text, value, x = 0, y = 0, bbox = { x: 0, y: 0, w: 1, h: 1 } } = labels[didx][sidx];
if (value != null) {
let align: CanvasTextAlign = isXHorizontal ? 'center' : value < 0 ? 'right' : 'left';
let baseline: CanvasTextBaseline = isXHorizontal ? (value < 0 ? 'top' : 'alphabetic') : 'middle';
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;
@ -365,14 +483,26 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
u.ctx.textBaseline = curBaseline = baseline;
}
u.ctx.fillText(
text,
u.bbox.left + (isXHorizontal ? r.x + r.w / 2 : value < 0 ? r.x - labelOffset : r.x + r.w + labelOffset),
u.bbox.top +
(isXHorizontal ? (value < 0 ? r.y + r.h + labelOffset : r.y - labelOffset) : r.y + r.h / 2 - middleShift)
);
if (showValue === VisibilityMode.Always) {
u.ctx.fillText(text, x, y);
} else if (showValue === VisibilityMode.Auto) {
let intersectsLabel = false;
// Test for any collisions
for (const subsidx in labels[didx]) {
const r = labels[didx][subsidx].bbox!;
if (!labels[didx][subsidx].hidden && sidx !== subsidx && intersects(bbox, r)) {
intersectsLabel = true;
labels[didx][sidx].hidden = true;
break;
}
}
!intersectsLabel && u.ctx.fillText(text, x, y);
}
}
});
}
u.ctx.restore();
};

@ -11,6 +11,15 @@ export function pointWithin(px: number, py: number, rlft: number, rtop: number,
return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm;
}
/**
* @internal
*
* Determines if r2 is intersected by r1.
*/
export function intersects(r1: Rect, r2: Rect) {
return r1.x <= r2.x + r2.w && r1.x + r1.w >= r2.x && r1.y + r1.h >= r2.y && r1.y <= r2.y + r2.h;
}
/**
* @internal
*/

Loading…
Cancel
Save