123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330 |
- import React from 'react';
- import uPlot, { AlignedData } from 'uplot';
- import {
- DataFrame,
- formattedValueToString,
- getFieldColorModeForField,
- getFieldSeriesColor,
- GrafanaTheme2,
- } from '@grafana/data';
- import {
- histogramBucketSizes,
- histogramFrameBucketMaxFieldName,
- } from '@grafana/data/src/transformations/transformers/histogram';
- import {
- VizLegendOptions,
- LegendDisplayMode,
- ScaleDistribution,
- AxisPlacement,
- ScaleDirection,
- ScaleOrientation,
- } from '@grafana/schema';
- import {
- Themeable2,
- UPlotConfigBuilder,
- UPlotChart,
- VizLayout,
- PlotLegend,
- measureText,
- UPLOT_AXIS_FONT_SIZE,
- } from '@grafana/ui';
- import { PanelOptions } from './models.gen';
- function incrRoundDn(num: number, incr: number) {
- return Math.floor(num / incr) * incr;
- }
- function incrRoundUp(num: number, incr: number) {
- return Math.ceil(num / incr) * incr;
- }
- export interface HistogramProps extends Themeable2 {
- options: PanelOptions; // used for diff
- alignedFrame: DataFrame; // This could take HistogramFields
- bucketSize: number;
- width: number;
- height: number;
- structureRev?: number; // a number that will change when the frames[] structure changes
- legend: VizLegendOptions;
- children?: (builder: UPlotConfigBuilder, frame: DataFrame) => React.ReactNode;
- }
- export function getBucketSize(frame: DataFrame) {
- // assumes BucketMin is fields[0] and BucktMax is fields[1]
- return frame.fields[1].values.get(0) - frame.fields[0].values.get(0);
- }
- const prepConfig = (frame: DataFrame, theme: GrafanaTheme2) => {
- // todo: scan all values in BucketMin and BucketMax fields to assert if uniform bucketSize
- let builder = new UPlotConfigBuilder();
- // assumes BucketMin is fields[0] and BucktMax is fields[1]
- let bucketSize = getBucketSize(frame);
- // splits shifter, to ensure splits always start at first bucket
- let xSplits: uPlot.Axis.Splits = (u, axisIdx, scaleMin, scaleMax, foundIncr, foundSpace) => {
- /** @ts-ignore */
- let minSpace = u.axes[axisIdx]._space;
- let bucketWidth = u.valToPos(u.data[0][0] + bucketSize, 'x') - u.valToPos(u.data[0][0], 'x');
- let firstSplit = u.data[0][0];
- let lastSplit = u.data[0][u.data[0].length - 1] + bucketSize;
- let splits = [];
- let skip = Math.ceil(minSpace / bucketWidth);
- for (let i = 0, s = firstSplit; s <= lastSplit; i++, s += bucketSize) {
- !(i % skip) && splits.push(s);
- }
- return splits;
- };
- builder.addScale({
- scaleKey: 'x', // bukkits
- isTime: false,
- distribution: ScaleDistribution.Linear,
- orientation: ScaleOrientation.Horizontal,
- direction: ScaleDirection.Right,
- range: (u, wantedMin, wantedMax) => {
- let fullRangeMin = u.data[0][0];
- let fullRangeMax = u.data[0][u.data[0].length - 1];
- // snap to bucket divisors...
- if (wantedMax === fullRangeMax) {
- wantedMax += bucketSize;
- } else {
- wantedMax = incrRoundUp(wantedMax, bucketSize);
- }
- if (wantedMin > fullRangeMin) {
- wantedMin = incrRoundDn(wantedMin, bucketSize);
- }
- return [wantedMin, wantedMax];
- },
- });
- builder.addScale({
- scaleKey: 'y', // counts
- isTime: false,
- distribution: ScaleDistribution.Linear,
- orientation: ScaleOrientation.Vertical,
- direction: ScaleDirection.Up,
- });
- const fmt = frame.fields[0].display!;
- const xAxisFormatter = (v: number) => {
- return formattedValueToString(fmt(v));
- };
- builder.addAxis({
- scaleKey: 'x',
- isTime: false,
- placement: AxisPlacement.Bottom,
- incrs: histogramBucketSizes,
- splits: xSplits,
- values: (u: uPlot, splits: any[]) => {
- const tickLabels = splits.map(xAxisFormatter);
- const maxWidth = tickLabels.reduce(
- (curMax, label) => Math.max(measureText(label, UPLOT_AXIS_FONT_SIZE).width, curMax),
- 0
- );
- const labelSpacing = 10;
- const maxCount = u.bbox.width / ((maxWidth + labelSpacing) * devicePixelRatio);
- const keepMod = Math.ceil(tickLabels.length / maxCount);
- return tickLabels.map((label, i) => (i % keepMod === 0 ? label : null));
- },
- //incrs: () => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((mult) => mult * bucketSize),
- //splits: config.xSplits,
- //values: config.xValues,
- //grid: false,
- //ticks: false,
- //gap: 15,
- theme,
- });
- builder.addAxis({
- scaleKey: 'y',
- isTime: false,
- placement: AxisPlacement.Left,
- //splits: config.xSplits,
- //values: config.xValues,
- //grid: false,
- //ticks: false,
- //gap: 15,
- theme,
- });
- builder.setCursor({
- points: { show: false },
- drag: {
- x: true,
- y: false,
- setScale: true,
- },
- });
- let pathBuilder = uPlot.paths.bars!({ align: 1, size: [1, Infinity] });
- let seriesIndex = 0;
- // assumes BucketMax is [1]
- for (let i = 2; i < frame.fields.length; i++) {
- const field = frame.fields[i];
- field.state = field.state ?? {};
- field.state.seriesIndex = seriesIndex++;
- const customConfig = { ...field.config.custom };
- const scaleKey = 'y';
- const colorMode = getFieldColorModeForField(field);
- const scaleColor = getFieldSeriesColor(field, theme);
- const seriesColor = scaleColor.color;
- builder.addSeries({
- scaleKey,
- lineWidth: customConfig.lineWidth,
- lineColor: seriesColor,
- //lineStyle: customConfig.lineStyle,
- fillOpacity: customConfig.fillOpacity,
- theme,
- colorMode,
- pathBuilder,
- //pointsBuilder: config.drawPoints,
- show: !customConfig.hideFrom?.vis,
- gradientMode: customConfig.gradientMode,
- thresholds: field.config.thresholds,
- hardMin: field.config.min,
- hardMax: field.config.max,
- softMin: customConfig.axisSoftMin,
- softMax: customConfig.axisSoftMax,
- // The following properties are not used in the uPlot config, but are utilized as transport for legend config
- dataFrameFieldIndex: {
- fieldIndex: i,
- frameIndex: 0,
- },
- });
- }
- return builder;
- };
- const preparePlotData = (frame: DataFrame) => {
- let data = [];
- for (const field of frame.fields) {
- if (field.name !== histogramFrameBucketMaxFieldName) {
- data.push(field.values.toArray());
- }
- }
- // uPlot's bars pathBuilder will draw rects even if 0 (to distinguish them from nulls)
- // but for histograms we want to omit them, so remap 0s -> nulls
- for (let i = 1; i < data.length; i++) {
- let counts = data[i];
- for (let j = 0; j < counts.length; j++) {
- if (counts[j] === 0) {
- counts[j] = null;
- }
- }
- }
- return data as AlignedData;
- };
- interface State {
- alignedData: AlignedData;
- config?: UPlotConfigBuilder;
- }
- export class Histogram extends React.Component<HistogramProps, State> {
- constructor(props: HistogramProps) {
- super(props);
- this.state = this.prepState(props);
- }
- prepState(props: HistogramProps, withConfig = true) {
- let state: State = null as any;
- const { alignedFrame } = props;
- if (alignedFrame) {
- state = {
- alignedData: preparePlotData(alignedFrame),
- };
- if (withConfig) {
- state.config = prepConfig(alignedFrame, this.props.theme);
- }
- }
- return state;
- }
- renderLegend(config: UPlotConfigBuilder) {
- const { legend } = this.props;
- if (!config || legend.displayMode === LegendDisplayMode.Hidden) {
- return null;
- }
- return <PlotLegend data={[this.props.alignedFrame]} config={config} maxHeight="35%" maxWidth="60%" {...legend} />;
- }
- componentDidUpdate(prevProps: HistogramProps) {
- const { structureRev, alignedFrame, bucketSize } = this.props;
- if (alignedFrame !== prevProps.alignedFrame) {
- let newState = this.prepState(this.props, false);
- if (newState) {
- const shouldReconfig =
- bucketSize !== prevProps.bucketSize ||
- this.props.options !== prevProps.options ||
- this.state.config === undefined ||
- structureRev !== prevProps.structureRev ||
- !structureRev;
- if (shouldReconfig) {
- newState.config = prepConfig(alignedFrame, this.props.theme);
- }
- }
- newState && this.setState(newState);
- }
- }
- render() {
- const { width, height, children, alignedFrame } = this.props;
- const { config } = this.state;
- if (!config) {
- return null;
- }
- return (
- <VizLayout width={width} height={height} legend={this.renderLegend(config)}>
- {(vizWidth: number, vizHeight: number) => (
- <UPlotChart
- config={this.state.config!}
- data={this.state.alignedData}
- width={vizWidth}
- height={vizHeight}
- timeRange={null as any}
- >
- {children ? children(config, alignedFrame) : null}
- </UPlotChart>
- )}
- </VizLayout>
- );
- }
- }
|