import tinycolor from 'tinycolor2'; import uPlot from 'uplot'; import { FALLBACK_COLOR, Field, FieldType, formattedValueToString, getFieldColorModeForField, GrafanaTheme2, MappingType, SpecialValueMatch, ThresholdsMode, } from '@grafana/data'; import { alpha } from '@grafana/data/src/themes/colorManipulator'; import { AxisPlacement, FieldColorModeId, ScaleDirection, ScaleOrientation, VisibilityMode } from '@grafana/schema'; import { UPlotConfigBuilder } from '@grafana/ui'; import { FacetedData, FacetSeries } from '@grafana/ui/src/components/uPlot/types'; import { pointWithin, Quadtree, Rect } from '../barchart/quadtree'; import { valuesToFills } from '../heatmap/utils'; import { PointShape } from './panelcfg.gen'; import { XYSeries } from './types2'; import { getCommonPrefixSuffix } from './utils'; interface DrawBubblesOpts { each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void; disp: { //unit: 3, size: { values: (u: uPlot, seriesIdx: number) => number[]; }; color: { values: (u: uPlot, seriesIdx: number) => string[]; }; }; } export const prepConfig = (xySeries: XYSeries[], theme: GrafanaTheme2) => { if (xySeries.length === 0) { return { builder: null, prepData: () => [] }; } let qt: Quadtree; let hRect: Rect | null; function drawBubblesFactory(opts: DrawBubblesOpts) { const drawBubbles: uPlot.Series.PathBuilder = (u, seriesIdx, idx0, idx1) => { uPlot.orient( u, seriesIdx, ( series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect, arc ) => { const pxRatio = uPlot.pxRatio; const scatterInfo = xySeries[seriesIdx - 1]; let d = u.data[seriesIdx] as unknown as FacetSeries; // showLine: boolean; // lineStyle: common.LineStyle; // showPoints: common.VisibilityMode; let showLine = scatterInfo.showLine; let showPoints = scatterInfo.showPoints === VisibilityMode.Always; let strokeWidth = scatterInfo.pointStrokeWidth ?? 0; u.ctx.save(); u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); u.ctx.clip(); let pointAlpha = scatterInfo.fillOpacity / 100; u.ctx.fillStyle = alpha((series.fill as any)(), pointAlpha); u.ctx.strokeStyle = alpha((series.stroke as any)(), 1); u.ctx.lineWidth = strokeWidth; let deg360 = 2 * Math.PI; let xKey = scaleX.key!; let yKey = scaleY.key!; //const colorMode = getFieldColorModeForField(field); // isByValue const pointSize = scatterInfo.y.field.config.custom.pointSize; const colorByValue = scatterInfo.color.field != null; // && colorMode.isByValue; let maxSize = (pointSize.max ?? pointSize.fixed) * pxRatio; // todo: this depends on direction & orientation // todo: calc once per redraw, not per path let filtLft = u.posToVal(-maxSize / 2, xKey); let filtRgt = u.posToVal(u.bbox.width / pxRatio + maxSize / 2, xKey); let filtBtm = u.posToVal(u.bbox.height / pxRatio + maxSize / 2, yKey); let filtTop = u.posToVal(-maxSize / 2, yKey); let sizes = opts.disp.size.values(u, seriesIdx); // let pointColors = opts.disp.color.values(u, seriesIdx); let pointColors = dispColors[seriesIdx - 1].values; // idxs let pointPalette = dispColors[seriesIdx - 1].index as Array; let paletteHasAlpha = dispColors[seriesIdx - 1].hasAlpha; let isSquare = scatterInfo.pointShape === PointShape.Square; let linePath: Path2D | null = showLine ? new Path2D() : null; let curColorIdx = -1; for (let i = 0; i < d[0].length; i++) { let xVal = d[0][i]; let yVal = d[1][i]; if (xVal >= filtLft && xVal <= filtRgt && yVal >= filtBtm && yVal <= filtTop) { let size = Math.round(sizes[i] * pxRatio); let cx = valToPosX(xVal, scaleX, xDim, xOff); let cy = valToPosY(yVal, scaleY, yDim, yOff); if (showLine) { linePath!.lineTo(cx, cy); } if (showPoints) { if (colorByValue) { if (pointColors[i] !== curColorIdx) { curColorIdx = pointColors[i]; let c = curColorIdx === -1 ? FALLBACK_COLOR : pointPalette[curColorIdx]; u.ctx.fillStyle = paletteHasAlpha ? c : alpha(c as string, pointAlpha); u.ctx.strokeStyle = alpha(c as string, 1); } } if (isSquare) { let x = Math.round(cx - size / 2); let y = Math.round(cy - size / 2); if (colorByValue || pointAlpha > 0) { u.ctx.fillRect(x, y, size, size); } if (strokeWidth > 0) { u.ctx.strokeRect(x, y, size, size); } } else { u.ctx.beginPath(); u.ctx.arc(cx, cy, size / 2, 0, deg360); if (colorByValue || pointAlpha > 0) { u.ctx.fill(); } if (strokeWidth > 0) { u.ctx.stroke(); } } opts.each( u, seriesIdx, i, cx - size / 2 - strokeWidth / 2, cy - size / 2 - strokeWidth / 2, size + strokeWidth, size + strokeWidth ); } } } if (showLine) { u.ctx.strokeStyle = scatterInfo.color.fixed!; u.ctx.lineWidth = scatterInfo.lineWidth * pxRatio; const { lineStyle } = scatterInfo; if (lineStyle && lineStyle.fill !== 'solid') { if (lineStyle.fill === 'dot') { u.ctx.lineCap = 'round'; } u.ctx.setLineDash(lineStyle.dash ?? [10, 10]); } u.ctx.stroke(linePath!); } u.ctx.restore(); } ); return null; }; return drawBubbles; } let drawBubbles = drawBubblesFactory({ disp: { size: { //unit: 3, // raw CSS pixels values: (u, seriesIdx) => { return u.data[seriesIdx][2] as any; // already contains final pixel geometry //let [minValue, maxValue] = getSizeMinMax(u); //return u.data[seriesIdx][2].map(v => getSize(v, minValue, maxValue)); }, }, color: { // string values values: (u, seriesIdx) => { return u.data[seriesIdx][3] as any; }, }, }, 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; qt.add({ x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx }); }, }); const builder = new UPlotConfigBuilder(); builder.setCursor({ drag: { setScale: true }, dataIdx: (u, seriesIdx) => { if (seriesIdx === 1) { const pxRatio = uPlot.pxRatio; hRect = null; let dist = Infinity; let cx = u.cursor.left! * pxRatio; let cy = u.cursor.top! * pxRatio; qt.get(cx, cy, 1, 1, (o) => { if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { let ocx = o.x + o.w / 2; let ocy = o.y + o.h / 2; let dx = ocx - cx; let dy = ocy - cy; let d = Math.sqrt(dx ** 2 + dy ** 2); // test against radius for actual hover if (d <= o.w / 2) { // only hover bbox with closest distance if (d <= dist) { dist = d; hRect = o; } } } }); } return hRect && seriesIdx === hRect.sidx ? hRect.didx : null; }, points: { size: (u, seriesIdx) => { return hRect && seriesIdx === hRect.sidx ? hRect.w / uPlot.pxRatio : 0; }, fill: (u, seriesIdx) => 'rgba(255,255,255,0.4)', }, }); // clip hover points/bubbles to plotting area builder.addHook('init', (u, r) => { u.over.style.overflow = 'hidden'; }); builder.addHook('drawClear', (u) => { qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); qt.clear(); // force-clear the path cache to cause drawBars() to rebuild new quadtree u.series.forEach((s, i) => { if (i > 0) { // @ts-ignore s._paths = null; } }); }); builder.setMode(2); let xField = xySeries[0].x.field; let fieldConfig = xField.config; let customConfig = fieldConfig.custom; let scaleDistr = customConfig?.scaleDistribution; builder.addScale({ scaleKey: 'x', isTime: false, orientation: ScaleOrientation.Horizontal, direction: ScaleDirection.Right, distribution: scaleDistr?.type, log: scaleDistr?.log, linearThreshold: scaleDistr?.linearThreshold, min: fieldConfig.min, max: fieldConfig.max, softMin: customConfig?.axisSoftMin, softMax: customConfig?.axisSoftMax, centeredZero: customConfig?.axisCenteredZero, decimals: fieldConfig.decimals, }); // why does this fall back to '' instead of null or undef? let xAxisLabel = customConfig.axisLabel; if (xAxisLabel == null || xAxisLabel === '') { let dispNames = xySeries.map((s) => s.x.field.state?.displayName ?? ''); let xAxisAutoLabel = xySeries.length === 1 ? (xField.state?.displayName ?? xField.name) : new Set(dispNames).size === 1 ? dispNames[0] : getCommonPrefixSuffix(dispNames); if (xAxisAutoLabel !== '') { xAxisLabel = xAxisAutoLabel; } } builder.addAxis({ scaleKey: 'x', placement: customConfig?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden, show: customConfig?.axisPlacement !== AxisPlacement.Hidden, grid: { show: customConfig?.axisGridShow }, border: { show: customConfig?.axisBorderShow }, theme, label: xAxisLabel, formatValue: (v, decimals) => formattedValueToString(xField.display!(v, decimals)), }); xySeries.forEach((s, si) => { let field = s.y.field; const lineColor = s.color.fixed; const pointColor = s.color.fixed; //const lineColor = s.lineColor(frame); //const lineWidth = s.lineWidth; let scaleKey = field.config.unit ?? 'y'; let config = field.config; let customConfig = config.custom; let scaleDistr = customConfig?.scaleDistribution; builder.addScale({ scaleKey, orientation: ScaleOrientation.Vertical, direction: ScaleDirection.Up, distribution: scaleDistr?.type, log: scaleDistr?.log, linearThreshold: scaleDistr?.linearThreshold, min: config.min, max: config.max, softMin: customConfig?.axisSoftMin, softMax: customConfig?.axisSoftMax, centeredZero: customConfig?.axisCenteredZero, decimals: config.decimals, }); // why does this fall back to '' instead of null or undef? let yAxisLabel = customConfig.axisLabel; if (yAxisLabel == null || yAxisLabel === '') { let dispNames = xySeries.map((s) => s.y.field.state?.displayName ?? ''); let yAxisAutoLabel = xySeries.length === 1 ? (field.state?.displayName ?? field.name) : new Set(dispNames).size === 1 ? dispNames[0] : getCommonPrefixSuffix(dispNames); if (yAxisAutoLabel !== '') { yAxisLabel = yAxisAutoLabel; } } builder.addAxis({ scaleKey, theme, placement: customConfig?.axisPlacement === AxisPlacement.Auto ? AxisPlacement.Left : customConfig?.axisPlacement, show: customConfig?.axisPlacement !== AxisPlacement.Hidden, grid: { show: customConfig?.axisGridShow }, border: { show: customConfig?.axisBorderShow }, size: customConfig?.axisWidth, // label: yAxisLabel == null || yAxisLabel === '' ? fieldDisplayName : yAxisLabel, label: yAxisLabel, formatValue: (v, decimals) => formattedValueToString(field.display!(v, decimals)), }); builder.addSeries({ facets: [ { scale: 'x', auto: true, }, { scale: scaleKey, auto: true, }, ], pathBuilder: drawBubbles, // drawBubbles({disp: {size: {values: () => }}}) theme, scaleKey: '', // facets' scales used (above) lineColor: alpha(lineColor ?? '#ffff', 1), fillColor: alpha(pointColor ?? '#ffff', 0.5), show: !field.state?.hideFrom?.viz, }); }); const dispColors = xySeries.map((s): FieldColorValuesWithCache => { const cfg: FieldColorValuesWithCache = { index: [], getAll: () => [], getOne: () => -1, // cache for renderer, refreshed in prepData() values: [], hasAlpha: false, }; const f = s.color.field; if (f != null) { Object.assign(cfg, fieldValueColors(f, theme)); cfg.hasAlpha = cfg.index.some((v) => !(v as string).endsWith('ff')); } return cfg; }); function prepData(xySeries: XYSeries[]): FacetedData { // if (info.error || !data.length) { // return [null]; // } const { size: sizeRange, color: colorRange } = getGlobalRanges(xySeries); xySeries.forEach((s, i) => { dispColors[i].values = dispColors[i].getAll(s.color.field?.values ?? [], colorRange.min, colorRange.max); }); return [ null, ...xySeries.map((s, idx) => { let len = s.x.field.values.length; let diams: number[]; if (s.size.field != null) { let { min, max } = s.size; // todo: this scaling should be in renderer from raw values (not by passing css pixel diams via data) let minPx = min! ** 2; let maxPx = max! ** 2; // use quadratic size scaling in byValue modes let pxRange = maxPx - minPx; let vals = s.size.field.values; let minVal = sizeRange.min; let maxVal = sizeRange.max; let valRange = maxVal - minVal; diams = Array(len); for (let i = 0; i < vals.length; i++) { let val = vals[i]; let valPct = (val - minVal) / valRange; let pxArea = minPx + valPct * pxRange; diams[i] = pxArea ** 0.5; } } else { diams = Array(len).fill(s.size.fixed!); } return [ s.x.field.values, // X s.y.field.values, // Y diams, Array(len).fill(s.color.fixed!), // TODO: fails for by value ]; }), ]; } return { builder, prepData }; }; export type PrepData = (xySeries: XYSeries[]) => FacetedData; const getGlobalRanges = (xySeries: XYSeries[]) => { const ranges = { size: { min: Infinity, max: -Infinity, }, color: { min: Infinity, max: -Infinity, }, }; xySeries.forEach((series) => { [series.size, series.color].forEach((facet, fi) => { if (facet.field != null) { let range = fi === 0 ? ranges.size : ranges.color; const vals = facet.field.values; for (let i = 0; i < vals.length; i++) { const v = vals[i]; if (v != null) { if (v < range.min) { range.min = v; } if (v > range.max) { range.max = v; } } } } }); }); return ranges; }; function getHex8Color(color: string, theme: GrafanaTheme2) { return tinycolor(theme.visualization.getColorByName(color)).toHex8String(); } interface FieldColorValues { index: unknown[]; getOne: GetOneValue; getAll: GetAllValues; } interface FieldColorValuesWithCache extends FieldColorValues { values: number[]; hasAlpha: boolean; } type GetAllValues = (values: unknown[], min?: number, max?: number) => number[]; type GetOneValue = (value: unknown, min?: number, max?: number) => number; /** compiler for values to palette color idxs (from thresholds, mappings, by-value gradients) */ function fieldValueColors(f: Field, theme: GrafanaTheme2): FieldColorValues { let index: unknown[] = []; let getAll: GetAllValues = () => []; let getOne: GetOneValue = () => -1; let conds = ''; // if any mappings exist, use them regardless of other settings if (f.config.mappings?.length ?? 0 > 0) { let mappings = f.config.mappings!; for (let i = 0; i < mappings.length; i++) { let m = mappings[i]; if (m.type === MappingType.ValueToText) { for (let k in m.options) { let { color } = m.options[k]; if (color != null) { let rhs = f.type === FieldType.string ? JSON.stringify(k) : Number(k); conds += `v === ${rhs} ? ${index.length} : `; index.push(getHex8Color(color, theme)); } } } else if (m.options.result.color != null) { let { color } = m.options.result; if (m.type === MappingType.RangeToText) { let range = []; if (m.options.from != null) { range.push(`v >= ${Number(m.options.from)}`); } if (m.options.to != null) { range.push(`v <= ${Number(m.options.to)}`); } if (range.length > 0) { conds += `${range.join(' && ')} ? ${index.length} : `; index.push(getHex8Color(color, theme)); } } else if (m.type === MappingType.SpecialValue) { let spl = m.options.match; if (spl === SpecialValueMatch.NaN) { conds += `isNaN(v)`; } else if (spl === SpecialValueMatch.NullAndNaN) { conds += `v == null || isNaN(v)`; } else { conds += `v ${ spl === SpecialValueMatch.True ? '=== true' : spl === SpecialValueMatch.False ? '=== false' : spl === SpecialValueMatch.Null ? '== null' : spl === SpecialValueMatch.Empty ? '=== ""' : '== null' }`; } conds += ` ? ${index.length} : `; index.push(getHex8Color(color, theme)); } else if (m.type === MappingType.RegexToText) { // TODO } } } conds += '-1'; // ?? what default here? null? FALLBACK_COLOR? } else if (f.config.color?.mode === FieldColorModeId.Thresholds) { if (f.config.thresholds?.mode === ThresholdsMode.Absolute) { let steps = f.config.thresholds.steps; let lasti = steps.length - 1; for (let i = lasti; i > 0; i--) { conds += `v >= ${steps[i].value} ? ${i} : `; } conds += '0'; index = steps.map((s) => getHex8Color(s.color, theme)); } else { // TODO: percent thresholds? } } else if (f.config.color?.mode?.startsWith('continuous')) { let calc = getFieldColorModeForField(f).getCalculator(f, theme); index = Array(32); for (let i = 0; i < index.length; i++) { let pct = i / (index.length - 1); index[i] = getHex8Color(calc(pct, pct), theme); } getAll = (vals, min, max) => valuesToFills(vals as number[], index as string[], min!, max!); } if (conds !== '') { getOne = new Function('v', `return ${conds};`) as GetOneValue; getAll = new Function( 'vals', ` let idxs = Array(vals.length); for (let i = 0; i < vals.length; i++) { let v = vals[i]; idxs[i] = ${conds}; } return idxs; ` ) as GetAllValues; } return { index, getOne, getAll, }; }