|
|
|
@ -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(); |
|
|
|
|
}; |
|
|
|
|