Timeline: Text align option (#34087)

* Timeline: Text align option, but does not work

* working text alignment

* Refactoring and fixing rendering text values on the right edge that does not fit

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Torkel Ödegaard
2021-05-14 08:27:03 +02:00
committed by GitHub
parent 2fc9c6ca58
commit 96183d70a8
7 changed files with 85 additions and 35 deletions

View File

@@ -17,6 +17,7 @@ export function pointWithin(px: number, py: number, rlft: number, rtop: number,
export class Quadtree {
o: Rect[];
q: Quads | null;
constructor(public x: number, public y: number, public w: number, public h: number, public l: number = 0) {
this.o = [];
this.q = null;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { PanelContext, PanelContextRoot, GraphNG, GraphNGProps, BarValueVisibility } from '@grafana/ui';
import { DataFrame, FieldType, TimeRange } from '@grafana/data';
import { preparePlotConfigBuilder } from './utils';
import { TimelineMode } from './types';
import { TimelineMode, TimelineValueAlignment } from './types';
/**
* @alpha
@@ -11,10 +11,11 @@ export interface TimelineProps extends Omit<GraphNGProps, 'prepConfig' | 'propsT
mode: TimelineMode;
rowHeight: number;
showValue: BarValueVisibility;
alignValue: TimelineValueAlignment;
colWidth?: number;
}
const propsToDiff = ['mode', 'rowHeight', 'colWidth', 'showValue'];
const propsToDiff = ['mode', 'rowHeight', 'colWidth', 'showValue', 'alignValue'];
export class TimelineChart extends React.Component<TimelineProps> {
static contextType = PanelContextRoot;

View File

@@ -39,7 +39,7 @@ export const TimelinePanel: React.FC<TimelinePanelProps> = ({
height={height}
{...options}
>
{(config, alignedDataFrame) => <ZoomPlugin config={config} onZoom={onChangeTimeRange} />}
{(config) => <ZoomPlugin config={config} onZoom={onChangeTimeRange} />}
</TimelineChart>
);
};

View File

@@ -79,6 +79,19 @@ export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(Time
},
defaultValue: BarValueVisibility.Always,
})
.addRadio({
path: 'alignValue',
name: 'Align value',
settings: {
options: [
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' },
],
},
defaultValue: 'center',
showIf: ({ mode }) => mode === TimelineMode.Changes,
})
.addSliderInput({
path: 'rowHeight',
name: 'Row height',

View File

@@ -2,7 +2,7 @@ import uPlot, { Series, Cursor } from 'uplot';
import { FIXED_UNIT } from '@grafana/ui/src/components/GraphNG/GraphNG';
import { Quadtree, Rect, pointWithin } from 'app/plugins/panel/barchart/quadtree';
import { distribute, SPACE_BETWEEN } from 'app/plugins/panel/barchart/distribute';
import { TimelineMode } from './types';
import { TimelineMode, TimelineValueAlignment } from './types';
import { GrafanaTheme2, TimeRange } from '@grafana/data';
import { BarValueVisibility } from '@grafana/ui';
@@ -23,6 +23,11 @@ function walk(rowHeight: number, yIdx: number | null, count: number, dim: number
});
}
interface TimelineBoxRect extends Rect {
left: number;
strokeWidth: number;
}
/**
* @internal
*/
@@ -33,6 +38,7 @@ export interface TimelineCoreOptions {
colWidth?: number;
theme: GrafanaTheme2;
showValue: BarValueVisibility;
alignValue: TimelineValueAlignment;
isDiscrete: (seriesIdx: number) => boolean;
colorLookup: (seriesIdx: number, value: any) => string;
label: (seriesIdx: number) => string;
@@ -55,6 +61,7 @@ export function getConfig(opts: TimelineCoreOptions) {
rowHeight = 0,
colWidth = 0,
showValue,
alignValue,
theme,
label,
fill,
@@ -78,6 +85,15 @@ export function getConfig(opts: TimelineCoreOptions) {
return mark;
});
// alignement-aware text position cache filled by drawPaths->putBox for use in drawPoints
let boxRectsBySeries: TimelineBoxRect[][];
const resetBoxRectsBySeries = (count: number) => {
boxRectsBySeries = Array(numSeries)
.fill(null)
.map((v) => Array(count).fill(null));
};
const font = `500 ${Math.round(12 * devicePixelRatio)}px ${theme.typography.fontFamily}`;
const hovered: Array<Rect | null> = Array(numSeries).fill(null);
@@ -108,10 +124,10 @@ export function getConfig(opts: TimelineCoreOptions) {
rect: uPlot.RectH,
xOff: number,
yOff: number,
lft: number,
left: number,
top: number,
wid: number,
hgt: number,
boxWidth: number,
boxHeight: number,
strokeWidth: number,
seriesIdx: number,
valueIdx: number,
@@ -126,7 +142,7 @@ export function getConfig(opts: TimelineCoreOptions) {
fillPaths.set(fillStyle, (fillPath = new Path2D()));
}
rect(fillPath, lft, top, wid, hgt);
rect(fillPath, left, top, boxWidth, boxHeight);
if (strokeWidth) {
let strokeStyle = stroke(seriesIdx + 1, value);
@@ -136,30 +152,41 @@ export function getConfig(opts: TimelineCoreOptions) {
strokePaths.set(strokeStyle, (strokePath = new Path2D()));
}
rect(strokePath, lft + strokeWidth / 2, top + strokeWidth / 2, wid - strokeWidth, hgt - strokeWidth);
rect(
strokePath,
left + strokeWidth / 2,
top + strokeWidth / 2,
boxWidth - strokeWidth,
boxHeight - strokeWidth
);
}
} else {
ctx.beginPath();
rect(ctx, lft, top, wid, hgt);
rect(ctx, left, top, boxWidth, boxHeight);
ctx.fillStyle = fill(seriesIdx, value);
ctx.fill();
if (strokeWidth) {
ctx.beginPath();
rect(ctx, lft + strokeWidth / 2, top + strokeWidth / 2, wid - strokeWidth, hgt - strokeWidth);
rect(ctx, left + strokeWidth / 2, top + strokeWidth / 2, boxWidth - strokeWidth, boxHeight - strokeWidth);
ctx.strokeStyle = stroke(seriesIdx, value);
ctx.stroke();
}
}
qt.add({
x: round(lft - xOff),
const boxRect = (boxRectsBySeries[seriesIdx][valueIdx] = {
x: round(left - xOff),
y: round(top - yOff),
w: wid,
h: hgt,
w: boxWidth,
h: boxHeight,
sidx: seriesIdx + 1,
didx: valueIdx,
// These two are needed for later text positioning
left: left,
strokeWidth,
});
qt.add(boxRect);
}
const drawPaths: Series.PathBuilder = (u, sidx, idx0, idx1) => {
@@ -259,7 +286,7 @@ export function getConfig(opts: TimelineCoreOptions) {
u.ctx.clip();
u.ctx.font = font;
u.ctx.textAlign = mode === TimelineMode.Changes ? 'left' : 'center';
u.ctx.textAlign = alignValue;
u.ctx.textBaseline = 'middle';
uPlot.orient(
@@ -285,13 +312,14 @@ export function getConfig(opts: TimelineCoreOptions) {
for (let ix = 0; ix < dataY.length; ix++) {
if (dataY[ix] != null) {
let x = valToPosX(dataX[ix], scaleX, xDim, xOff);
const boxRect = boxRectsBySeries[sidx - 1][ix];
// For the left aligned values shift them 2 pixels of edge
if (mode === TimelineMode.Changes) {
x += 2;
// Todo refine this to better know when to not render text (when values do not fit)
if (boxRect.w < 20) {
continue;
}
const x = getTextPositionOffet(boxRect, alignValue);
const valueColor = colorLookup(sidx, dataY[ix]);
u.ctx.fillStyle = theme.colors.getContrastText(valueColor, 3);
@@ -318,6 +346,7 @@ export function getConfig(opts: TimelineCoreOptions) {
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
qt.clear();
resetBoxRectsBySeries(u.data[0].length);
// force-clear the path cache to cause drawBars() to rebuild new quadtree
u.series.forEach((s) => {
@@ -440,3 +469,16 @@ export function getConfig(opts: TimelineCoreOptions) {
setCursor,
};
}
function getTextPositionOffet(rect: TimelineBoxRect, alignValue: TimelineValueAlignment) {
// left or right aligned values shift 2 pixels inside edge
const textPadding = alignValue === 'left' ? 2 : alignValue === 'right' ? -2 : 0;
const { left, w, strokeWidth } = rect;
return (
left +
strokeWidth / 2 +
(alignValue === 'center' ? w / 2 - strokeWidth / 2 : alignValue === 'right' ? w - strokeWidth / 2 : 0) +
textPadding
);
}

View File

@@ -9,8 +9,11 @@ export interface TimelineOptions {
showValue: BarValueVisibility;
rowHeight: number;
colWidth?: number;
alignValue: TimelineValueAlignment;
}
export type TimelineValueAlignment = 'center' | 'left' | 'right';
/**
* @alpha
*/

View File

@@ -9,13 +9,7 @@ import {
Field,
FALLBACK_COLOR,
} from '@grafana/data';
import {
UPlotConfigBuilder,
FIXED_UNIT,
SeriesVisibilityChangeMode,
BarValueVisibility,
UPlotConfigPrepFn,
} from '@grafana/ui';
import { UPlotConfigBuilder, FIXED_UNIT, SeriesVisibilityChangeMode, UPlotConfigPrepFn } from '@grafana/ui';
import { TimelineCoreOptions, getConfig } from './timeline';
import {
AxisPlacement,
@@ -25,7 +19,7 @@ import {
} from '@grafana/ui/src/components/uPlot/config';
import { measureText } from '@grafana/ui/src/utils/measureText';
import { TimelineFieldConfig, TimelineMode } from './types';
import { TimelineFieldConfig, TimelineOptions } from './types';
const defaultConfig: TimelineFieldConfig = {
lineWidth: 0,
@@ -49,21 +43,16 @@ export function preparePlotFrame(data: DataFrame[], dimFields: XYFieldMatchers)
});
}
export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
mode: TimelineMode;
rowHeight: number;
colWidth?: number;
showValue: BarValueVisibility;
}> = ({
export const preparePlotConfigBuilder: UPlotConfigPrepFn<TimelineOptions> = ({
frame,
theme,
timeZone,
getTimeRange,
mode,
rowHeight,
colWidth,
showValue,
alignValue,
}) => {
const builder = new UPlotConfigBuilder(timeZone);
@@ -101,6 +90,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
rowHeight: rowHeight!,
colWidth: colWidth,
showValue: showValue!,
alignValue,
theme,
label: (seriesIdx) => getFieldDisplayName(frame.fields[seriesIdx], frame),
fill: colorLookup,