1import { isNumber } from 'lodash'; 2import { 3 DashboardCursorSync, 4 DataFrame, 5 DataHoverClearEvent, 6 DataHoverEvent, 7 DataHoverPayload, 8 FieldConfig, 9 FieldType, 10 formattedValueToString, 11 getFieldColorModeForField, 12 getFieldSeriesColor, 13 getFieldDisplayName, 14} from '@grafana/data'; 15 16import { UPlotConfigBuilder, UPlotConfigPrepFn } from '../uPlot/config/UPlotConfigBuilder'; 17import { 18 AxisPlacement, 19 GraphDrawStyle, 20 GraphFieldConfig, 21 GraphTresholdsStyleMode, 22 VisibilityMode, 23 ScaleDirection, 24 ScaleOrientation, 25 VizLegendOptions, 26} from '@grafana/schema'; 27import { collectStackingGroups, orderIdsByCalcs, preparePlotData } from '../uPlot/utils'; 28import uPlot from 'uplot'; 29import { buildScaleKey } from '../GraphNG/utils'; 30 31const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); 32 33const defaultConfig: GraphFieldConfig = { 34 drawStyle: GraphDrawStyle.Line, 35 showPoints: VisibilityMode.Auto, 36 axisPlacement: AxisPlacement.Auto, 37}; 38 39export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ 40 sync?: () => DashboardCursorSync; 41 legend?: VizLegendOptions; 42}> = ({ 43 frame, 44 theme, 45 timeZone, 46 getTimeRange, 47 eventBus, 48 sync, 49 allFrames, 50 renderers, 51 legend, 52 tweakScale = (opts) => opts, 53 tweakAxis = (opts) => opts, 54}) => { 55 const builder = new UPlotConfigBuilder(timeZone); 56 57 builder.setPrepData((prepData) => preparePlotData(prepData, undefined, legend)); 58 59 // X is the first field in the aligned frame 60 const xField = frame.fields[0]; 61 if (!xField) { 62 return builder; // empty frame with no options 63 } 64 65 let seriesIndex = 0; 66 67 const xScaleKey = 'x'; 68 let xScaleUnit = '_x'; 69 let yScaleKey = ''; 70 71 if (xField.type === FieldType.time) { 72 xScaleUnit = 'time'; 73 builder.addScale({ 74 scaleKey: xScaleKey, 75 orientation: ScaleOrientation.Horizontal, 76 direction: ScaleDirection.Right, 77 isTime: true, 78 range: () => { 79 const r = getTimeRange(); 80 return [r.from.valueOf(), r.to.valueOf()]; 81 }, 82 }); 83 84 builder.addAxis({ 85 scaleKey: xScaleKey, 86 isTime: true, 87 placement: AxisPlacement.Bottom, 88 label: xField.config.custom?.axisLabel, 89 timeZone, 90 theme, 91 grid: { show: xField.config.custom?.axisGridShow }, 92 }); 93 } else { 94 // Not time! 95 if (xField.config.unit) { 96 xScaleUnit = xField.config.unit; 97 } 98 99 builder.addScale({ 100 scaleKey: xScaleKey, 101 orientation: ScaleOrientation.Horizontal, 102 direction: ScaleDirection.Right, 103 }); 104 105 builder.addAxis({ 106 scaleKey: xScaleKey, 107 placement: AxisPlacement.Bottom, 108 label: xField.config.custom?.axisLabel, 109 theme, 110 grid: { show: xField.config.custom?.axisGridShow }, 111 }); 112 } 113 114 let customRenderedFields = 115 renderers?.flatMap((r) => Object.values(r.fieldMap).filter((name) => r.indicesOnly.indexOf(name) === -1)) ?? []; 116 117 const stackingGroups: Map<string, number[]> = new Map(); 118 119 let indexByName: Map<string, number> | undefined; 120 121 for (let i = 1; i < frame.fields.length; i++) { 122 const field = frame.fields[i]; 123 124 const config = { 125 ...field.config, 126 custom: { 127 ...defaultConfig, 128 ...field.config.custom, 129 }, 130 } as FieldConfig<GraphFieldConfig>; 131 132 const customConfig: GraphFieldConfig = config.custom!; 133 134 if (field === xField || field.type !== FieldType.number) { 135 continue; 136 } 137 138 // TODO: skip this for fields with custom renderers? 139 field.state!.seriesIndex = seriesIndex++; 140 141 const fmt = field.display ?? defaultFormatter; 142 143 const scaleKey = buildScaleKey(config); 144 const colorMode = getFieldColorModeForField(field); 145 const scaleColor = getFieldSeriesColor(field, theme); 146 const seriesColor = scaleColor.color; 147 148 // The builder will manage unique scaleKeys and combine where appropriate 149 builder.addScale( 150 tweakScale( 151 { 152 scaleKey, 153 orientation: ScaleOrientation.Vertical, 154 direction: ScaleDirection.Up, 155 distribution: customConfig.scaleDistribution?.type, 156 log: customConfig.scaleDistribution?.log, 157 min: field.config.min, 158 max: field.config.max, 159 softMin: customConfig.axisSoftMin, 160 softMax: customConfig.axisSoftMax, 161 }, 162 field 163 ) 164 ); 165 166 if (!yScaleKey) { 167 yScaleKey = scaleKey; 168 } 169 170 if (customConfig.axisPlacement !== AxisPlacement.Hidden) { 171 builder.addAxis( 172 tweakAxis( 173 { 174 scaleKey, 175 label: customConfig.axisLabel, 176 size: customConfig.axisWidth, 177 placement: customConfig.axisPlacement ?? AxisPlacement.Auto, 178 formatValue: (v) => formattedValueToString(fmt(v)), 179 theme, 180 grid: { show: customConfig.axisGridShow }, 181 }, 182 field 183 ) 184 ); 185 } 186 187 const showPoints = 188 customConfig.drawStyle === GraphDrawStyle.Points ? VisibilityMode.Always : customConfig.showPoints; 189 190 let pointsFilter: uPlot.Series.Points.Filter = () => null; 191 192 if (customConfig.spanNulls !== true) { 193 pointsFilter = (u, seriesIdx, show, gaps) => { 194 let filtered = []; 195 196 let series = u.series[seriesIdx]; 197 198 if (!show && gaps && gaps.length) { 199 const [firstIdx, lastIdx] = series.idxs!; 200 const xData = u.data[0]; 201 const firstPos = Math.round(u.valToPos(xData[firstIdx], 'x', true)); 202 const lastPos = Math.round(u.valToPos(xData[lastIdx], 'x', true)); 203 204 if (gaps[0][0] === firstPos) { 205 filtered.push(firstIdx); 206 } 207 208 // show single points between consecutive gaps that share end/start 209 for (let i = 0; i < gaps.length; i++) { 210 let thisGap = gaps[i]; 211 let nextGap = gaps[i + 1]; 212 213 if (nextGap && thisGap[1] === nextGap[0]) { 214 filtered.push(u.posToIdx(thisGap[1], true)); 215 } 216 } 217 218 if (gaps[gaps.length - 1][1] === lastPos) { 219 filtered.push(lastIdx); 220 } 221 } 222 223 return filtered.length ? filtered : null; 224 }; 225 } 226 227 let { fillOpacity } = customConfig; 228 229 let pathBuilder: uPlot.Series.PathBuilder | null = null; 230 let pointsBuilder: uPlot.Series.Points.Show | null = null; 231 232 if (field.state?.origin) { 233 if (!indexByName) { 234 indexByName = getNamesToFieldIndex(frame, allFrames); 235 } 236 237 const originFrame = allFrames[field.state.origin.frameIndex]; 238 const originField = originFrame?.fields[field.state.origin.fieldIndex]; 239 240 const dispName = getFieldDisplayName(originField ?? field, originFrame, allFrames); 241 242 // disable default renderers 243 if (customRenderedFields.indexOf(dispName) >= 0) { 244 pathBuilder = () => null; 245 pointsBuilder = () => undefined; 246 } 247 248 if (customConfig.fillBelowTo) { 249 const t = indexByName.get(dispName); 250 const b = indexByName.get(customConfig.fillBelowTo); 251 if (isNumber(b) && isNumber(t)) { 252 builder.addBand({ 253 series: [t, b], 254 fill: undefined, // using null will have the band use fill options from `t` 255 }); 256 257 if (!fillOpacity) { 258 fillOpacity = 35; // default from flot 259 } 260 } else { 261 fillOpacity = 0; 262 } 263 } 264 } 265 266 builder.addSeries({ 267 pathBuilder, 268 pointsBuilder, 269 scaleKey, 270 showPoints, 271 pointsFilter, 272 colorMode, 273 fillOpacity, 274 theme, 275 drawStyle: customConfig.drawStyle!, 276 lineColor: customConfig.lineColor ?? seriesColor, 277 lineWidth: customConfig.lineWidth, 278 lineInterpolation: customConfig.lineInterpolation, 279 lineStyle: customConfig.lineStyle, 280 barAlignment: customConfig.barAlignment, 281 barWidthFactor: customConfig.barWidthFactor, 282 barMaxWidth: customConfig.barMaxWidth, 283 pointSize: customConfig.pointSize, 284 spanNulls: customConfig.spanNulls || false, 285 show: !customConfig.hideFrom?.viz, 286 gradientMode: customConfig.gradientMode, 287 thresholds: config.thresholds, 288 hardMin: field.config.min, 289 hardMax: field.config.max, 290 softMin: customConfig.axisSoftMin, 291 softMax: customConfig.axisSoftMax, 292 // The following properties are not used in the uPlot config, but are utilized as transport for legend config 293 dataFrameFieldIndex: field.state?.origin, 294 }); 295 296 // Render thresholds in graph 297 if (customConfig.thresholdsStyle && config.thresholds) { 298 const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphTresholdsStyleMode.Off; 299 if (thresholdDisplay !== GraphTresholdsStyleMode.Off) { 300 builder.addThresholds({ 301 config: customConfig.thresholdsStyle, 302 thresholds: config.thresholds, 303 scaleKey, 304 theme, 305 hardMin: field.config.min, 306 hardMax: field.config.max, 307 softMin: customConfig.axisSoftMin, 308 softMax: customConfig.axisSoftMax, 309 }); 310 } 311 } 312 collectStackingGroups(field, stackingGroups, seriesIndex); 313 } 314 315 if (stackingGroups.size !== 0) { 316 for (const [_, seriesIds] of stackingGroups.entries()) { 317 const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame }); 318 for (let j = seriesIdxs.length - 1; j > 0; j--) { 319 builder.addBand({ 320 series: [seriesIdxs[j], seriesIdxs[j - 1]], 321 }); 322 } 323 } 324 } 325 326 // hook up custom/composite renderers 327 renderers?.forEach((r) => { 328 if (!indexByName) { 329 indexByName = getNamesToFieldIndex(frame, allFrames); 330 } 331 let fieldIndices: Record<string, number> = {}; 332 333 for (let key in r.fieldMap) { 334 let dispName = r.fieldMap[key]; 335 fieldIndices[key] = indexByName.get(dispName)!; 336 } 337 338 r.init(builder, fieldIndices); 339 }); 340 341 builder.scaleKeys = [xScaleKey, yScaleKey]; 342 343 // if hovered value is null, how far we may scan left/right to hover nearest non-null 344 const hoverProximityPx = 15; 345 346 let cursor: Partial<uPlot.Cursor> = { 347 // this scans left and right from cursor position to find nearest data index with value != null 348 // TODO: do we want to only scan past undefined values, but halt at explicit null values? 349 dataIdx: (self, seriesIdx, hoveredIdx, cursorXVal) => { 350 let seriesData = self.data[seriesIdx]; 351 352 if (seriesData[hoveredIdx] == null) { 353 let nonNullLft = hoveredIdx, 354 nonNullRgt = hoveredIdx, 355 i; 356 357 i = hoveredIdx; 358 while (nonNullLft === hoveredIdx && i-- > 0) { 359 if (seriesData[i] != null) { 360 nonNullLft = i; 361 } 362 } 363 364 i = hoveredIdx; 365 while (nonNullRgt === hoveredIdx && i++ < seriesData.length) { 366 if (seriesData[i] != null) { 367 nonNullRgt = i; 368 } 369 } 370 371 let xVals = self.data[0]; 372 373 let curPos = self.valToPos(cursorXVal, 'x'); 374 let rgtPos = self.valToPos(xVals[nonNullRgt], 'x'); 375 let lftPos = self.valToPos(xVals[nonNullLft], 'x'); 376 377 let lftDelta = curPos - lftPos; 378 let rgtDelta = rgtPos - curPos; 379 380 if (lftDelta <= rgtDelta) { 381 if (lftDelta <= hoverProximityPx) { 382 hoveredIdx = nonNullLft; 383 } 384 } else { 385 if (rgtDelta <= hoverProximityPx) { 386 hoveredIdx = nonNullRgt; 387 } 388 } 389 } 390 391 return hoveredIdx; 392 }, 393 }; 394 395 if (sync && sync() !== DashboardCursorSync.Off) { 396 const payload: DataHoverPayload = { 397 point: { 398 [xScaleKey]: null, 399 [yScaleKey]: null, 400 }, 401 data: frame, 402 }; 403 const hoverEvent = new DataHoverEvent(payload); 404 cursor.sync = { 405 key: '__global_', 406 filters: { 407 pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => { 408 if (sync && sync() === DashboardCursorSync.Off) { 409 return false; 410 } 411 412 payload.rowIndex = dataIdx; 413 if (x < 0 && y < 0) { 414 payload.point[xScaleUnit] = null; 415 payload.point[yScaleKey] = null; 416 eventBus.publish(new DataHoverClearEvent()); 417 } else { 418 // convert the points 419 payload.point[xScaleUnit] = src.posToVal(x, xScaleKey); 420 payload.point[yScaleKey] = src.posToVal(y, yScaleKey); 421 payload.point.panelRelY = y > 0 ? y / h : 1; // used by old graph panel to position tooltip 422 eventBus.publish(hoverEvent); 423 hoverEvent.payload.down = undefined; 424 } 425 return true; 426 }, 427 }, 428 // ??? setSeries: syncMode === DashboardCursorSync.Tooltip, 429 //TODO: remove any once https://github.com/leeoniya/uPlot/pull/611 got merged or the typing is fixed 430 scales: [xScaleKey, null as any], 431 match: [() => true, () => true], 432 }; 433 } 434 435 builder.setSync(); 436 builder.setCursor(cursor); 437 438 return builder; 439}; 440 441export function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map<string, number> { 442 const originNames = new Map<string, number>(); 443 frame.fields.forEach((field, i) => { 444 const origin = field.state?.origin; 445 if (origin) { 446 const origField = allFrames[origin.frameIndex]?.fields[origin.fieldIndex]; 447 if (origField) { 448 originNames.set(getFieldDisplayName(origField, allFrames[origin.frameIndex], allFrames), i); 449 } 450 } 451 }); 452 return originNames; 453} 454