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>
This commit is contained in:
Kyle Cunningham
2022-02-17 14:07:50 -06:00
committed by GitHub
co-authored by Dominik Prokop
parent e29c38ae27
commit 7a619cbe4e
2 changed files with 198 additions and 59 deletions
+189 -59
View File
@@ -1,8 +1,8 @@
import uPlot, { Axis, AlignedData, Scale } from 'uplot'; 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 { distribute, SPACE_BETWEEN } from './distribute';
import { DataFrame, GrafanaTheme2 } from '@grafana/data'; import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { calculateFontSize, PlotTooltipInterpolator } from '@grafana/ui'; import { measureText, PlotTooltipInterpolator } from '@grafana/ui';
import { import {
StackingMode, StackingMode,
VisibilityMode, VisibilityMode,
@@ -57,6 +57,57 @@ export interface BarsOptions {
xTimeAuto?: boolean; 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 * @internal
*/ */
@@ -191,9 +242,15 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
return out; 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 barsPctLayout: Array<null | { offs: number[]; size: number[] }> = [];
let barsColors: Array<null | { fill: Array<string | null>; stroke: Array<string | null> }> = []; 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) // minimum available space for labels between bar end and plotting area bound (in canvas pixels)
let vSpace = Infinity; let vSpace = Infinity;
@@ -235,7 +292,6 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
top -= u.bbox.top; top -= u.bbox.top;
let val = u.data[seriesIdx][dataIdx]!; let val = u.data[seriesIdx][dataIdx]!;
// accum min space abvailable for labels // accum min space abvailable for labels
if (isXHorizontal) { if (isXHorizontal) {
vSpace = Math.min(vSpace, val < 0 ? u.bbox.height - (top + hgt) : top); 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 }; let barRect = { x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx };
qt.add(barRect); 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 // Build bars
const drawClear = (u: uPlot) => { const drawClear = (u: uPlot) => {
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
qt.clear(); qt.clear();
// clear the path cache to force drawBars() to rebuild new quadtree // 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; 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. // uPlot hook to draw the labels on the bar chart.
const draw = (u: uPlot) => { const draw = (u: uPlot) => {
if (showValue === VisibilityMode.Never) { if (showValue === VisibilityMode.Never || fontSize < VALUE_MIN_FONT_SIZE) {
return; 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.save();
u.ctx.fillStyle = theme.colors.text.primary; u.ctx.fillStyle = theme.colors.text.primary;
u.ctx.font = `${fontSize}px ${theme.typography.fontFamily}`; u.ctx.font = `${fontSize}px ${theme.typography.fontFamily}`;
let middleShift = isXHorizontal ? 0 : -Math.round(MIDDLE_BASELINE_SHIFT * fontSize); let curAlign: CanvasTextAlign | undefined = undefined,
curBaseline: CanvasTextBaseline | undefined = undefined;
let curAlign: CanvasTextAlign, curBaseline: CanvasTextBaseline; 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];
barRects.forEach((r, i) => { let align: CanvasTextAlign = isXHorizontal ? 'center' : value !== null && value < 0 ? 'right' : 'left';
let value = rawValue(r.sidx, r.didx); let baseline: CanvasTextBaseline = isXHorizontal
let text = texts[i]; ? value !== null && value < 0
? 'top'
if (value != null) { : 'alphabetic'
let align: CanvasTextAlign = isXHorizontal ? 'center' : value < 0 ? 'right' : 'left'; : 'middle';
let baseline: CanvasTextBaseline = isXHorizontal ? (value < 0 ? 'top' : 'alphabetic') : 'middle';
if (align !== curAlign) { if (align !== curAlign) {
u.ctx.textAlign = curAlign = align; u.ctx.textAlign = curAlign = align;
@@ -365,14 +483,26 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
u.ctx.textBaseline = curBaseline = baseline; u.ctx.textBaseline = curBaseline = baseline;
} }
u.ctx.fillText( if (showValue === VisibilityMode.Always) {
text, u.ctx.fillText(text, x, y);
u.bbox.left + (isXHorizontal ? r.x + r.w / 2 : value < 0 ? r.x - labelOffset : r.x + r.w + labelOffset), } else if (showValue === VisibilityMode.Auto) {
u.bbox.top + let intersectsLabel = false;
(isXHorizontal ? (value < 0 ? r.y + r.h + labelOffset : r.y - labelOffset) : r.y + r.h / 2 - middleShift)
); // 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(); 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; 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 * @internal
*/ */