New Panel: Histogram (#33752)

pull/33877/head
Leon Sorokin 4 years ago committed by GitHub
parent a40946b6aa
commit 5fd7c34420
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/grafana-data/src/transformations/index.ts
  2. 2
      packages/grafana-data/src/transformations/transformers.ts
  3. 172
      packages/grafana-data/src/transformations/transformers/histogram.test.ts
  4. 306
      packages/grafana-data/src/transformations/transformers/histogram.ts
  5. 1
      packages/grafana-data/src/transformations/transformers/ids.ts
  6. 4
      packages/grafana-data/src/transformations/transformers/joinDataFrames.ts
  7. 1
      packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts
  8. 91
      public/app/core/components/TransformersUI/HistogramTransformerEditor.tsx
  9. 2
      public/app/core/utils/standardTransformers.ts
  10. 2
      public/app/features/plugins/built_in_plugins.ts
  11. 257
      public/app/plugins/panel/histogram/Histogram.tsx
  12. 51
      public/app/plugins/panel/histogram/HistogramPanel.tsx
  13. 1
      public/app/plugins/panel/histogram/img/histogram.svg
  14. 18
      public/app/plugins/panel/histogram/models.cue
  15. 36
      public/app/plugins/panel/histogram/models.gen.ts
  16. 92
      public/app/plugins/panel/histogram/module.tsx
  17. 18
      public/app/plugins/panel/histogram/plugin.json
  18. 30
      public/app/plugins/panel/histogram/utils.ts

@ -12,3 +12,4 @@ export {
export { RegexpOrNamesMatcherOptions, ByNamesMatcherOptions, ByNamesMatcherMode } from './matchers/nameMatcher';
export { RenameByRegexTransformerOptions } from './transformers/renameByRegex';
export { outerJoinDataFrames } from './transformers/joinDataFrames';
export * from './transformers/histogram';

@ -17,6 +17,7 @@ import { sortByTransformer } from './transformers/sortBy';
import { mergeTransformer } from './transformers/merge';
import { renameByRegexTransformer } from './transformers/renameByRegex';
import { filterByValueTransformer } from './transformers/filterByValue';
import { histogramTransformer } from './transformers/histogram';
export const standardTransformers = {
noopTransformer,
@ -39,4 +40,5 @@ export const standardTransformers = {
sortByTransformer,
mergeTransformer,
renameByRegexTransformer,
histogramTransformer,
};

@ -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,
};
}

@ -22,4 +22,5 @@ export enum DataTransformerID {
ensureColumns = 'ensureColumns',
groupBy = 'groupBy',
sortBy = 'sortBy',
histogram = 'histogram',
}

@ -216,7 +216,7 @@ export function outerJoinDataFrames(options: JoinOptions): DataFrame | undefined
//--------------------------------------------------------------------------------
// Copied from uplot
type AlignedData = [number[], ...Array<Array<number | null>>];
export type AlignedData = [number[], ...Array<Array<number | null>>];
// nullModes
const NULL_REMOVE = 0; // nulls are converted to undefined (e.g. for spanGaps: true)
@ -245,7 +245,7 @@ function nullExpand(yVals: Array<number | null>, nullIdxs: number[], alignedLen:
}
// nullModes is a tables-matched array indicating how to treat nulls in each series
function join(tables: AlignedData[], nullModes: number[][]) {
export function join(tables: AlignedData[], nullModes?: number[][]) {
const xVals = new Set<number>();
for (let ti = 0; ti < tables.length; ti++) {

@ -16,6 +16,7 @@ export interface AxisProps {
grid?: boolean;
ticks?: boolean;
formatValue?: (v: any) => string;
incrs?: Axis.Incrs;
splits?: Axis.Splits;
values?: any;
isTime?: boolean;

@ -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,
};

@ -13,6 +13,7 @@ import { mergeTransformerRegistryItem } from '../components/TransformersUI/Merge
import { seriesToRowsTransformerRegistryItem } from '../components/TransformersUI/SeriesToRowsTransformerEditor';
import { concatenateTransformRegistryItem } from '../components/TransformersUI/ConcatenateTransformerEditor';
import { renameByRegexTransformRegistryItem } from '../components/TransformersUI/RenameByRegexTransformer';
import { histogramTransformRegistryItem } from '../components/TransformersUI/HistogramTransformerEditor';
export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> => {
return [
@ -30,5 +31,6 @@ export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> =
groupByTransformRegistryItem,
sortByTransformRegistryItem,
mergeTransformerRegistryItem,
histogramTransformRegistryItem,
];
};

@ -64,6 +64,7 @@ import * as livePanel from 'app/plugins/panel/live/module';
import * as debugPanel from 'app/plugins/panel/debug/module';
import * as welcomeBanner from 'app/plugins/panel/welcome/module';
import * as nodeGraph from 'app/plugins/panel/nodeGraph/module';
import * as histogramPanel from 'app/plugins/panel/histogram/module';
const builtInPlugins: any = {
'app/plugins/datasource/graphite/module': graphitePlugin,
@ -111,6 +112,7 @@ const builtInPlugins: any = {
'app/plugins/panel/logs/module': logsPanel,
'app/plugins/panel/welcome/module': welcomeBanner,
'app/plugins/panel/nodeGraph/module': nodeGraph,
'app/plugins/panel/histogram/module': histogramPanel,
};
export default builtInPlugins;

@ -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}
/>
);
};

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 78.69 80.31"><defs><style>.cls-1{fill:url(#linear-gradient);}.cls-2{fill:#3865ab;}</style><linearGradient id="linear-gradient" y1="43.57" x2="78.69" y2="43.57" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Icons"><path class="cls-1" d="M23.16,80.31h-4a1,1,0,0,1-1-1V48.21a1,1,0,0,1,1-1h4a1,1,0,0,1,1,1v31.1A1,1,0,0,1,23.16,80.31Zm-9.07-12h-4a1,1,0,0,0-1,1v10a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1v-10A1,1,0,0,0,14.09,68.32ZM32.23,16.61h-4a1,1,0,0,0-1,1v61.7a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1V17.61A1,1,0,0,0,32.23,16.61ZM41.3,6.82h-4a1,1,0,0,0-1,1V79.31a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1V7.82A1,1,0,0,0,41.3,6.82Zm9.07,9.79h-4a1,1,0,0,0-1,1v61.7a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1V17.61A1,1,0,0,0,50.37,16.61Zm9.07,30.6h-4a1,1,0,0,0-1,1v31.1a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1V48.21A1,1,0,0,0,59.44,47.21Zm9.07,21.11h-4a1,1,0,0,0-1,1v10a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1v-10A1,1,0,0,0,68.51,68.32Zm9.18,4.81h-4a1,1,0,0,0-1,1v5.18a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1V74.13A1,1,0,0,0,77.69,73.13ZM5,73.13H1a1,1,0,0,0-1,1v5.18a1,1,0,0,0,1,1H5a1,1,0,0,0,1-1V74.13A1,1,0,0,0,5,73.13Z"/><path class="cls-2" d="M0,70.31v-4a6.87,6.87,0,0,0,2.29-.37L3.6,69.72A11,11,0,0,1,0,70.31Z"/><path class="cls-2" d="M8.54,66.35l-3-2.69A19.14,19.14,0,0,0,8.5,59.07l3.61,1.72A22.79,22.79,0,0,1,8.54,66.35Zm61.55-.07a23.07,23.07,0,0,1-3.55-5.56L70.16,59a19.22,19.22,0,0,0,2.9,4.6Zm-56-10.39L10.28,54.6c.57-1.66,1.12-3.51,1.69-5.67l3.87,1C15.24,52.2,14.67,54.15,14.07,55.89Zm50.52-.07c-.59-1.74-1.17-3.69-1.76-5.94l3.87-1c.57,2.16,1.12,4,1.68,5.67ZM17.06,45l-3.89-.91c.45-1.93.88-3.9,1.32-5.89l3.91.87C18,41.07,17.51,43.06,17.06,45Zm44.55-.08C61.16,43,60.72,41,60.27,39l3.91-.87c.44,2,.88,4,1.32,5.89ZM19.5,34.15l-3.9-.89c.52-2.3,1-4.19,1.42-5.94l3.88,1C20.46,30,20,31.88,19.5,34.15Zm39.67-.08c-.53-2.32-1-4.17-1.4-5.84l3.88-1c.43,1.7.89,3.59,1.42,5.94Zm-37-10.55-3.83-1.14c.65-2.19,1.3-4.11,2-5.89l3.73,1.44C23.45,19.61,22.83,21.44,22.21,23.52Zm34.24-.09c-.62-2.07-1.23-3.89-1.89-5.58l3.73-1.45c.69,1.79,1.35,3.72,2,5.89ZM26.06,13.52,22.5,11.69A30.32,30.32,0,0,1,26,6.26L29.1,8.82A26.32,26.32,0,0,0,26.06,13.52Zm26.53-.07a26.42,26.42,0,0,0-3.05-4.69L52.6,6.19a29.61,29.61,0,0,1,3.54,5.41ZM32.36,5.83,30.15,2.5A15.82,15.82,0,0,1,36.49,0l.63,4A11.6,11.6,0,0,0,32.36,5.83Zm13.9,0A11.66,11.66,0,0,0,41.5,4L42.1,0a15.84,15.84,0,0,1,6.36,2.44Z"/><path class="cls-2" d="M78.69,70.31a11,11,0,0,1-3.6-.59l1.3-3.78a7.25,7.25,0,0,0,2.3.37Z"/></g></g></svg>

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…
Cancel
Save