From 7a619cbe4e8fa949dfb30da7a4f48c9f79e6593d Mon Sep 17 00:00:00 2001 From: Kyle Cunningham Date: Thu, 17 Feb 2022 14:07:50 -0600 Subject: [PATCH] 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 --- public/app/plugins/panel/barchart/bars.ts | 248 +++++++++++++----- public/app/plugins/panel/barchart/quadtree.ts | 9 + 2 files changed, 198 insertions(+), 59 deletions(-) diff --git a/public/app/plugins/panel/barchart/bars.ts b/public/app/plugins/panel/barchart/bars.ts index eb9437b53e9..a8e79ca1942 100644 --- a/public/app/plugins/panel/barchart/bars.ts +++ b/public/app/plugins/panel/barchart/bars.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 = []; let barsColors: Array; stroke: Array }> = []; - 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(); }; diff --git a/public/app/plugins/panel/barchart/quadtree.ts b/public/app/plugins/panel/barchart/quadtree.ts index 76648eaa5dc..dd214972c39 100644 --- a/public/app/plugins/panel/barchart/quadtree.ts +++ b/public/app/plugins/panel/barchart/quadtree.ts @@ -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 */