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:
co-authored by
Dominik Prokop
parent
e29c38ae27
commit
7a619cbe4e
@@ -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 | 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 value = rawValue(r.sidx, r.didx);
|
||||
let text = texts[i];
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user