diff --git a/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json b/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json new file mode 100644 index 00000000000..a7b26f42270 --- /dev/null +++ b/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json @@ -0,0 +1,577 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 441, + "links": [], + "panels": [ + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "graph": false, + "legend": false, + "tooltip": false + }, + "lineWidth": 0 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 13, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 9, + "options": { + "barWidth": 1, + "groupWidth": 1, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "orientation": "auto", + "showValue": "auto", + "text": {}, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "refId": "A", + "scenarioId": "categorical_data" + } + ], + "title": "Plenty od data, automatic value sizing", + "type": "barchart" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "graph": false, + "legend": false, + "tooltip": false + }, + "lineWidth": 0 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 13 + }, + "id": 10, + "options": { + "barWidth": 1, + "groupWidth": 1, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "orientation": "auto", + "showValue": "auto", + "text": { + "size": 10, + "valueSize": 10 + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "refId": "A", + "scenarioId": "categorical_data" + } + ], + "title": "Plenty od data, fixed value sizing", + "type": "barchart" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "graph": false, + "legend": false, + "tooltip": false + }, + "lineWidth": 0 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 12, + "x": 0, + "y": 25 + }, + "id": 11, + "options": { + "barWidth": 1, + "groupWidth": 1, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "orientation": "auto", + "showValue": "auto", + "text": { + "size": 10 + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "refId": "A", + "scenarioId": "categorical_data" + } + ], + "title": "Auto font size, value auto visible", + "transformations": [ + { + "id": "filterByValue", + "options": { + "filters": [ + { + "config": { + "id": "equal", + "options": { + "value": "Bedroom" + } + }, + "fieldName": "location" + }, + { + "config": { + "id": "equal", + "options": { + "value": "Cellar" + } + }, + "fieldName": "location" + } + ], + "match": "any", + "type": "include" + } + } + ], + "type": "barchart" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "graph": false, + "legend": false, + "tooltip": false + }, + "lineWidth": 0 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 12, + "x": 12, + "y": 25 + }, + "id": 12, + "options": { + "barWidth": 1, + "groupWidth": 1, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "orientation": "auto", + "showValue": "always", + "text": { + "size": 10 + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "refId": "A", + "scenarioId": "categorical_data" + } + ], + "title": "Auto font size, value always visible", + "transformations": [ + { + "id": "filterByValue", + "options": { + "filters": [ + { + "config": { + "id": "equal", + "options": { + "value": "Bedroom" + } + }, + "fieldName": "location" + }, + { + "config": { + "id": "equal", + "options": { + "value": "Cellar" + } + }, + "fieldName": "location" + } + ], + "match": "any", + "type": "include" + } + } + ], + "type": "barchart" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "graph": false, + "legend": false, + "tooltip": false + }, + "lineWidth": 0 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 12, + "x": 0, + "y": 37 + }, + "id": 13, + "options": { + "barWidth": 1, + "groupWidth": 1, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "orientation": "horizontal", + "showValue": "auto", + "text": { + "size": 10 + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "refId": "A", + "scenarioId": "categorical_data" + } + ], + "title": "Auto font size, value auto visible", + "transformations": [ + { + "id": "filterByValue", + "options": { + "filters": [ + { + "config": { + "id": "equal", + "options": { + "value": "Bedroom" + } + }, + "fieldName": "location" + }, + { + "config": { + "id": "equal", + "options": { + "value": "Cellar" + } + }, + "fieldName": "location" + } + ], + "match": "any", + "type": "include" + } + } + ], + "type": "barchart" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "graph": false, + "legend": false, + "tooltip": false + }, + "lineWidth": 0 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 12, + "x": 12, + "y": 37 + }, + "id": 14, + "options": { + "barWidth": 1, + "groupWidth": 1, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "orientation": "horizontal", + "showValue": "always", + "text": { + "size": 10 + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "refId": "A", + "scenarioId": "categorical_data" + } + ], + "title": "Auto font size, value always visible", + "transformations": [ + { + "id": "filterByValue", + "options": { + "filters": [ + { + "config": { + "id": "equal", + "options": { + "value": "Bedroom" + } + }, + "fieldName": "location" + }, + { + "config": { + "id": "equal", + "options": { + "value": "Cellar" + } + }, + "fieldName": "location" + } + ], + "match": "any", + "type": "include" + } + } + ], + "type": "barchart" + } + ], + "refresh": "", + "schemaVersion": 30, + "style": "dark", + "tags": [ + "gdev", + "panel-tests", + "barchart" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "BarChart - value text sizing", + "uid": "WFlOM-jM1", + "version": 9 +} diff --git a/packages/grafana-data/src/types/displayValue.ts b/packages/grafana-data/src/types/displayValue.ts index 4d0a42d9c4f..781ae4203e3 100644 --- a/packages/grafana-data/src/types/displayValue.ts +++ b/packages/grafana-data/src/types/displayValue.ts @@ -20,14 +20,14 @@ export interface DisplayValue extends FormattedValue { /** * Explicit control for text settings + * @deprecated Use VizTextDisplayOptions from @grafana/ui instead */ -export interface TextDisplayOptions { +export type TextDisplayOptions = { /* Explicit text size */ titleSize?: number; - /* Explicit text size */ valueSize?: number; -} +}; /** * These represents the display value with the longest title and text. diff --git a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts index 0238ac9290f..039c33b9f3c 100644 --- a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts +++ b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts @@ -15,13 +15,12 @@ import { ThresholdsConfig, validateFieldConfig, FieldColorModeId, - TextDisplayOptions, } from '@grafana/data'; +import { OptionsWithTextFormatting } from '../../options'; -export interface SingleStatBaseOptions { +export interface SingleStatBaseOptions extends OptionsWithTextFormatting { reduceOptions: ReduceDataOptions; orientation: VizOrientation; - text?: TextDisplayOptions; } const optionsToKeep = ['reduceOptions', 'orientation']; diff --git a/packages/grafana-ui/src/options/builder/index.ts b/packages/grafana-ui/src/options/builder/index.ts index b86da47ddb6..e8f41207149 100644 --- a/packages/grafana-ui/src/options/builder/index.ts +++ b/packages/grafana-ui/src/options/builder/index.ts @@ -2,4 +2,5 @@ export * from './axis'; export * from './hideSeries'; export * from './legend'; export * from './tooltip'; +export * from './text'; export * from './stacking'; diff --git a/packages/grafana-ui/src/options/builder/text.tsx b/packages/grafana-ui/src/options/builder/text.tsx new file mode 100644 index 00000000000..2f87e8d64a2 --- /dev/null +++ b/packages/grafana-ui/src/options/builder/text.tsx @@ -0,0 +1,52 @@ +import { OptionsWithTextFormatting } from '../models.gen'; +import { PanelOptionsEditorBuilder } from '@grafana/data'; + +/** + * Explicit control for visualization text settings + * @public + **/ +export interface VizTextDisplayOptions { + /* Explicit title text size */ + titleSize?: number; + /* Explicit value text size */ + valueSize?: number; +} + +/** + * Adds common text control options to a visualization options + * @param builder + * @param withTitle + * @public + */ +export function addTextSizeOptions( + builder: PanelOptionsEditorBuilder, + withTitle = true +) { + if (withTitle) { + builder.addNumberInput({ + path: 'text.titleSize', + category: ['Text size'], + name: 'Title', + settings: { + placeholder: 'Auto', + integer: false, + min: 1, + max: 200, + }, + defaultValue: undefined, + }); + } + + builder.addNumberInput({ + path: 'text.valueSize', + category: ['Text size'], + name: 'Value', + settings: { + placeholder: 'Auto', + integer: false, + min: 1, + max: 200, + }, + defaultValue: undefined, + }); +} diff --git a/packages/grafana-ui/src/options/models.gen.ts b/packages/grafana-ui/src/options/models.gen.ts index 00629aef64d..45cd632b50a 100644 --- a/packages/grafana-ui/src/options/models.gen.ts +++ b/packages/grafana-ui/src/options/models.gen.ts @@ -1,14 +1,25 @@ // TODO: this should be generated with cue import { VizLegendOptions, VizTooltipOptions } from '../components'; +import { VizTextDisplayOptions } from './builder/text'; /** - * @alpha + * @public */ export interface OptionsWithLegend { legend: VizLegendOptions; } +/** + * @public + */ export interface OptionsWithTooltip { tooltip: VizTooltipOptions; } + +/** + * @public + */ +export interface OptionsWithTextFormatting { + text?: VizTextDisplayOptions; +} diff --git a/pkg/tsdb/testdatasource/scenarios.go b/pkg/tsdb/testdatasource/scenarios.go index f02a218fe39..ab4b502e7df 100644 --- a/pkg/tsdb/testdatasource/scenarios.go +++ b/pkg/tsdb/testdatasource/scenarios.go @@ -39,6 +39,7 @@ const ( serverError500Query queryType = "server_error_500" logsQuery queryType = "logs" nodeGraphQuery queryType = "node_graph" + categoricalDataQuery queryType = "categorical_data" ) type queryType string @@ -188,6 +189,12 @@ Timestamps will line up evenly on timeStepSeconds (For example, 60 seconds means Name: "Node Graph", }) + p.registerScenario(&Scenario{ + ID: string(categoricalDataQuery), + Name: "Categorical Data", + handler: p.handleCategoricalDataScenario, + }) + p.queryMux.HandleFunc("", p.handleFallbackScenario) } @@ -688,6 +695,27 @@ func (p *testDataPlugin) handleLogsScenario(ctx context.Context, req *backend.Qu return resp, nil } +func (p *testDataPlugin) handleCategoricalDataScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + resp := backend.NewQueryDataResponse() + for _, q := range req.Queries { + frame := data.NewFrame(q.RefID, + data.NewField("location", nil, []string{}), + data.NewField("temperature", nil, []int64{}), + data.NewField("humidity", nil, []int64{}), + data.NewField("pressure", nil, []int64{}), + ) + + for i := 0; i < len(houseLocations); i++ { + frame.AppendRow(houseLocations[i], rand.Int63n(40+40)-40, rand.Int63n(100), rand.Int63n(1020-900)+900) + } + respD := resp.Responses[q.RefID] + respD.Frames = append(respD.Frames, frame) + resp.Responses[q.RefID] = respD + } + + return resp, nil +} + func randomWalk(query backend.DataQuery, model *simplejson.Json, index int) *data.Frame { timeWalkerMs := query.TimeRange.From.UnixNano() / int64(time.Millisecond) to := query.TimeRange.To.UnixNano() / int64(time.Millisecond) diff --git a/public/app/plugins/panel/barchart/BarChart.tsx b/public/app/plugins/panel/barchart/BarChart.tsx index 34c916c0911..8e65a5f67df 100644 --- a/public/app/plugins/panel/barchart/BarChart.tsx +++ b/public/app/plugins/panel/barchart/BarChart.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { cloneDeep } from 'lodash'; import { DataFrame, TimeRange } from '@grafana/data'; import { GraphNG, @@ -19,7 +20,7 @@ export interface BarChartProps extends BarChartOptions, Omit {} -const propsToDiff: string[] = ['orientation', 'barWidth', 'groupWidth', 'showValue']; +const propsToDiff: string[] = ['orientation', 'barWidth', 'groupWidth', 'showValue', 'text']; export const BarChart: React.FC = (props) => { const theme = useTheme2(); @@ -34,7 +35,8 @@ export const BarChart: React.FC = (props) => { }; const prepConfig = (alignedFrame: DataFrame, getTimeRange: () => TimeRange) => { - const { timeZone, orientation, barWidth, showValue, groupWidth, stacking, legend, tooltip } = props; + const { timeZone, orientation, barWidth, showValue, groupWidth, stacking, legend, tooltip, text } = props; + return preparePlotConfigBuilder({ frame: alignedFrame, getTimeRange, @@ -48,12 +50,14 @@ export const BarChart: React.FC = (props) => { stacking, legend, tooltip, + text, }); }; return ( void); - -function walkTwo( - groupWidth: number, - barWidth: number, - yIdx: number, - xCount: number, - yCount: number, - xDim: number, - xDraw?: WalkTwoCb, - yDraw?: WalkTwoCb -) { - distribute(xCount, groupWidth, groupDistr, null, (ix, offPct, dimPct) => { - let groupOffPx = xDim * offPct; - let groupWidPx = xDim * dimPct; - - xDraw && xDraw(ix, groupOffPx, groupWidPx); - - yDraw && - distribute(yCount, barWidth, barDistr, yIdx, (iy, offPct, dimPct) => { - let barOffPx = groupWidPx * offPct; - let barWidPx = groupWidPx * dimPct; - - yDraw(ix, groupOffPx + barOffPx, barWidPx); - }); - }); -} +// min.max font size for value label +const VALUE_MIN_FONT_SIZE = 8; +const VALUE_MAX_FONT_SIZE = 30; +// % of width/height of the bar that value should fit in when measuring size +const BAR_FONT_SIZE_RATIO = 0.65; +// distance between label and a horizontal bar +const HORIZONTAL_BAR_LABEL_OFFSET = 10; /** * @internal @@ -44,18 +26,33 @@ export interface BarsOptions { xDir: ScaleDirection; groupWidth: number; barWidth: number; - formatValue?: (seriesIdx: number, value: any) => string; + showValue: BarValueVisibility; + formatValue: (seriesIdx: number, value: any) => string; + text?: VizTextDisplayOptions; onHover?: (seriesIdx: number, valueIdx: number) => void; onLeave?: (seriesIdx: number, valueIdx: number) => void; } +interface LabelDescriptor extends CartesianCoords2D { + formattedValue: string; + value: number; + textAlign: CanvasTextAlign; + textBaseline: CanvasTextBaseline; + fontSize: number; + barWidth: number; + barHeight: number; + textWidth: number; +} + /** * @internal */ -export function getConfig(opts: BarsOptions) { - const { xOri: ori, xDir: dir, groupWidth, barWidth, formatValue } = opts; +export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { + const { xOri: ori, xDir: dir, groupWidth, barWidth, formatValue, showValue } = opts; + const hasAutoValueSize = !Boolean(opts.text?.valueSize); let qt: Quadtree; + let labelsSizing: Array = []; const drawBars: Series.PathBuilder = (u, sidx) => { return uPlot.orient( @@ -73,30 +70,119 @@ export function getConfig(opts: BarsOptions) { const _dir = dir * (ori === 0 ? 1 : -1); walkTwo(groupWidth, barWidth, sidx - 1, numGroups, barsPerGroup, xDim, null, (ix, x0, wid) => { - let lft = Math.round(xOff + (_dir === 1 ? x0 : xDim - x0 - wid)); + let left = Math.round(xOff + (_dir === 1 ? x0 : xDim - x0 - wid)); let barWid = Math.round(wid); + const canvas = u.root.querySelector('.u-over'); + const bbox = canvas?.getBoundingClientRect(); if (dataY[ix] != null) { let yPos = valToPosY(dataY[ix]!, scaleY, yDim, yOff); - let btm = Math.round(Math.max(yPos, y0Pos)); let top = Math.round(Math.min(yPos, y0Pos)); let barHgt = btm - top; - let strokeWidth = series.width || 0; if (strokeWidth) { - rect(stroke, lft + strokeWidth / 2, top + strokeWidth / 2, barWid - strokeWidth, barHgt - strokeWidth); + rect(stroke, left + strokeWidth / 2, top + strokeWidth / 2, barWid - strokeWidth, barHgt - strokeWidth); } - rect(fill, lft, top, barWid, barHgt); + rect(fill, left, top, barWid, barHgt); - let x = ori === 0 ? Math.round(lft - xOff) : Math.round(top - yOff); - let y = ori === 0 ? Math.round(top - yOff) : Math.round(lft - xOff); - let w = ori === 0 ? barWid : barHgt; - let h = ori === 0 ? barHgt : barWid; + let x = ori === ScaleOrientation.Horizontal ? Math.round(left - xOff) : Math.round(top - yOff); + let y = ori === ScaleOrientation.Horizontal ? Math.round(top - yOff) : Math.round(left - xOff); + let width = ori === ScaleOrientation.Horizontal ? barWid : barHgt; + let height = ori === ScaleOrientation.Horizontal ? barHgt : barWid; - qt.add({ x, y, w, h, sidx: sidx, didx: ix }); + qt.add({ x, y, w: width, h: height, sidx: sidx, didx: ix }); + + // Collect labels sizes and placements + const value = formatValue(sidx, dataY[ix]); + let labelX = ori === ScaleOrientation.Horizontal ? Math.round(left) : Math.round(top); + let labelY = ori === ScaleOrientation.Horizontal ? Math.round(top) : Math.round(left); + + let availableSpaceForText; + + if (ori === ScaleOrientation.Horizontal) { + availableSpaceForText = + dataY[ix]! >= 0 ? y / devicePixelRatio : bbox!.height - (y + height) / devicePixelRatio; + } else { + availableSpaceForText = + dataY[ix]! >= 0 ? bbox!.width - (x + width) / devicePixelRatio : x / devicePixelRatio; + } + + /** + * Snippet below is for debugging the available space for text. Leaving it for the future bugs... + */ + // u.ctx.beginPath(); + // u.ctx.strokeStyle = '#0000ff'; + + // if (dataY[ix]! >= 0) { + // if (ori === ScaleOrientation.Horizontal) { + // u.ctx.moveTo(left, top - availableSpaceForText * devicePixelRatio); + // u.ctx.lineTo(left + width, top - availableSpaceForText * devicePixelRatio); + // u.ctx.lineTo(left + width, top); + // u.ctx.lineTo(left, top); + // } else { + // u.ctx.moveTo(top + width, left); + // u.ctx.lineTo(top + width + availableSpaceForText * devicePixelRatio, left); + // u.ctx.lineTo(top + width + availableSpaceForText * devicePixelRatio, left + height); + // u.ctx.lineTo(top + width, left + height); + // } + // } else { + // if (ori === ScaleOrientation.Horizontal) { + // u.ctx.moveTo(left, top + height + availableSpaceForText * devicePixelRatio); + // u.ctx.lineTo(left + width, top + height + availableSpaceForText * devicePixelRatio); + // u.ctx.lineTo(left + width, top + height); + // u.ctx.lineTo(left, top + height); + // } else { + // u.ctx.moveTo(top, left); + // u.ctx.lineTo(top - availableSpaceForText * devicePixelRatio, left); + // u.ctx.lineTo(top - availableSpaceForText * devicePixelRatio, left + height); + // u.ctx.lineTo(top, left + height); + // } + // } + // u.ctx.closePath(); + // u.ctx.stroke(); + + let fontSize = opts.text?.valueSize ?? VALUE_MIN_FONT_SIZE; + + if (hasAutoValueSize) { + const size = + ori === ScaleOrientation.Horizontal + ? calculateFontSize( + value, + (width / devicePixelRatio) * BAR_FONT_SIZE_RATIO, + availableSpaceForText * BAR_FONT_SIZE_RATIO, + 1 + ) + : calculateFontSize( + value, + availableSpaceForText, + (height * BAR_FONT_SIZE_RATIO) / devicePixelRatio, + 1 + ); + fontSize = size > VALUE_MAX_FONT_SIZE ? VALUE_MAX_FONT_SIZE : size; + } + + const textAlign = ori === ScaleOrientation.Horizontal ? 'center' : 'left'; + const textBaseline = (ori === ScaleOrientation.Horizontal ? 'bottom' : 'alphabetic') as CanvasTextBaseline; + const textMeasurement = measureText(value, fontSize * devicePixelRatio); + let labelPosition: CartesianCoords2D = { x: labelX, y: labelY }; + + // Collect labels szes + labelsSizing.push({ + formattedValue: value, + value: dataY[ix]!, + textAlign, + textBaseline, + fontSize: Math.floor(fontSize), + barWidth: width, + barHeight: height, + textWidth: textMeasurement.width, + ...labelPosition, + }); + } else { + labelsSizing.push(null); } }); @@ -108,44 +194,74 @@ export function getConfig(opts: BarsOptions) { ); }; - const drawPoints: Series.Points.Show = - formatValue == null - ? false - : (u, sidx) => { - u.ctx.font = font; - u.ctx.fillStyle = 'white'; + // uPlot hook to draw the labels on the bar chart + const draw = (u: uPlot) => { + let minFontSize = labelsSizing.reduce((min, s) => (s && s.fontSize < min ? s.fontSize : min), Infinity); - uPlot.orient( - u, - sidx, - (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim) => { - let numGroups = dataX.length; - let barsPerGroup = u.series.length - 1; + if (minFontSize === Infinity) { + return; + } - const _dir = dir * (ori === 0 ? 1 : -1); + for (let i = 0; i < labelsSizing.length; i++) { + const label = labelsSizing[i]; + let x = 0, + y = 0; + if (label === null) { + continue; + } - walkTwo(groupWidth, barWidth, sidx - 1, numGroups, barsPerGroup, xDim, null, (ix, x0, wid) => { - let lft = Math.round(xOff + (_dir === 1 ? x0 : xDim - x0 - wid)); - let barWid = Math.round(wid); + const fontSize = hasAutoValueSize ? minFontSize : label.fontSize; - // prettier-ignore - if (dataY[ix] != null) { - let yPos = valToPosY(dataY[ix]!, scaleY, yDim, yOff); + if (showValue === BarValueVisibility.Never) { + return; + } - let x = ori === 0 ? Math.round(lft + barWid / 2) : Math.round(yPos); - let y = ori === 0 ? Math.round(yPos) : Math.round(lft + barWid / 2); + if (showValue !== BarValueVisibility.Always) { + if ( + hasAutoValueSize && + ((ori === ScaleOrientation.Horizontal && label.textWidth > label.barWidth) || + minFontSize < VALUE_MIN_FONT_SIZE) + ) { + return; + } + } - u.ctx.textAlign = ori === 0 ? 'center' : dataY[ix]! >= 0 ? 'left' : 'right'; - u.ctx.textBaseline = ori === 1 ? 'middle' : dataY[ix]! >= 0 ? 'bottom' : 'top'; + // Calculate final labels positions according to unified text size + const textMeasurement = measureText(label.formattedValue, fontSize * devicePixelRatio); + const actualLineHeight = textMeasurement.fontBoundingBoxAscent + textMeasurement.fontBoundingBoxDescent; - u.ctx.fillText(formatValue(sidx, dataY[ix]), x, y); - } - }); - } - ); + if (ori === ScaleOrientation.Horizontal) { + x = label.x + label.barWidth / 2; + y = label.y + (label.value >= 0 ? 0 : label.barHeight + actualLineHeight); + } else { + x = + label.x + + (label.value >= 0 + ? label.barWidth + HORIZONTAL_BAR_LABEL_OFFSET + : -textMeasurement.width - HORIZONTAL_BAR_LABEL_OFFSET); + y = + label.y + + (label.barHeight + textMeasurement.actualBoundingBoxAscent + textMeasurement.actualBoundingBoxDescent) / 2; + } - return false; - }; + /** + * Snippet below is for debugging the available space for text. Leaving it for the future bugs... + */ + // u.ctx.beginPath(); + // u.ctx.fillStyle = '#0000ff'; + // u.ctx.arc(label.x, label.y, 10, 0, Math.PI * 2, true); + // u.ctx.closePath(); + // u.ctx.fill(); + + u.ctx.fillStyle = theme.colors.text.primary; + u.ctx.font = `${fontSize * devicePixelRatio}px ${theme.typography.fontFamily}`; + u.ctx.textAlign = label.textAlign; + u.ctx.textBaseline = label.textBaseline; + u.ctx.fillText(label.formattedValue, x, y); + } + + return false; + }; const xSplits: Axis.Splits = (u: uPlot) => { const dim = ori === 0 ? u.bbox.width : u.bbox.height; @@ -153,8 +269,8 @@ export function getConfig(opts: BarsOptions) { let splits: number[] = []; - distribute(u.data[0].length, groupWidth, groupDistr, null, (di, lftPct, widPct) => { - let groupLftPx = (dim * lftPct) / devicePixelRatio; + distribute(u.data[0].length, groupWidth, groupDistr, null, (di, leftPct, widPct) => { + let groupLftPx = (dim * leftPct) / devicePixelRatio; let groupWidPx = (dim * widPct) / devicePixelRatio; let groupCenterPx = groupLftPx + groupWidPx / 2; @@ -185,6 +301,8 @@ export function getConfig(opts: BarsOptions) { qt.clear(); + labelsSizing = []; + // clear the path cache to force drawBars() to rebuild new quadtree u.series.forEach((s) => { // @ts-ignore @@ -241,7 +359,7 @@ export function getConfig(opts: BarsOptions) { // pathbuilders drawBars, - drawPoints, + draw, // hooks init, @@ -249,3 +367,31 @@ export function getConfig(opts: BarsOptions) { interpolateBarChartTooltip, }; } + +type WalkTwoCb = null | ((idx: number, offPx: number, dimPx: number) => void); + +function walkTwo( + groupWidth: number, + barWidth: number, + yIdx: number, + xCount: number, + yCount: number, + xDim: number, + xDraw?: WalkTwoCb, + yDraw?: WalkTwoCb +) { + distribute(xCount, groupWidth, groupDistr, null, (ix, offPct, dimPct) => { + let groupOffPx = xDim * offPct; + let groupWidPx = xDim * dimPct; + + xDraw && xDraw(ix, groupOffPx, groupWidPx); + + yDraw && + distribute(yCount, barWidth, barDistr, yIdx, (iy, offPct, dimPct) => { + let barOffPx = groupWidPx * offPct; + let barWidPx = groupWidPx * dimPct; + + yDraw(ix, groupOffPx + barOffPx, barWidPx); + }); + }); +} diff --git a/public/app/plugins/panel/barchart/module.tsx b/public/app/plugins/panel/barchart/module.tsx index 445f0b2e70a..074d69a046d 100755 --- a/public/app/plugins/panel/barchart/module.tsx +++ b/public/app/plugins/panel/barchart/module.tsx @@ -115,6 +115,7 @@ export const plugin = new PanelPlugin(BarC commonOptionsBuilder.addTooltipOptions(builder); commonOptionsBuilder.addLegendOptions(builder); + commonOptionsBuilder.addTextSizeOptions(builder, false); }); function countNumberFields(data?: DataFrame[]): number { diff --git a/public/app/plugins/panel/barchart/types.ts b/public/app/plugins/panel/barchart/types.ts index 7fbe4f5be8e..95f7216e41b 100644 --- a/public/app/plugins/panel/barchart/types.ts +++ b/public/app/plugins/panel/barchart/types.ts @@ -5,6 +5,7 @@ import { GraphGradientMode, HideableFieldConfig, OptionsWithLegend, + OptionsWithTextFormatting, OptionsWithTooltip, StackingMode, } from '@grafana/ui'; @@ -12,7 +13,7 @@ import { /** * @alpha */ -export interface BarChartOptions extends OptionsWithLegend, OptionsWithTooltip { +export interface BarChartOptions extends OptionsWithLegend, OptionsWithTooltip, OptionsWithTextFormatting { orientation: VizOrientation; stacking: StackingMode; showValue: BarValueVisibility; diff --git a/public/app/plugins/panel/barchart/utils.test.ts b/public/app/plugins/panel/barchart/utils.test.ts index c1ed21c7781..7dd17ec616e 100644 --- a/public/app/plugins/panel/barchart/utils.test.ts +++ b/public/app/plugins/panel/barchart/utils.test.ts @@ -91,6 +91,9 @@ describe('BarChart utils', () => { tooltip: { mode: TooltipDisplayMode.None, }, + text: { + valueSize: 10, + }, }; it.each([VizOrientation.Auto, VizOrientation.Horizontal, VizOrientation.Vertical])('orientation', (v) => { diff --git a/public/app/plugins/panel/barchart/utils.ts b/public/app/plugins/panel/barchart/utils.ts index b5a65f088f7..12d080ee83b 100644 --- a/public/app/plugins/panel/barchart/utils.ts +++ b/public/app/plugins/panel/barchart/utils.ts @@ -12,7 +12,6 @@ import { BarChartFieldConfig, BarChartOptions, defaultBarChartFieldConfig } from import { BarsOptions, getConfig } from './bars'; import { AxisPlacement, - BarValueVisibility, FIXED_UNIT, ScaleDirection, ScaleDistribution, @@ -47,6 +46,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ showValue, groupWidth, barWidth, + text, }) => { const builder = new UPlotConfigBuilder(); const defaultValueFormatter = (seriesIdx: number, value: any) => @@ -55,7 +55,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ // bar orientation -> x scale orientation & direction const vizOrientation = getBarCharScaleOrientation(orientation); - const formatValue = showValue !== BarValueVisibility.Never ? defaultValueFormatter : undefined; + const formatValue = defaultValueFormatter; // Use bar width when only one field if (frame.fields.length === 2) { @@ -69,12 +69,16 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ groupWidth, barWidth, formatValue, + text, + showValue, }; - const config = getConfig(opts); + const config = getConfig(opts, theme); builder.addHook('init', config.init); builder.addHook('drawClear', config.drawClear); + builder.addHook('draw', config.draw); + builder.setTooltipInterpolator(config.interpolateBarChartTooltip); builder.addScale({ @@ -117,12 +121,10 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ pxAlign: false, lineWidth: customConfig.lineWidth, lineColor: seriesColor, - //lineStyle: customConfig.lineStyle, fillOpacity: customConfig.fillOpacity, theme, colorMode, pathBuilder: config.drawBars, - pointsBuilder: config.drawPoints, show: !customConfig.hideFrom?.viz, gradientMode: customConfig.gradientMode, thresholds: field.config.thresholds, diff --git a/public/app/plugins/panel/bargauge/module.tsx b/public/app/plugins/panel/bargauge/module.tsx index 2d92c6e87dd..c869d5a40da 100644 --- a/public/app/plugins/panel/bargauge/module.tsx +++ b/public/app/plugins/panel/bargauge/module.tsx @@ -1,8 +1,8 @@ -import { sharedSingleStatPanelChangedHandler } from '@grafana/ui'; +import { commonOptionsBuilder, sharedSingleStatPanelChangedHandler } from '@grafana/ui'; import { PanelPlugin } from '@grafana/data'; import { BarGaugePanel } from './BarGaugePanel'; import { BarGaugeOptions, displayModes } from './types'; -import { addOrientationOption, addStandardDataReduceOptions, addTextSizeOptions } from '../stat/types'; +import { addOrientationOption, addStandardDataReduceOptions } from '../stat/types'; import { barGaugePanelMigrationHandler } from './BarGaugeMigrations'; export const plugin = new PanelPlugin(BarGaugePanel) @@ -10,7 +10,7 @@ export const plugin = new PanelPlugin(BarGaugePanel) .setPanelOptions((builder) => { addStandardDataReduceOptions(builder); addOrientationOption(builder); - addTextSizeOptions(builder); + commonOptionsBuilder.addTextSizeOptions(builder); builder .addRadio({ diff --git a/public/app/plugins/panel/gauge/module.tsx b/public/app/plugins/panel/gauge/module.tsx index e8e5e23c99d..cecedb65132 100644 --- a/public/app/plugins/panel/gauge/module.tsx +++ b/public/app/plugins/panel/gauge/module.tsx @@ -1,8 +1,9 @@ import { PanelPlugin } from '@grafana/data'; import { GaugePanel } from './GaugePanel'; import { GaugeOptions } from './types'; -import { addStandardDataReduceOptions, addTextSizeOptions } from '../stat/types'; +import { addStandardDataReduceOptions } from '../stat/types'; import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './GaugeMigrations'; +import { commonOptionsBuilder } from '@grafana/ui'; export const plugin = new PanelPlugin(GaugePanel) .useFieldConfig() @@ -23,7 +24,7 @@ export const plugin = new PanelPlugin(GaugePanel) defaultValue: true, }); - addTextSizeOptions(builder); + commonOptionsBuilder.addTextSizeOptions(builder); }) .setPanelChangeHandler(gaugePanelChangedHandler) .setMigrationHandler(gaugePanelMigrationHandler); diff --git a/public/app/plugins/panel/stat/module.tsx b/public/app/plugins/panel/stat/module.tsx index ce4f56e6ef3..e7d3d143d2a 100644 --- a/public/app/plugins/panel/stat/module.tsx +++ b/public/app/plugins/panel/stat/module.tsx @@ -1,6 +1,6 @@ -import { BigValueTextMode, sharedSingleStatMigrationHandler } from '@grafana/ui'; +import { BigValueTextMode, commonOptionsBuilder, sharedSingleStatMigrationHandler } from '@grafana/ui'; import { PanelPlugin } from '@grafana/data'; -import { addOrientationOption, addStandardDataReduceOptions, addTextSizeOptions, StatPanelOptions } from './types'; +import { addOrientationOption, addStandardDataReduceOptions, StatPanelOptions } from './types'; import { StatPanel } from './StatPanel'; import { statPanelChangedHandler } from './StatMigrations'; @@ -11,7 +11,7 @@ export const plugin = new PanelPlugin(StatPanel) addStandardDataReduceOptions(builder); addOrientationOption(builder, mainCategory); - addTextSizeOptions(builder); + commonOptionsBuilder.addTextSizeOptions(builder); builder.addSelect({ path: 'textMode', diff --git a/public/app/plugins/panel/stat/types.ts b/public/app/plugins/panel/stat/types.ts index 5d3f7bbe463..b32f3af9db5 100644 --- a/public/app/plugins/panel/stat/types.ts +++ b/public/app/plugins/panel/stat/types.ts @@ -119,31 +119,3 @@ export function addOrientationOption( defaultValue: VizOrientation.Auto, }); } - -export function addTextSizeOptions(builder: PanelOptionsEditorBuilder) { - builder.addNumberInput({ - path: 'text.titleSize', - category: ['Text size'], - name: 'Title', - settings: { - placeholder: 'Auto', - integer: false, - min: 1, - max: 200, - }, - defaultValue: undefined, - }); - - builder.addNumberInput({ - path: 'text.valueSize', - category: ['Text size'], - name: 'Value', - settings: { - placeholder: 'Auto', - integer: false, - min: 1, - max: 200, - }, - defaultValue: undefined, - }); -}