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 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user