mirror of https://github.com/grafana/grafana
New Panel: Histogram (#33752)
parent
a40946b6aa
commit
5fd7c34420
@ -0,0 +1,172 @@ |
||||
import { toDataFrame } from '../../dataframe/processDataFrame'; |
||||
import { FieldType } from '../../types/dataFrame'; |
||||
import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; |
||||
import { histogramTransformer, buildHistogram, histogramFieldsToFrame } from './histogram'; |
||||
|
||||
describe('histogram frames frames', () => { |
||||
beforeAll(() => { |
||||
mockTransformationsRegistry([histogramTransformer]); |
||||
}); |
||||
|
||||
it('by first time field', () => { |
||||
const series1 = toDataFrame({ |
||||
fields: [ |
||||
{ name: 'A', type: FieldType.number, values: [1, 2, 3, 4, 5] }, |
||||
{ name: 'B', type: FieldType.number, values: [3, 4, 5, 6, 7] }, |
||||
{ name: 'C', type: FieldType.number, values: [5, 6, 7, 8, 9] }, |
||||
], |
||||
}); |
||||
|
||||
const series2 = toDataFrame({ |
||||
fields: [{ name: 'C', type: FieldType.number, values: [5, 6, 7, 8, 9] }], |
||||
}); |
||||
|
||||
const out = histogramFieldsToFrame(buildHistogram([series1, series2])!); |
||||
expect( |
||||
out.fields.map((f) => ({ |
||||
name: f.name, |
||||
values: f.values.toArray(), |
||||
})) |
||||
).toMatchInlineSnapshot(` |
||||
Array [ |
||||
Object { |
||||
"name": "BucketMin", |
||||
"values": Array [ |
||||
1, |
||||
2, |
||||
3, |
||||
4, |
||||
5, |
||||
6, |
||||
7, |
||||
8, |
||||
9, |
||||
], |
||||
}, |
||||
Object { |
||||
"name": "BucketMax", |
||||
"values": Array [ |
||||
2, |
||||
3, |
||||
4, |
||||
5, |
||||
6, |
||||
7, |
||||
8, |
||||
9, |
||||
10, |
||||
], |
||||
}, |
||||
Object { |
||||
"name": "A", |
||||
"values": Array [ |
||||
1, |
||||
1, |
||||
1, |
||||
1, |
||||
1, |
||||
0, |
||||
0, |
||||
0, |
||||
0, |
||||
], |
||||
}, |
||||
Object { |
||||
"name": "B", |
||||
"values": Array [ |
||||
0, |
||||
0, |
||||
1, |
||||
1, |
||||
1, |
||||
1, |
||||
1, |
||||
0, |
||||
0, |
||||
], |
||||
}, |
||||
Object { |
||||
"name": "C", |
||||
"values": Array [ |
||||
0, |
||||
0, |
||||
0, |
||||
0, |
||||
1, |
||||
1, |
||||
1, |
||||
1, |
||||
1, |
||||
], |
||||
}, |
||||
Object { |
||||
"name": "C", |
||||
"values": Array [ |
||||
0, |
||||
0, |
||||
0, |
||||
0, |
||||
1, |
||||
1, |
||||
1, |
||||
1, |
||||
1, |
||||
], |
||||
}, |
||||
] |
||||
`);
|
||||
|
||||
const out2 = histogramFieldsToFrame(buildHistogram([series1, series2], { combine: true })!); |
||||
expect( |
||||
out2.fields.map((f) => ({ |
||||
name: f.name, |
||||
values: f.values.toArray(), |
||||
})) |
||||
).toMatchInlineSnapshot(` |
||||
Array [ |
||||
Object { |
||||
"name": "BucketMin", |
||||
"values": Array [ |
||||
1, |
||||
2, |
||||
3, |
||||
4, |
||||
5, |
||||
6, |
||||
7, |
||||
8, |
||||
9, |
||||
], |
||||
}, |
||||
Object { |
||||
"name": "BucketMax", |
||||
"values": Array [ |
||||
2, |
||||
3, |
||||
4, |
||||
5, |
||||
6, |
||||
7, |
||||
8, |
||||
9, |
||||
10, |
||||
], |
||||
}, |
||||
Object { |
||||
"name": "Count", |
||||
"values": Array [ |
||||
1, |
||||
1, |
||||
2, |
||||
2, |
||||
4, |
||||
3, |
||||
3, |
||||
2, |
||||
2, |
||||
], |
||||
}, |
||||
] |
||||
`);
|
||||
}); |
||||
}); |
@ -0,0 +1,306 @@ |
||||
import { DataTransformerInfo } from '../../types'; |
||||
import { map } from 'rxjs/operators'; |
||||
|
||||
import { DataTransformerID } from './ids'; |
||||
import { DataFrame, Field, FieldType } from '../../types/dataFrame'; |
||||
import { ArrayVector } from '../../vector/ArrayVector'; |
||||
import { AlignedData, join } from './joinDataFrames'; |
||||
|
||||
/* eslint-disable */ |
||||
// prettier-ignore
|
||||
/** |
||||
* @internal |
||||
*/ |
||||
export const histogramBucketSizes = [ |
||||
.001, .002, .0025, .005, |
||||
.01, .02, .025, .05, |
||||
.1, .2, .25, .5, |
||||
1, 2, 4, 5, |
||||
10, 20, 25, 50, |
||||
100, 200, 250, 500, |
||||
1000, 2000, 2500, 5000, |
||||
]; |
||||
/* eslint-enable */ |
||||
|
||||
const histFilter = [null]; |
||||
const histSort = (a: number, b: number) => a - b; |
||||
|
||||
/** |
||||
* @alpha |
||||
*/ |
||||
export interface HistogramTransformerOptions { |
||||
bucketSize?: number; // 0 is auto
|
||||
bucketOffset?: number; |
||||
// xMin?: number;
|
||||
// xMax?: number;
|
||||
combine?: boolean; // if multiple series are input, join them into one
|
||||
} |
||||
|
||||
/** |
||||
* This is a helper class to use the same text in both a panel and transformer UI |
||||
* |
||||
* @internal |
||||
*/ |
||||
export const histogramFieldInfo = { |
||||
bucketSize: { |
||||
name: 'Bucket size', |
||||
description: undefined, |
||||
}, |
||||
bucketOffset: { |
||||
name: 'Bucket offset', |
||||
description: 'for non-zero-based buckets', |
||||
}, |
||||
combine: { |
||||
name: 'Combine series', |
||||
description: 'combine all series into a single histogram', |
||||
}, |
||||
}; |
||||
|
||||
/** |
||||
* @alpha |
||||
*/ |
||||
export const histogramTransformer: DataTransformerInfo<HistogramTransformerOptions> = { |
||||
id: DataTransformerID.histogram, |
||||
name: 'Histogram', |
||||
description: 'Calculate a histogram from input data', |
||||
defaultOptions: { |
||||
fields: {}, |
||||
}, |
||||
|
||||
/** |
||||
* Return a modified copy of the series. If the transform is not or should not |
||||
* be applied, just return the input series |
||||
*/ |
||||
operator: (options) => (source) => |
||||
source.pipe( |
||||
map((data) => { |
||||
if (!Array.isArray(data) || data.length === 0) { |
||||
return data; |
||||
} |
||||
const hist = buildHistogram(data, options); |
||||
if (hist == null) { |
||||
return []; |
||||
} |
||||
return [histogramFieldsToFrame(hist)]; |
||||
}) |
||||
), |
||||
}; |
||||
|
||||
/** |
||||
* @internal |
||||
*/ |
||||
export const histogramFrameBucketMinFieldName = 'BucketMin'; |
||||
|
||||
/** |
||||
* @internal |
||||
*/ |
||||
export const histogramFrameBucketMaxFieldName = 'BucketMax'; |
||||
|
||||
/** |
||||
* @alpha |
||||
*/ |
||||
export interface HistogramFields { |
||||
bucketMin: Field; |
||||
bucketMax: Field; |
||||
counts: Field[]; // frequency
|
||||
} |
||||
|
||||
/** |
||||
* Given a frame, find the explicit histogram fields |
||||
* |
||||
* @alpha |
||||
*/ |
||||
export function getHistogramFields(frame: DataFrame): HistogramFields | undefined { |
||||
let bucketMin: Field | undefined = undefined; |
||||
let bucketMax: Field | undefined = undefined; |
||||
const counts: Field[] = []; |
||||
for (const field of frame.fields) { |
||||
if (field.name === histogramFrameBucketMinFieldName) { |
||||
bucketMin = field; |
||||
} else if (field.name === histogramFrameBucketMaxFieldName) { |
||||
bucketMax = field; |
||||
} else if (field.type === FieldType.number) { |
||||
counts.push(field); |
||||
} |
||||
} |
||||
if (bucketMin && bucketMax && counts.length) { |
||||
return { |
||||
bucketMin, |
||||
bucketMax, |
||||
counts, |
||||
}; |
||||
} |
||||
return undefined; |
||||
} |
||||
|
||||
/** |
||||
* @alpha |
||||
*/ |
||||
export function buildHistogram(frames: DataFrame[], options?: HistogramTransformerOptions): HistogramFields | null { |
||||
let bucketSize = options?.bucketSize; |
||||
let bucketOffset = options?.bucketOffset ?? 0; |
||||
|
||||
// if bucket size is auto, try to calc from all numeric fields
|
||||
if (!bucketSize) { |
||||
let min = Infinity, |
||||
max = -Infinity; |
||||
|
||||
// TODO: include field configs!
|
||||
for (const frame of frames) { |
||||
for (const field of frame.fields) { |
||||
if (field.type === FieldType.number) { |
||||
for (const value of field.values.toArray()) { |
||||
min = Math.min(min, value); |
||||
max = Math.max(max, value); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
let range = Math.abs(max - min); |
||||
|
||||
// choose bucket
|
||||
for (const size of histogramBucketSizes) { |
||||
if (range / 10 < size) { |
||||
bucketSize = size; |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
|
||||
const getBucket = (v: number) => incrRoundDn(v - bucketOffset, bucketSize!) + bucketOffset; |
||||
|
||||
let histograms: AlignedData[] = []; |
||||
let counts: Field[] = []; |
||||
|
||||
for (const frame of frames) { |
||||
for (const field of frame.fields) { |
||||
if (field.type === FieldType.number) { |
||||
let fieldHist = histogram(field.values.toArray(), getBucket, histFilter, histSort) as AlignedData; |
||||
histograms.push(fieldHist); |
||||
counts.push({ ...field }); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Quit early for empty a
|
||||
if (!counts.length) { |
||||
return null; |
||||
} |
||||
|
||||
// align histograms
|
||||
let joinedHists = join(histograms); |
||||
|
||||
// zero-fill all undefined values (missing buckets -> 0 counts)
|
||||
for (let histIdx = 1; histIdx < joinedHists.length; histIdx++) { |
||||
let hist = joinedHists[histIdx]; |
||||
|
||||
for (let bucketIdx = 0; bucketIdx < hist.length; bucketIdx++) { |
||||
if (hist[bucketIdx] == null) { |
||||
hist[bucketIdx] = 0; |
||||
} |
||||
} |
||||
} |
||||
|
||||
const bucketMin = { |
||||
name: histogramFrameBucketMinFieldName, |
||||
values: new ArrayVector(joinedHists[0]), |
||||
type: FieldType.number, |
||||
config: {}, |
||||
}; |
||||
const bucketMax = { |
||||
name: histogramFrameBucketMaxFieldName, |
||||
values: new ArrayVector(joinedHists[0].map((v) => v + bucketSize!)), |
||||
type: FieldType.number, |
||||
config: {}, |
||||
}; |
||||
|
||||
if (options?.combine) { |
||||
const vals = new Array(joinedHists[0].length).fill(0); |
||||
for (let i = 1; i < joinedHists.length; i++) { |
||||
for (let j = 0; j < vals.length; j++) { |
||||
vals[j] += joinedHists[i][j]; |
||||
} |
||||
} |
||||
counts = [ |
||||
{ |
||||
...counts[0], |
||||
name: 'Count', |
||||
values: new ArrayVector(vals), |
||||
}, |
||||
]; |
||||
} else { |
||||
counts.forEach((field, i) => { |
||||
field.values = new ArrayVector(joinedHists[i + 1]); |
||||
}); |
||||
} |
||||
|
||||
return { |
||||
bucketMin, |
||||
bucketMax, |
||||
counts, |
||||
}; |
||||
} |
||||
|
||||
// function incrRound(num: number, incr: number) {
|
||||
// return Math.round(num / incr) * incr;
|
||||
// }
|
||||
|
||||
// function incrRoundUp(num: number, incr: number) {
|
||||
// return Math.ceil(num / incr) * incr;
|
||||
// }
|
||||
|
||||
function incrRoundDn(num: number, incr: number) { |
||||
return Math.floor(num / incr) * incr; |
||||
} |
||||
|
||||
function histogram( |
||||
vals: number[], |
||||
getBucket: (v: number) => number, |
||||
filterOut?: any[] | null, |
||||
sort?: ((a: any, b: any) => number) | null |
||||
) { |
||||
let hist = new Map(); |
||||
|
||||
for (let i = 0; i < vals.length; i++) { |
||||
let v = vals[i]; |
||||
|
||||
if (v != null) { |
||||
v = getBucket(v); |
||||
} |
||||
|
||||
let entry = hist.get(v); |
||||
|
||||
if (entry) { |
||||
entry.count++; |
||||
} else { |
||||
hist.set(v, { value: v, count: 1 }); |
||||
} |
||||
} |
||||
|
||||
filterOut && filterOut.forEach((v) => hist.delete(v)); |
||||
|
||||
let bins = [...hist.values()]; |
||||
|
||||
sort && bins.sort((a, b) => sort(a.value, b.value)); |
||||
|
||||
let values = Array(bins.length); |
||||
let counts = Array(bins.length); |
||||
|
||||
for (let i = 0; i < bins.length; i++) { |
||||
values[i] = bins[i].value; |
||||
counts[i] = bins[i].count; |
||||
} |
||||
|
||||
return [values, counts]; |
||||
} |
||||
|
||||
/** |
||||
* @internal |
||||
*/ |
||||
export function histogramFieldsToFrame(info: HistogramFields): DataFrame { |
||||
return { |
||||
fields: [info.bucketMin, info.bucketMax, ...info.counts], |
||||
length: info.bucketMin.values.length, |
||||
}; |
||||
} |
@ -0,0 +1,91 @@ |
||||
import React, { FormEvent, useCallback } from 'react'; |
||||
import { DataTransformerID, standardTransformers, TransformerRegistryItem, TransformerUIProps } from '@grafana/data'; |
||||
|
||||
import { |
||||
HistogramTransformerOptions, |
||||
histogramFieldInfo, |
||||
} from '@grafana/data/src/transformations/transformers/histogram'; |
||||
import { InlineField, InlineFieldRow, InlineSwitch, Input } from '@grafana/ui'; |
||||
|
||||
export const HistogramTransformerEditor: React.FC<TransformerUIProps<HistogramTransformerOptions>> = ({ |
||||
input, |
||||
options, |
||||
onChange, |
||||
}) => { |
||||
const labelWidth = 18; |
||||
|
||||
const onBucketSizeChanged = useCallback( |
||||
(evt: FormEvent<HTMLInputElement>) => { |
||||
const val = evt.currentTarget.valueAsNumber; |
||||
onChange({ |
||||
...options, |
||||
bucketSize: isNaN(val) ? undefined : val, |
||||
}); |
||||
}, |
||||
[onChange, options] |
||||
); |
||||
|
||||
const onBucketOffsetChanged = useCallback( |
||||
(evt: FormEvent<HTMLInputElement>) => { |
||||
const val = evt.currentTarget.valueAsNumber; |
||||
onChange({ |
||||
...options, |
||||
bucketOffset: isNaN(val) ? undefined : val, |
||||
}); |
||||
}, |
||||
[onChange, options] |
||||
); |
||||
|
||||
const onToggleCombine = useCallback(() => { |
||||
onChange({ |
||||
...options, |
||||
combine: !options.combine, |
||||
}); |
||||
}, [onChange, options]); |
||||
|
||||
return ( |
||||
<div> |
||||
<InlineFieldRow> |
||||
<InlineField |
||||
labelWidth={labelWidth} |
||||
label={histogramFieldInfo.bucketSize.name} |
||||
tooltip={histogramFieldInfo.bucketSize.description} |
||||
> |
||||
<Input type="number" value={options.bucketSize} placeholder="auto" onChange={onBucketSizeChanged} min={0} /> |
||||
</InlineField> |
||||
</InlineFieldRow> |
||||
<InlineFieldRow> |
||||
<InlineField |
||||
labelWidth={labelWidth} |
||||
label={histogramFieldInfo.bucketOffset.name} |
||||
tooltip={histogramFieldInfo.bucketOffset.description} |
||||
> |
||||
<Input |
||||
type="number" |
||||
value={options.bucketOffset} |
||||
placeholder="none" |
||||
onChange={onBucketOffsetChanged} |
||||
min={0} |
||||
/> |
||||
</InlineField> |
||||
</InlineFieldRow> |
||||
<InlineFieldRow> |
||||
<InlineField |
||||
labelWidth={labelWidth} |
||||
label={histogramFieldInfo.combine.name} |
||||
tooltip={histogramFieldInfo.combine.description} |
||||
> |
||||
<InlineSwitch value={options.combine ?? false} onChange={onToggleCombine} /> |
||||
</InlineField> |
||||
</InlineFieldRow> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export const histogramTransformRegistryItem: TransformerRegistryItem<HistogramTransformerOptions> = { |
||||
id: DataTransformerID.histogram, |
||||
editor: HistogramTransformerEditor, |
||||
transformation: standardTransformers.histogramTransformer, |
||||
name: standardTransformers.histogramTransformer.name, |
||||
description: standardTransformers.histogramTransformer.description, |
||||
}; |
@ -0,0 +1,257 @@ |
||||
import React from 'react'; |
||||
import uPlot, { AlignedData } from 'uplot'; |
||||
import { |
||||
DataFrame, |
||||
getFieldColorModeForField, |
||||
getFieldDisplayName, |
||||
getFieldSeriesColor, |
||||
GrafanaTheme2, |
||||
histogramBucketSizes, |
||||
} from '@grafana/data'; |
||||
import { |
||||
Themeable2, |
||||
UPlotConfigBuilder, |
||||
VizLegendOptions, |
||||
UPlotChart, |
||||
VizLayout, |
||||
AxisPlacement, |
||||
ScaleDirection, |
||||
ScaleDistribution, |
||||
ScaleOrientation, |
||||
} from '@grafana/ui'; |
||||
|
||||
import { histogramFrameBucketMaxFieldName } from '@grafana/data/src/transformations/transformers/histogram'; |
||||
import { PanelOptions } from './models.gen'; |
||||
|
||||
export interface HistogramProps extends Themeable2 { |
||||
options: PanelOptions; // used for diff
|
||||
alignedFrame: DataFrame; // This could take HistogramFields
|
||||
width: number; |
||||
height: number; |
||||
structureRev?: number; // a number that will change when the frames[] structure changes
|
||||
legend: VizLegendOptions; |
||||
//onLegendClick?: (event: GraphNGLegendEvent) => void;
|
||||
children?: (builder: UPlotConfigBuilder, frame: DataFrame) => React.ReactNode; |
||||
|
||||
//prepConfig: (frame: DataFrame) => UPlotConfigBuilder;
|
||||
//propsToDiff?: string[];
|
||||
//renderLegend: (config: UPlotConfigBuilder) => React.ReactElement;
|
||||
} |
||||
|
||||
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 = frame.fields[1].values.get(0) - frame.fields[0].values.get(0); |
||||
|
||||
// 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) => [u.data[0][0], u.data[0][u.data[0].length - 1] + bucketSize], |
||||
}); |
||||
|
||||
builder.addScale({ |
||||
scaleKey: 'y', // counts
|
||||
isTime: false, |
||||
distribution: ScaleDistribution.Linear, |
||||
orientation: ScaleOrientation.Vertical, |
||||
direction: ScaleDirection.Up, |
||||
}); |
||||
|
||||
builder.addAxis({ |
||||
scaleKey: 'x', |
||||
isTime: false, |
||||
placement: AxisPlacement.Bottom, |
||||
incrs: histogramBucketSizes, |
||||
splits: xSplits, |
||||
//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, |
||||
}); |
||||
|
||||
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!.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?.graph, |
||||
gradientMode: customConfig.gradientMode, |
||||
thresholds: field.config.thresholds, |
||||
|
||||
// The following properties are not used in the uPlot config, but are utilized as transport for legend config
|
||||
// dataFrameFieldIndex: {
|
||||
// fieldIndex: i,
|
||||
// frameIndex: 0,
|
||||
// },
|
||||
fieldName: getFieldDisplayName(field, frame), |
||||
hideInLegend: customConfig.hideFrom?.legend, |
||||
}); |
||||
} |
||||
|
||||
return builder; |
||||
}; |
||||
|
||||
const preparePlotData = (frame: DataFrame) => { |
||||
let data: AlignedData = [] as any; |
||||
|
||||
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; |
||||
}; |
||||
|
||||
const renderLegend = (config: UPlotConfigBuilder) => { |
||||
return null; |
||||
}; |
||||
|
||||
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; |
||||
} |
||||
|
||||
componentDidUpdate(prevProps: HistogramProps) { |
||||
const { structureRev, alignedFrame } = this.props; |
||||
|
||||
if (alignedFrame !== prevProps.alignedFrame) { |
||||
let newState = this.prepState(this.props, false); |
||||
|
||||
if (newState) { |
||||
const shouldReconfig = |
||||
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={renderLegend(config) as any}> |
||||
{(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> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,51 @@ |
||||
import React, { useMemo } from 'react'; |
||||
import { PanelProps, buildHistogram, getHistogramFields } from '@grafana/data'; |
||||
|
||||
import { Histogram } from './Histogram'; |
||||
import { PanelOptions } from './models.gen'; |
||||
import { useTheme2 } from '@grafana/ui'; |
||||
|
||||
type Props = PanelProps<PanelOptions>; |
||||
|
||||
import { histogramFieldsToFrame } from '@grafana/data/src/transformations/transformers/histogram'; |
||||
|
||||
export const HistogramPanel: React.FC<Props> = ({ data, options, width, height }) => { |
||||
const theme = useTheme2(); |
||||
|
||||
const histogram = useMemo(() => { |
||||
if (!data?.series?.length) { |
||||
return undefined; |
||||
} |
||||
if (data.series.length === 1) { |
||||
const info = getHistogramFields(data.series[0]); |
||||
if (info) { |
||||
return histogramFieldsToFrame(info); |
||||
} |
||||
} |
||||
const hist = buildHistogram(data.series, options); |
||||
if (!hist) { |
||||
return undefined; |
||||
} |
||||
return histogramFieldsToFrame(hist); |
||||
}, [data.series, options]); |
||||
|
||||
if (!histogram || !histogram.fields.length) { |
||||
return ( |
||||
<div className="panel-empty"> |
||||
<p>No histogram found in response</p> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<Histogram |
||||
options={options} |
||||
theme={theme} |
||||
legend={null as any} // TODO!
|
||||
structureRev={data.structureRev} |
||||
width={width} |
||||
height={height} |
||||
alignedFrame={histogram} |
||||
/> |
||||
); |
||||
}; |
After Width: | Height: | Size: 2.6 KiB |
@ -0,0 +1,18 @@ |
||||
package grafanaschema |
||||
|
||||
Family: { |
||||
lineages: [ |
||||
[ |
||||
{ |
||||
PanelOptions: { |
||||
bucketSize?: int |
||||
bucketOffset: int | *0 |
||||
combine?: bool |
||||
} |
||||
|
||||
// TODO: FieldConfig |
||||
} |
||||
] |
||||
] |
||||
migrations: [] |
||||
} |
@ -0,0 +1,36 @@ |
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
// NOTE: This file will be auto generated from models.cue
|
||||
// It is currenty hand written but will serve as the target for cuetsy
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
import { GraphGradientMode } from '@grafana/ui'; |
||||
|
||||
export const modelVersion = Object.freeze([1, 0]); |
||||
|
||||
export interface PanelOptions { |
||||
bucketSize?: number; |
||||
bucketOffset?: number; |
||||
combine?: boolean; |
||||
} |
||||
|
||||
export const defaultPanelOptions: PanelOptions = { |
||||
bucketOffset: 0, |
||||
}; |
||||
|
||||
/** |
||||
* @alpha |
||||
*/ |
||||
export interface PanelFieldConfig { |
||||
lineWidth?: number; // 0
|
||||
fillOpacity?: number; // 100
|
||||
gradientMode?: GraphGradientMode; |
||||
} |
||||
|
||||
/** |
||||
* @alpha |
||||
*/ |
||||
export const defaultPanelFieldConfig: PanelFieldConfig = { |
||||
lineWidth: 1, |
||||
fillOpacity: 80, |
||||
//gradientMode: GraphGradientMode.None,
|
||||
}; |
@ -0,0 +1,92 @@ |
||||
import { FieldColorModeId, FieldConfigProperty, PanelPlugin } from '@grafana/data'; |
||||
import { HistogramPanel } from './HistogramPanel'; |
||||
import { graphFieldOptions } from '@grafana/ui'; |
||||
import { PanelFieldConfig, PanelOptions, defaultPanelFieldConfig, defaultPanelOptions } from './models.gen'; |
||||
import { originalDataHasHistogram } from './utils'; |
||||
|
||||
import { histogramFieldInfo } from '@grafana/data/src/transformations/transformers/histogram'; |
||||
|
||||
export const plugin = new PanelPlugin<PanelOptions, PanelFieldConfig>(HistogramPanel) |
||||
.setPanelOptions((builder) => { |
||||
builder |
||||
.addCustomEditor({ |
||||
id: '__calc__', |
||||
path: '__calc__', |
||||
name: 'Values', |
||||
description: 'Showing frequencies that are calculated in the query', |
||||
editor: () => null, // empty editor
|
||||
showIf: (opts, data) => originalDataHasHistogram(data), |
||||
}) |
||||
.addNumberInput({ |
||||
path: 'bucketSize', |
||||
name: histogramFieldInfo.bucketSize.name, |
||||
description: histogramFieldInfo.bucketSize.description, |
||||
settings: { |
||||
placeholder: 'Auto', |
||||
}, |
||||
defaultValue: defaultPanelOptions.bucketSize, |
||||
showIf: (opts, data) => !originalDataHasHistogram(data), |
||||
}) |
||||
.addNumberInput({ |
||||
path: 'bucketOffset', |
||||
name: histogramFieldInfo.bucketOffset.name, |
||||
description: histogramFieldInfo.bucketOffset.description, |
||||
settings: { |
||||
placeholder: '0', |
||||
}, |
||||
defaultValue: defaultPanelOptions.bucketOffset, |
||||
showIf: (opts, data) => !originalDataHasHistogram(data), |
||||
}) |
||||
.addBooleanSwitch({ |
||||
path: 'combine', |
||||
name: histogramFieldInfo.combine.name, |
||||
description: histogramFieldInfo.combine.description, |
||||
defaultValue: defaultPanelOptions.combine, |
||||
showIf: (opts, data) => !originalDataHasHistogram(data), |
||||
}); |
||||
}) |
||||
.useFieldConfig({ |
||||
standardOptions: { |
||||
[FieldConfigProperty.Color]: { |
||||
settings: { |
||||
byValueSupport: false, |
||||
}, |
||||
defaultValue: { |
||||
mode: FieldColorModeId.PaletteClassic, |
||||
}, |
||||
}, |
||||
}, |
||||
useCustomConfig: (builder) => { |
||||
const cfg = defaultPanelFieldConfig; |
||||
|
||||
builder |
||||
.addSliderInput({ |
||||
path: 'lineWidth', |
||||
name: 'Line width', |
||||
defaultValue: cfg.lineWidth, |
||||
settings: { |
||||
min: 0, |
||||
max: 10, |
||||
step: 1, |
||||
}, |
||||
}) |
||||
.addSliderInput({ |
||||
path: 'fillOpacity', |
||||
name: 'Fill opacity', |
||||
defaultValue: cfg.fillOpacity, |
||||
settings: { |
||||
min: 0, |
||||
max: 100, |
||||
step: 1, |
||||
}, |
||||
}) |
||||
.addRadio({ |
||||
path: 'gradientMode', |
||||
name: 'Gradient mode', |
||||
defaultValue: graphFieldOptions.fillGradient[0].value, |
||||
settings: { |
||||
options: graphFieldOptions.fillGradient, |
||||
}, |
||||
}); |
||||
}, |
||||
}); |
@ -0,0 +1,18 @@ |
||||
{ |
||||
"type": "panel", |
||||
"name": "Histogram", |
||||
"id": "histogram", |
||||
|
||||
"state": "alpha", |
||||
|
||||
"info": { |
||||
"author": { |
||||
"name": "Grafana Labs", |
||||
"url": "https://grafana.com" |
||||
}, |
||||
"logos": { |
||||
"small": "img/histogram.svg", |
||||
"large": "img/histogram.svg" |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,30 @@ |
||||
import { DataFrame, FieldType } from '@grafana/data'; |
||||
|
||||
import { |
||||
histogramFrameBucketMinFieldName, |
||||
histogramFrameBucketMaxFieldName, |
||||
} from '@grafana/data/src/transformations/transformers/histogram'; |
||||
|
||||
export function originalDataHasHistogram(frames?: DataFrame[]): boolean { |
||||
if (frames?.length !== 1) { |
||||
return false; |
||||
} |
||||
const frame = frames[0]; |
||||
if (frame.fields.length < 3) { |
||||
return false; |
||||
} |
||||
|
||||
if ( |
||||
frame.fields[0].name !== histogramFrameBucketMinFieldName || |
||||
frame.fields[1].name !== histogramFrameBucketMaxFieldName |
||||
) { |
||||
return false; |
||||
} |
||||
for (const field of frame.fields) { |
||||
if (field.type !== FieldType.number) { |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
return true; |
||||
} |
Loading…
Reference in new issue