diff --git a/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json b/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json index 27aa029161f..14440800c6a 100644 --- a/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json +++ b/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json @@ -8,6 +8,12 @@ "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, "type": "dashboard" } ] @@ -33,7 +39,8 @@ "hideFrom": { "graph": false, "legend": false, - "tooltip": false + "tooltip": false, + "viz": false }, "lineWidth": 0 }, @@ -105,7 +112,8 @@ "hideFrom": { "graph": false, "legend": false, - "tooltip": false + "tooltip": false, + "viz": false }, "lineWidth": 0 }, @@ -177,7 +185,8 @@ "hideFrom": { "graph": false, "legend": false, - "tooltip": false + "tooltip": false, + "viz": false }, "lineWidth": 0 }, @@ -248,7 +257,8 @@ "hideFrom": { "graph": false, "legend": false, - "tooltip": false + "tooltip": false, + "viz": false }, "lineWidth": 0 }, @@ -319,7 +329,8 @@ "hideFrom": { "graph": false, "legend": false, - "tooltip": false + "tooltip": false, + "viz": false }, "lineWidth": 0 }, @@ -390,10 +401,12 @@ "hideFrom": { "graph": false, "legend": false, - "tooltip": false + "tooltip": false, + "viz": false }, "lineWidth": 0 }, + "decimals": 7, "mappings": [], "thresholds": { "mode": "absolute", @@ -437,7 +450,7 @@ }, "targets": [ { - "csvContent": "Name,Stat1,Stat2\nStockholm, 10, 15\nNew York, 19, 5\nLondon, 10, 1\nLong value, 15,10", + "csvContent": "Name,Stat1,Stat2\nStockholm, 10, 15\nNew York, 19, -5\nLondon, 10, 1\nLong value, 15,10", "refId": "A", "scenarioId": "csv_content" } @@ -462,7 +475,8 @@ "hideFrom": { "graph": false, "legend": false, - "tooltip": false + "tooltip": false, + "viz": false }, "lineWidth": 0 }, @@ -537,5 +551,5 @@ "timezone": "", "title": "BarChart - Panel Tests - Value sizing", "uid": "WFlOM-jM1", - "version": 3 + "version": 9 } \ No newline at end of file diff --git a/packages/grafana-ui/src/components/uPlot/Plot.tsx b/packages/grafana-ui/src/components/uPlot/Plot.tsx index 685b5939925..8fd0ed19189 100755 --- a/packages/grafana-ui/src/components/uPlot/Plot.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.tsx @@ -73,7 +73,7 @@ export class UPlotChart extends React.Component { ...this.props.config.getConfig(), }; - pluginLog('UPlot', false, 'Reinitializing plot'); + pluginLog('UPlot', false, 'Reinitializing plot', config); const plot = new uPlot(config, this.props.data, this.plotContainer!.current!); if (plotRef) { diff --git a/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap b/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap index 8d4bfe31dcd..1ab24730aa4 100644 --- a/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap +++ b/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap @@ -113,7 +113,7 @@ Object { "size": undefined, "stroke": "#808080", }, - "pxAlign": false, + "pxAlign": true, "scale": "m/s", "show": true, "spanGaps": undefined, @@ -238,7 +238,7 @@ Object { "size": undefined, "stroke": "#808080", }, - "pxAlign": false, + "pxAlign": true, "scale": "m/s", "show": true, "spanGaps": undefined, @@ -363,7 +363,7 @@ Object { "size": undefined, "stroke": "#808080", }, - "pxAlign": false, + "pxAlign": true, "scale": "m/s", "show": true, "spanGaps": undefined, @@ -488,7 +488,7 @@ Object { "size": undefined, "stroke": "#808080", }, - "pxAlign": false, + "pxAlign": true, "scale": "m/s", "show": true, "spanGaps": undefined, @@ -613,7 +613,7 @@ Object { "size": undefined, "stroke": "#808080", }, - "pxAlign": false, + "pxAlign": true, "scale": "m/s", "show": true, "spanGaps": undefined, @@ -738,7 +738,7 @@ Object { "size": undefined, "stroke": "#808080", }, - "pxAlign": false, + "pxAlign": true, "scale": "m/s", "show": true, "spanGaps": undefined, @@ -863,7 +863,7 @@ Object { "size": undefined, "stroke": "#808080", }, - "pxAlign": false, + "pxAlign": true, "scale": "m/s", "show": true, "spanGaps": undefined, @@ -988,7 +988,7 @@ Object { "size": undefined, "stroke": "#808080", }, - "pxAlign": false, + "pxAlign": true, "scale": "m/s", "show": true, "spanGaps": undefined, diff --git a/public/app/plugins/panel/barchart/bars.ts b/public/app/plugins/panel/barchart/bars.ts index d74a6d50d91..b272aad10b4 100644 --- a/public/app/plugins/panel/barchart/bars.ts +++ b/public/app/plugins/panel/barchart/bars.ts @@ -1,20 +1,28 @@ -import uPlot, { Axis, Series } from 'uplot'; +import uPlot, { Axis } from 'uplot'; import { pointWithin, Quadtree, Rect } from './quadtree'; import { distribute, SPACE_BETWEEN } from './distribute'; import { BarValueVisibility, ScaleDirection, ScaleOrientation } from '@grafana/ui/src/components/uPlot/config'; -import { CartesianCoords2D, GrafanaTheme2 } from '@grafana/data'; -import { calculateFontSize, measureText, PlotTooltipInterpolator, VizTextDisplayOptions } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { calculateFontSize, PlotTooltipInterpolator, VizTextDisplayOptions } from '@grafana/ui'; const groupDistr = SPACE_BETWEEN; const barDistr = SPACE_BETWEEN; - // 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; +// distance between label and a bar in % of bar width +const LABEL_OFFSET_FACTOR_VT = 0.1; +const LABEL_OFFSET_FACTOR_HZ = 0.15; +// max distance +const LABEL_OFFSET_MAX_VT = 5; +const LABEL_OFFSET_MAX_HZ = 10; + +// text baseline middle runs through the middle of lowercase letters +// since bar values are numbers and uppercase-like, we want the middle of uppercase +// this is a cheap fudge factor that skips expensive and inconsistent cross-browser measuring +const MIDDLE_BASELINE_SHIFT = 0.1; /** * @internal @@ -31,247 +39,25 @@ export interface BarsOptions { 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, theme: GrafanaTheme2) { - const { xOri: ori, xDir: dir, groupWidth, barWidth, formatValue, showValue } = opts; + const { xOri, xDir: dir, groupWidth, barWidth, formatValue, showValue } = opts; + const isXHorizontal = xOri === ScaleOrientation.Horizontal; const hasAutoValueSize = !Boolean(opts.text?.valueSize); let qt: Quadtree; - let labelsSizing: Array = []; + let hovered: Rect | undefined = undefined; - const drawBars: Series.PathBuilder = (u, sidx) => { - return uPlot.orient( - u, - sidx, - (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect) => { - const fill = new Path2D(); - const stroke = new Path2D(); - - let numGroups = dataX.length; - let barsPerGroup = u.series.length - 1; - - let y0Pos = valToPosY(0, scaleY, yDim, yOff); - - const _dir = dir * (ori === 0 ? 1 : -1); - - walkTwo(groupWidth, barWidth, sidx - 1, numGroups, barsPerGroup, xDim, null, (ix, x0, wid) => { - let left = Math.round(xOff + (_dir === 1 ? x0 : xDim - x0 - wid)); - let barWid = Math.round(wid); - const canvas = 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, left + strokeWidth / 2, top + strokeWidth / 2, barWid - strokeWidth, barHgt - strokeWidth); - } - - rect(fill, left, top, barWid, barHgt); - - 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: 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); - } - }); - - return { - stroke, - fill, - }; - } - ); - }; - - // 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); - - if (minFontSize === Infinity) { - return; - } - - for (let i = 0; i < labelsSizing.length; i++) { - const label = labelsSizing[i]; - let x = 0, - y = 0; - if (label === null) { - continue; - } - - const fontSize = hasAutoValueSize ? minFontSize : label.fontSize; - - if (showValue === BarValueVisibility.Never) { - return; - } - - if (showValue !== BarValueVisibility.Always) { - if ( - hasAutoValueSize && - ((ori === ScaleOrientation.Horizontal && label.textWidth > label.barWidth) || - minFontSize < VALUE_MIN_FONT_SIZE) - ) { - return; - } - } - - // Calculate final labels positions according to unified text size - const textMeasurement = measureText(label.formattedValue, fontSize * devicePixelRatio); - - let actualLineHeight = textMeasurement.actualBoundingBoxAscent + textMeasurement.actualBoundingBoxDescent; - - // fontBoundingBoxAscent is only supported in chrome & safari at the moment. (see: https://caniuse.com/?search=fontBoundingBoxAscent) - // @ts-ignore - if (textMeasurement.fontBoundingBoxAscent && textMeasurement.fontBoundingBoxDescent) { - // @ts-ignore - actualLineHeight = textMeasurement.fontBoundingBoxAscent + textMeasurement.fontBoundingBoxDescent; - } - - 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; - } - - /** - * 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; - }; + let barMark = document.createElement('div'); + barMark.classList.add('bar-mark'); + barMark.style.position = 'absolute'; + barMark.style.background = 'rgba(255,255,255,0.4)'; const xSplits: Axis.Splits = (u: uPlot) => { - const dim = ori === 0 ? u.bbox.width : u.bbox.height; - const _dir = dir * (ori === 0 ? 1 : -1); + const dim = isXHorizontal ? u.bbox.width : u.bbox.height; + const _dir = dir * (isXHorizontal ? 1 : -1); let splits: number[] = []; @@ -289,12 +75,63 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { const xValues: Axis.Values = (u) => u.data[0].map((x) => formatValue(0, x)); - let hovered: Rect | undefined = undefined; + let distrTwo = (groupCount: number, barCount: number) => { + let out = Array.from({ length: barCount }, () => ({ + offs: Array(groupCount).fill(0), + size: Array(groupCount).fill(0), + })); - let barMark = document.createElement('div'); - barMark.classList.add('bar-mark'); - barMark.style.position = 'absolute'; - barMark.style.background = 'rgba(255,255,255,0.4)'; + distribute(groupCount, groupWidth, groupDistr, null, (groupIdx, groupOffPct, groupDimPct) => { + distribute(barCount, barWidth, barDistr, null, (barIdx, barOffPct, barDimPct) => { + out[barIdx].offs[groupIdx] = groupOffPct + groupDimPct * barOffPct; + out[barIdx].size[groupIdx] = groupDimPct * barDimPct; + }); + }); + + return out; + }; + + let barsPctLayout: Array = []; + let barRects: Rect[] = []; + + // minimum available space for labels between bar end and plotting area bound (in canvas pixels) + let vSpace = Infinity; + let hSpace = Infinity; + + let barsBuilder = uPlot.paths.bars!({ + disp: { + x0: { + unit: 2, + values: (u, seriesIdx) => barsPctLayout[seriesIdx]!.offs, + }, + size: { + unit: 2, + values: (u, seriesIdx) => barsPctLayout[seriesIdx]!.size, + }, + }, + // collect rendered bar geometry + each: (u, seriesIdx, dataIdx, lft, top, wid, hgt) => { + // we get back raw canvas coords (included axes & padding) + // translate to the plotting area origin + lft -= u.bbox.left; + 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); + hSpace = wid; + } else { + vSpace = hgt; + hSpace = Math.min(hSpace, val < 0 ? lft : u.bbox.width - (lft + wid)); + } + + let barRect = { x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx }; + qt.add(barRect); + barRects.push(barRect); + }, + }); const init = (u: uPlot) => { let over = u.over; @@ -302,18 +139,91 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { over.appendChild(barMark); }; + // Build bars const drawClear = (u: uPlot) => { qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); qt.clear(); - labelsSizing = []; - // clear the path cache to force drawBars() to rebuild new quadtree u.series.forEach((s) => { // @ts-ignore s._paths = null; }); + + barsPctLayout = ([null] as any).concat(distrTwo(u.data[0].length, u.data.length - 1)); + barRects.length = 0; + 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) => { + // pre-cache formatted labels + let texts = Array(barRects.length); + let labelOffset = LABEL_OFFSET_MAX; + + barRects.forEach((r, i) => { + texts[i] = formatValue(r.sidx, u.data[r.sidx][r.didx]); + 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 !== BarValueVisibility.Always) { + return; + } + } + } + + 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; + + barRects.forEach((r, i) => { + let value = u.data[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'; + + if (align !== curAlign) { + u.ctx.textAlign = curAlign = align; + } + + if (baseline !== curBaseline) { + 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) + ); + } + }); }; // handle hover interaction with quadtree probing @@ -367,41 +277,12 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { xValues, xSplits, - // pathbuilders - drawBars, - draw, + barsBuilder, // hooks init, drawClear, + draw, interpolateTooltip, }; } - -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/utils.ts b/public/app/plugins/panel/barchart/utils.ts index e550439f519..070e2e50469 100644 --- a/public/app/plugins/panel/barchart/utils.ts +++ b/public/app/plugins/panel/barchart/utils.ts @@ -121,13 +121,13 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ builder.addSeries({ scaleKey, - pxAlign: false, + pxAlign: true, lineWidth: customConfig.lineWidth, lineColor: seriesColor, fillOpacity: customConfig.fillOpacity, theme, colorMode, - pathBuilder: config.drawBars, + pathBuilder: config.barsBuilder, show: !customConfig.hideFrom?.viz, gradientMode: customConfig.gradientMode, thresholds: field.config.thresholds,