diff --git a/public/app/plugins/panel/barchart/quadtree.ts b/public/app/plugins/panel/barchart/quadtree.ts index dba30187da0..76648eaa5dc 100644 --- a/public/app/plugins/panel/barchart/quadtree.ts +++ b/public/app/plugins/panel/barchart/quadtree.ts @@ -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; diff --git a/public/app/plugins/panel/timeline/TimelineChart.tsx b/public/app/plugins/panel/timeline/TimelineChart.tsx index 2af6f15aac1..05981419560 100755 --- a/public/app/plugins/panel/timeline/TimelineChart.tsx +++ b/public/app/plugins/panel/timeline/TimelineChart.tsx @@ -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 { static contextType = PanelContextRoot; diff --git a/public/app/plugins/panel/timeline/TimelinePanel.tsx b/public/app/plugins/panel/timeline/TimelinePanel.tsx index ea2e47f9a01..c81a5113947 100755 --- a/public/app/plugins/panel/timeline/TimelinePanel.tsx +++ b/public/app/plugins/panel/timeline/TimelinePanel.tsx @@ -39,7 +39,7 @@ export const TimelinePanel: React.FC = ({ height={height} {...options} > - {(config, alignedDataFrame) => } + {(config) => } ); }; diff --git a/public/app/plugins/panel/timeline/module.tsx b/public/app/plugins/panel/timeline/module.tsx index ea2fe5be0fd..c9394a2f97c 100755 --- a/public/app/plugins/panel/timeline/module.tsx +++ b/public/app/plugins/panel/timeline/module.tsx @@ -79,6 +79,19 @@ export const plugin = new PanelPlugin(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', diff --git a/public/app/plugins/panel/timeline/timeline.ts b/public/app/plugins/panel/timeline/timeline.ts index 23ec8ad52b4..da9f8c763cd 100644 --- a/public/app/plugins/panel/timeline/timeline.ts +++ b/public/app/plugins/panel/timeline/timeline.ts @@ -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 = 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 + ); +} diff --git a/public/app/plugins/panel/timeline/types.ts b/public/app/plugins/panel/timeline/types.ts index aaf3008760f..1bdad8abd7a 100644 --- a/public/app/plugins/panel/timeline/types.ts +++ b/public/app/plugins/panel/timeline/types.ts @@ -9,8 +9,11 @@ export interface TimelineOptions { showValue: BarValueVisibility; rowHeight: number; colWidth?: number; + alignValue: TimelineValueAlignment; } +export type TimelineValueAlignment = 'center' | 'left' | 'right'; + /** * @alpha */ diff --git a/public/app/plugins/panel/timeline/utils.ts b/public/app/plugins/panel/timeline/utils.ts index addbf7cacd4..16017cae165 100644 --- a/public/app/plugins/panel/timeline/utils.ts +++ b/public/app/plugins/panel/timeline/utils.ts @@ -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 = ({ 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,