mirror of
https://github.com/grafana/grafana.git
synced 2024-11-23 01:16:31 -06:00
HeatmapNG: cell value filtering and color clamping (#50204)
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
parent
fac8db8ff6
commit
8cdfef4796
@ -23,7 +23,7 @@
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"graphTooltip": 1,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
@ -172,7 +172,7 @@
|
||||
"color": "rgba(255,0,255,0.7)"
|
||||
},
|
||||
"filterValues": {
|
||||
"min": 1e-9
|
||||
"le": 1e-9
|
||||
},
|
||||
"legend": {
|
||||
"show": true
|
||||
@ -257,7 +257,7 @@
|
||||
"color": "rgba(255,0,255,0.7)"
|
||||
},
|
||||
"filterValues": {
|
||||
"min": 1e-9
|
||||
"le": 1e-9
|
||||
},
|
||||
"legend": {
|
||||
"show": true
|
||||
@ -333,7 +333,7 @@
|
||||
"color": "rgba(255,0,255,0.7)"
|
||||
},
|
||||
"filterValues": {
|
||||
"min": 1e-9
|
||||
"le": 1e-9
|
||||
},
|
||||
"legend": {
|
||||
"show": true
|
||||
@ -417,7 +417,7 @@
|
||||
"color": "rgba(255,0,255,0.7)"
|
||||
},
|
||||
"filterValues": {
|
||||
"min": 1e-9
|
||||
"le": 1e-9
|
||||
},
|
||||
"legend": {
|
||||
"show": true
|
||||
@ -502,7 +502,7 @@
|
||||
"color": "rgba(255,0,255,0.7)"
|
||||
},
|
||||
"filterValues": {
|
||||
"min": 1e-9
|
||||
"le": 1e-9
|
||||
},
|
||||
"legend": {
|
||||
"show": true
|
||||
@ -548,6 +548,6 @@
|
||||
"timezone": "",
|
||||
"title": "Heatmap calculate (log)",
|
||||
"uid": "ZXYQTA97ZZ",
|
||||
"version": 4,
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ const GRADIENT_STOPS = 10;
|
||||
export const ColorScale = ({ colorPalette, min, max, display, hoverValue, useStopsPercentage }: Props) => {
|
||||
const [colors, setColors] = useState<string[]>([]);
|
||||
const [scaleHover, setScaleHover] = useState<HoverState>({ isShown: false, value: 0 });
|
||||
const [percent, setPercent] = useState<number | null>(null);
|
||||
const [percent, setPercent] = useState<number | null>(null); // 0-100 for CSS percentage
|
||||
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme, colors);
|
||||
@ -50,15 +50,12 @@ export const ColorScale = ({ colorPalette, min, max, display, hoverValue, useSto
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hoverValue != null) {
|
||||
const percent = hoverValue / (max - min);
|
||||
setPercent(percent * 100);
|
||||
}
|
||||
setPercent(hoverValue == null ? null : clampPercent100((hoverValue - min) / (max - min)));
|
||||
}, [hoverValue, min, max]);
|
||||
|
||||
return (
|
||||
<div className={styles.scaleWrapper}>
|
||||
<div className={styles.scaleGradient} onMouseMove={onScaleMouseMove} onMouseLeave={onScaleMouseLeave}>
|
||||
<div className={styles.scaleWrapper} onMouseMove={onScaleMouseMove} onMouseLeave={onScaleMouseLeave}>
|
||||
<div className={styles.scaleGradient}>
|
||||
{display && (scaleHover.isShown || hoverValue !== undefined) && (
|
||||
<div className={styles.followerContainer}>
|
||||
<div className={styles.follower} style={{ left: `${percent}%` }} />
|
||||
@ -121,10 +118,19 @@ const getGradientStops = ({
|
||||
return [...gradientStops];
|
||||
};
|
||||
|
||||
function clampPercent100(v: number) {
|
||||
if (v > 1) {
|
||||
return 100;
|
||||
}
|
||||
if (v < 0) {
|
||||
return 0;
|
||||
}
|
||||
return v * 100;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, colors: string[]) => ({
|
||||
scaleWrapper: css`
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
font-size: 11px;
|
||||
opacity: 1;
|
||||
`,
|
||||
@ -138,7 +144,7 @@ const getStyles = (theme: GrafanaTheme2, colors: string[]) => ({
|
||||
`,
|
||||
hoverValue: css`
|
||||
position: absolute;
|
||||
padding-top: 5px;
|
||||
padding-top: 4px;
|
||||
`,
|
||||
followerContainer: css`
|
||||
position: relative;
|
||||
|
@ -24,11 +24,6 @@ const logModeOptions: Array<SelectableValue<HeatmapCalculationMode>> = [
|
||||
value: HeatmapCalculationMode.Size,
|
||||
description: 'Split the buckets based on size',
|
||||
},
|
||||
{
|
||||
label: 'Count',
|
||||
value: HeatmapCalculationMode.Count,
|
||||
description: 'Split the buckets based on count',
|
||||
},
|
||||
];
|
||||
|
||||
export const AxisEditor: React.FC<StandardEditorProps<HeatmapCalculationBucketConfig, any>> = ({
|
||||
|
@ -58,7 +58,7 @@ describe('Heatmap transformer', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const heatmap = bucketsToScanlines({ frame, name: 'Speed' });
|
||||
const heatmap = bucketsToScanlines({ frame, value: 'Speed' });
|
||||
expect(heatmap.fields.map((f) => ({ name: f.name, type: f.type, config: f.config }))).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
|
@ -63,7 +63,7 @@ export function readHeatmapScanlinesCustomMeta(frame?: DataFrame): HeatmapScanli
|
||||
|
||||
export interface BucketsOptions {
|
||||
frame: DataFrame;
|
||||
name?: string;
|
||||
value?: string; // the field value name
|
||||
layout?: HeatmapBucketLayout;
|
||||
}
|
||||
|
||||
@ -147,7 +147,7 @@ export function bucketsToScanlines(opts: BucketsOptions): DataFrame {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: opts.name?.length ? opts.name : 'Value',
|
||||
name: opts.value?.length ? opts.value : 'Value',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector(counts2),
|
||||
config: yFields[0].config,
|
||||
|
@ -57,7 +57,7 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
|
||||
let exemplarsyFacet: number[] = [];
|
||||
|
||||
const meta = readHeatmapScanlinesCustomMeta(info.heatmap);
|
||||
if (info.exemplars && meta.yMatchWithLabel) {
|
||||
if (info.exemplars?.length && meta.yMatchWithLabel) {
|
||||
exemplarsXFacet = info.exemplars?.fields[0].values.toArray();
|
||||
|
||||
// ordinal/labeled heatmap-buckets?
|
||||
@ -126,7 +126,10 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
|
||||
getTimeRange: () => timeRangeRef.current,
|
||||
palette,
|
||||
cellGap: options.cellGap,
|
||||
hideThreshold: options.filterValues?.min, // eventually a better range
|
||||
hideLE: options.filterValues?.le,
|
||||
hideGE: options.filterValues?.ge,
|
||||
valueMin: options.color.min,
|
||||
valueMax: options.color.max,
|
||||
exemplarColor: options.exemplars?.color ?? 'rgba(255,0,255,0.7)',
|
||||
yAxisConfig: options.yAxis,
|
||||
ySizeDivisor: scaleConfig?.type === ScaleDistribution.Log ? +(options.calculation?.yBuckets?.value || 1) : 1,
|
||||
@ -143,7 +146,17 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
|
||||
let countFieldIdx = heatmapType === DataFrameType.HeatmapScanlines ? 2 : 3;
|
||||
const countField = info.heatmap.fields[countFieldIdx];
|
||||
|
||||
const { min, max } = reduceField({ field: countField, reducers: [ReducerID.min, ReducerID.max] });
|
||||
// TODO -- better would be to get the range from the real color scale!
|
||||
let { min, max } = options.color;
|
||||
if (min == null || max == null) {
|
||||
const calc = reduceField({ field: countField, reducers: [ReducerID.min, ReducerID.max] });
|
||||
if (min == null) {
|
||||
min = calc[ReducerID.min];
|
||||
}
|
||||
if (max == null) {
|
||||
max = calc[ReducerID.max];
|
||||
}
|
||||
}
|
||||
|
||||
let hoverValue: number | undefined = undefined;
|
||||
// seriesIdx: 1 is heatmap layer; 2 is exemplar layer
|
||||
@ -154,7 +167,7 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
|
||||
return (
|
||||
<VizLayout.Legend placement="bottom" maxHeight="20%">
|
||||
<div className={styles.colorScaleWrapper}>
|
||||
<ColorScale hoverValue={hoverValue} colorPalette={palette} min={min} max={max} display={info.display} />
|
||||
<ColorScale hoverValue={hoverValue} colorPalette={palette} min={min!} max={max!} display={info.display} />
|
||||
</div>
|
||||
</VizLayout.Legend>
|
||||
);
|
||||
@ -209,5 +222,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
colorScaleWrapper: css`
|
||||
margin-left: 25px;
|
||||
padding: 10px 0;
|
||||
max-width: 300px;
|
||||
`,
|
||||
});
|
||||
|
@ -75,7 +75,7 @@ export function prepareHeatmapData(data: PanelData, options: PanelOptions, theme
|
||||
}
|
||||
}
|
||||
|
||||
return getHeatmapData(bucketsToScanlines({ ...options.bucket, frame: bucketHeatmap }), exemplars, theme);
|
||||
return getHeatmapData(bucketsToScanlines({ ...options.bucketFrame, frame: bucketHeatmap }), exemplars, theme);
|
||||
}
|
||||
|
||||
const getSparseHeatmapData = (
|
||||
@ -139,7 +139,7 @@ const getHeatmapData = (frame: DataFrame, exemplars: DataFrame | undefined, them
|
||||
|
||||
const data: HeatmapData = {
|
||||
heatmap: frame,
|
||||
exemplars,
|
||||
exemplars: exemplars?.length ? exemplars : undefined,
|
||||
xBucketSize: xBinIncr,
|
||||
yBucketSize: yBinIncr,
|
||||
xBucketCount: xBinQty,
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
@ -21,11 +21,14 @@ describe('Heatmap Migrations', () => {
|
||||
expect(panel).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"fieldConfig": Object {
|
||||
"defaults": Object {},
|
||||
"defaults": Object {
|
||||
"decimals": 6,
|
||||
"unit": "short",
|
||||
},
|
||||
"overrides": Array [],
|
||||
},
|
||||
"options": Object {
|
||||
"bucket": Object {
|
||||
"bucketFrame": Object {
|
||||
"layout": "auto",
|
||||
},
|
||||
"calculate": true,
|
||||
@ -44,7 +47,7 @@ describe('Heatmap Migrations', () => {
|
||||
},
|
||||
},
|
||||
"cellGap": 2,
|
||||
"cellSize": 10,
|
||||
"cellRadius": 10,
|
||||
"color": Object {
|
||||
"exponent": 0.5,
|
||||
"fill": "dark-orange",
|
||||
@ -59,7 +62,7 @@ describe('Heatmap Migrations', () => {
|
||||
"color": "rgba(255,0,255,0.7)",
|
||||
},
|
||||
"filterValues": Object {
|
||||
"min": 1e-9,
|
||||
"le": 1e-9,
|
||||
},
|
||||
"legend": Object {
|
||||
"show": true,
|
||||
@ -72,6 +75,8 @@ describe('Heatmap Migrations', () => {
|
||||
"yAxis": Object {
|
||||
"axisPlacement": "left",
|
||||
"axisWidth": 400,
|
||||
"max": 22,
|
||||
"min": 7,
|
||||
"reverse": false,
|
||||
},
|
||||
},
|
||||
@ -133,11 +138,11 @@ const oldHeatmap = {
|
||||
yAxis: {
|
||||
show: true,
|
||||
format: 'short',
|
||||
decimals: null,
|
||||
decimals: 6,
|
||||
logBase: 2,
|
||||
splitFactor: 3,
|
||||
min: null,
|
||||
max: null,
|
||||
min: 7,
|
||||
max: 22,
|
||||
width: '400',
|
||||
},
|
||||
xBucketSize: null,
|
||||
|
@ -60,6 +60,9 @@ export function angularToReactHeatmap(angular: any): { fieldConfig: FieldConfigS
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fieldConfig.defaults.unit = oldYAxis.format;
|
||||
fieldConfig.defaults.decimals = oldYAxis.decimals;
|
||||
}
|
||||
|
||||
const options: PanelOptions = {
|
||||
@ -69,14 +72,16 @@ export function angularToReactHeatmap(angular: any): { fieldConfig: FieldConfigS
|
||||
...defaultPanelOptions.color,
|
||||
steps: 128, // best match with existing colors
|
||||
},
|
||||
cellGap: asNumber(angular.cards?.cardPadding),
|
||||
cellSize: asNumber(angular.cards?.cardRound),
|
||||
cellGap: asNumber(angular.cards?.cardPadding, 2),
|
||||
cellRadius: asNumber(angular.cards?.cardRound), // just to keep it
|
||||
yAxis: {
|
||||
axisPlacement: oldYAxis.show === false ? AxisPlacement.Hidden : AxisPlacement.Left,
|
||||
reverse: Boolean(angular.reverseYBuckets),
|
||||
axisWidth: oldYAxis.width ? +oldYAxis.width : undefined,
|
||||
min: oldYAxis.min,
|
||||
max: oldYAxis.max,
|
||||
},
|
||||
bucket: {
|
||||
bucketFrame: {
|
||||
layout: getHeatmapBucketLayout(angular.yBucketBound),
|
||||
},
|
||||
legend: {
|
||||
@ -134,9 +139,12 @@ function getHeatmapBucketLayout(v?: string): HeatmapBucketLayout {
|
||||
return HeatmapBucketLayout.auto;
|
||||
}
|
||||
|
||||
function asNumber(v: any): number | undefined {
|
||||
function asNumber(v: any, defaultValue?: number): number | undefined {
|
||||
if (v == null || v === '') {
|
||||
return defaultValue;
|
||||
}
|
||||
const num = +v;
|
||||
return isNaN(num) ? undefined : num;
|
||||
return isNaN(num) ? defaultValue : num;
|
||||
}
|
||||
|
||||
export const heatmapMigrationHandler = (panel: PanelModel): Partial<PanelOptions> => {
|
||||
|
@ -34,11 +34,14 @@ export interface YAxisConfig extends AxisConfig {
|
||||
unit?: string;
|
||||
reverse?: boolean;
|
||||
decimals?: number;
|
||||
// Only used when the axis is not ordinal
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface FilterValueRange {
|
||||
min?: number;
|
||||
max?: number;
|
||||
le?: number;
|
||||
ge?: number;
|
||||
}
|
||||
|
||||
export interface HeatmapTooltip {
|
||||
@ -53,8 +56,8 @@ export interface ExemplarConfig {
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface BucketOptions {
|
||||
name?: string;
|
||||
export interface BucketFrameOptions {
|
||||
value?: string; // value field name
|
||||
layout?: HeatmapBucketLayout;
|
||||
}
|
||||
|
||||
@ -64,11 +67,11 @@ export interface PanelOptions {
|
||||
|
||||
color: HeatmapColorOptions;
|
||||
filterValues?: FilterValueRange; // was hideZeroBuckets
|
||||
bucket?: BucketOptions;
|
||||
bucketFrame?: BucketFrameOptions;
|
||||
showValue: VisibilityMode;
|
||||
|
||||
cellGap?: number; // was cardPadding
|
||||
cellSize?: number; // was cardRadius
|
||||
cellRadius?: number; // was cardRadius (not used, but migrated from angular)
|
||||
|
||||
yAxis: YAxisConfig;
|
||||
legend: HeatmapLegend;
|
||||
@ -87,7 +90,7 @@ export const defaultPanelOptions: PanelOptions = {
|
||||
exponent: 0.5,
|
||||
steps: 64,
|
||||
},
|
||||
bucket: {
|
||||
bucketFrame: {
|
||||
layout: HeatmapBucketLayout.auto,
|
||||
},
|
||||
yAxis: {
|
||||
@ -105,7 +108,7 @@ export const defaultPanelOptions: PanelOptions = {
|
||||
color: 'rgba(255,0,255,0.7)',
|
||||
},
|
||||
filterValues: {
|
||||
min: 1e-9,
|
||||
le: 1e-9,
|
||||
},
|
||||
cellGap: 1,
|
||||
};
|
||||
|
@ -63,33 +63,10 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
|
||||
|
||||
if (opts.calculate) {
|
||||
addHeatmapCalculationOptions('calculation.', builder, opts.calculation, category);
|
||||
} else {
|
||||
builder.addTextInput({
|
||||
path: 'bucket.name',
|
||||
name: 'Cell value name',
|
||||
defaultValue: defaultPanelOptions.bucket?.name,
|
||||
settings: {
|
||||
placeholder: 'Value',
|
||||
},
|
||||
category,
|
||||
});
|
||||
builder.addRadio({
|
||||
path: 'bucket.layout',
|
||||
name: 'Layout',
|
||||
defaultValue: defaultPanelOptions.bucket?.layout ?? HeatmapBucketLayout.auto,
|
||||
category,
|
||||
settings: {
|
||||
options: [
|
||||
{ label: 'Auto', value: HeatmapBucketLayout.auto },
|
||||
{ label: 'Middle', value: HeatmapBucketLayout.unknown },
|
||||
{ label: 'Lower (LE)', value: HeatmapBucketLayout.le },
|
||||
{ label: 'Upper (GE)', value: HeatmapBucketLayout.ge },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
category = ['Y Axis'];
|
||||
|
||||
builder.addRadio({
|
||||
path: 'yAxis.axisPlacement',
|
||||
name: 'Placement',
|
||||
@ -104,6 +81,27 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: support clamping the min/max range when there is a real axis
|
||||
if (false && opts.calculate) {
|
||||
builder
|
||||
.addNumberInput({
|
||||
path: 'yAxis.min',
|
||||
name: 'Min value',
|
||||
settings: {
|
||||
placeholder: 'Auto',
|
||||
},
|
||||
category,
|
||||
})
|
||||
.addTextInput({
|
||||
path: 'yAxis.max',
|
||||
name: 'Max value',
|
||||
settings: {
|
||||
placeholder: 'Auto',
|
||||
},
|
||||
category,
|
||||
});
|
||||
}
|
||||
|
||||
builder
|
||||
.addNumberInput({
|
||||
path: 'yAxis.axisWidth',
|
||||
@ -123,14 +121,31 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
|
||||
placeholder: 'Auto',
|
||||
},
|
||||
category,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'yAxis.reverse',
|
||||
name: 'Reverse',
|
||||
defaultValue: defaultPanelOptions.yAxis.reverse === true,
|
||||
category,
|
||||
});
|
||||
|
||||
if (!opts.calculate) {
|
||||
builder.addRadio({
|
||||
path: 'bucketFrame.layout',
|
||||
name: 'Tick alignment',
|
||||
defaultValue: defaultPanelOptions.bucketFrame?.layout ?? HeatmapBucketLayout.auto,
|
||||
category,
|
||||
settings: {
|
||||
options: [
|
||||
{ label: 'Auto', value: HeatmapBucketLayout.auto },
|
||||
{ label: 'Top (LE)', value: HeatmapBucketLayout.le },
|
||||
{ label: 'Middle', value: HeatmapBucketLayout.unknown },
|
||||
{ label: 'Bottom (GE)', value: HeatmapBucketLayout.ge },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
builder.addBooleanSwitch({
|
||||
path: 'yAxis.reverse',
|
||||
name: 'Reverse',
|
||||
defaultValue: defaultPanelOptions.yAxis.reverse === true,
|
||||
category,
|
||||
});
|
||||
|
||||
category = ['Colors'];
|
||||
|
||||
builder.addRadio({
|
||||
@ -225,6 +240,26 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
|
||||
},
|
||||
});
|
||||
|
||||
builder
|
||||
.addNumberInput({
|
||||
path: 'color.min',
|
||||
name: 'Start color scale from value',
|
||||
defaultValue: defaultPanelOptions.color.min,
|
||||
settings: {
|
||||
placeholder: 'Auto (min)',
|
||||
},
|
||||
category,
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'color.max',
|
||||
name: 'End color scale at value',
|
||||
defaultValue: defaultPanelOptions.color.max,
|
||||
settings: {
|
||||
placeholder: 'Auto (max)',
|
||||
},
|
||||
category,
|
||||
});
|
||||
|
||||
category = ['Display'];
|
||||
|
||||
builder
|
||||
@ -241,12 +276,6 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
|
||||
// ],
|
||||
// },
|
||||
// })
|
||||
.addNumberInput({
|
||||
path: 'filterValues.min',
|
||||
name: 'Hide cell counts <=',
|
||||
defaultValue: defaultPanelOptions.filterValues?.min,
|
||||
category,
|
||||
})
|
||||
.addSliderInput({
|
||||
name: 'Cell gap',
|
||||
path: 'cellGap',
|
||||
@ -256,6 +285,24 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
|
||||
min: 0,
|
||||
max: 25,
|
||||
},
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'filterValues.le',
|
||||
name: 'Hide cells with values <=',
|
||||
defaultValue: defaultPanelOptions.filterValues?.le,
|
||||
settings: {
|
||||
placeholder: 'None',
|
||||
},
|
||||
category,
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'filterValues.ge',
|
||||
name: 'Hide cells with values >=',
|
||||
defaultValue: defaultPanelOptions.filterValues?.ge,
|
||||
settings: {
|
||||
placeholder: 'None',
|
||||
},
|
||||
category,
|
||||
});
|
||||
// .addSliderInput({
|
||||
// name: 'Cell radius',
|
||||
@ -277,6 +324,18 @@ export const plugin = new PanelPlugin<PanelOptions, GraphFieldConfig>(HeatmapPan
|
||||
category,
|
||||
});
|
||||
|
||||
if (!opts.calculate) {
|
||||
builder.addTextInput({
|
||||
path: 'bucketFrame.value',
|
||||
name: 'Cell value name',
|
||||
defaultValue: defaultPanelOptions.bucketFrame?.value,
|
||||
settings: {
|
||||
placeholder: 'Value',
|
||||
},
|
||||
category,
|
||||
});
|
||||
}
|
||||
|
||||
builder.addBooleanSwitch({
|
||||
path: 'tooltip.yHistogram',
|
||||
name: 'Show histogram (Y axis)',
|
||||
|
@ -5,14 +5,14 @@
|
||||
"state": "alpha",
|
||||
|
||||
"info": {
|
||||
"description": "Next generation heatmap visualization",
|
||||
"description": "Like a histogram over time",
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://grafana.com"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/heatmap.svg",
|
||||
"large": "img/heatmap.svg"
|
||||
"small": "img/icn-heatmap-panel.svg",
|
||||
"large": "img/icn-heatmap-panel.svg"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,15 @@
|
||||
import { MutableRefObject, RefObject } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { DataFrameType, GrafanaTheme2, incrRoundDn, incrRoundUp, TimeRange } from '@grafana/data';
|
||||
import {
|
||||
DataFrameType,
|
||||
formattedValueToString,
|
||||
getValueFormat,
|
||||
GrafanaTheme2,
|
||||
incrRoundDn,
|
||||
incrRoundUp,
|
||||
TimeRange,
|
||||
} from '@grafana/data';
|
||||
import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '@grafana/schema';
|
||||
import { UPlotConfigBuilder } from '@grafana/ui';
|
||||
import { readHeatmapScanlinesCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
|
||||
@ -15,7 +23,8 @@ import { PanelFieldConfig, YAxisConfig } from './models.gen';
|
||||
interface PathbuilderOpts {
|
||||
each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void;
|
||||
gap?: number | null;
|
||||
hideThreshold?: number;
|
||||
hideLE?: number;
|
||||
hideGE?: number;
|
||||
xAlign?: -1 | 0 | 1;
|
||||
yAlign?: -1 | 0 | 1;
|
||||
ySizeDivisor?: number;
|
||||
@ -55,7 +64,10 @@ interface PrepConfigOpts {
|
||||
palette: string[];
|
||||
exemplarColor: string;
|
||||
cellGap?: number | null; // in css pixels
|
||||
hideThreshold?: number;
|
||||
hideLE?: number;
|
||||
hideGE?: number;
|
||||
valueMin?: number;
|
||||
valueMax?: number;
|
||||
yAxisConfig: YAxisConfig;
|
||||
ySizeDivisor?: number;
|
||||
}
|
||||
@ -72,7 +84,10 @@ export function prepConfig(opts: PrepConfigOpts) {
|
||||
getTimeRange,
|
||||
palette,
|
||||
cellGap,
|
||||
hideThreshold,
|
||||
hideLE,
|
||||
hideGE,
|
||||
valueMin,
|
||||
valueMax,
|
||||
yAxisConfig,
|
||||
ySizeDivisor,
|
||||
} = opts;
|
||||
@ -289,12 +304,12 @@ export function prepConfig(opts: PrepConfigOpts) {
|
||||
// how to expand scale range if inferred non-regular or log buckets?
|
||||
}
|
||||
}
|
||||
|
||||
return [dataMin, dataMax];
|
||||
},
|
||||
});
|
||||
|
||||
const hasLabeledY = readHeatmapScanlinesCustomMeta(dataRef.current?.heatmap).yOrdinalDisplay != null;
|
||||
const isOrdianalY = readHeatmapScanlinesCustomMeta(dataRef.current?.heatmap).yOrdinalDisplay != null;
|
||||
const disp = dataRef.current?.heatmap?.fields[1].display ?? getValueFormat('short');
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
@ -303,35 +318,51 @@ export function prepConfig(opts: PrepConfigOpts) {
|
||||
size: yAxisConfig.axisWidth || null,
|
||||
label: yAxisConfig.axisLabel,
|
||||
theme: theme,
|
||||
splits: hasLabeledY
|
||||
? () => {
|
||||
const ys = dataRef.current?.heatmap?.fields[1].values.toArray()!;
|
||||
const splits = ys.slice(0, ys.length - ys.lastIndexOf(ys[0]));
|
||||
formatValue: (v: any) => formattedValueToString(disp(v)),
|
||||
splits: isOrdianalY
|
||||
? (self: uPlot) => {
|
||||
const meta = readHeatmapScanlinesCustomMeta(dataRef.current?.heatmap);
|
||||
if (!meta.yOrdinalDisplay) {
|
||||
return [0, 1]; //?
|
||||
}
|
||||
let splits = meta.yOrdinalDisplay.map((v, idx) => idx);
|
||||
|
||||
const bucketSize = dataRef.current?.yBucketSize!;
|
||||
|
||||
if (dataRef.current?.yLayout === HeatmapBucketLayout.le) {
|
||||
splits.unshift(ys[0] - bucketSize);
|
||||
} else {
|
||||
splits.push(ys[ys.length - 1] + bucketSize);
|
||||
switch (dataRef.current?.yLayout) {
|
||||
case HeatmapBucketLayout.le:
|
||||
splits.unshift(-1);
|
||||
break;
|
||||
case HeatmapBucketLayout.ge:
|
||||
splits.push(splits.length);
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip labels when the height is too small
|
||||
if (self.height < 60) {
|
||||
splits = [splits[0], splits[splits.length - 1]];
|
||||
} else {
|
||||
while (splits.length > 3 && (self.height - 15) / splits.length < 10) {
|
||||
splits = splits.filter((v, idx) => idx % 2 === 0); // remove half the items
|
||||
}
|
||||
}
|
||||
return splits;
|
||||
}
|
||||
: undefined,
|
||||
values: hasLabeledY
|
||||
? () => {
|
||||
values: isOrdianalY
|
||||
? (self: uPlot, splits) => {
|
||||
const meta = readHeatmapScanlinesCustomMeta(dataRef.current?.heatmap);
|
||||
const yAxisValues = meta.yOrdinalDisplay?.slice()!;
|
||||
const isFromBuckets = meta.yOrdinalDisplay?.length && !('le' === meta.yMatchWithLabel);
|
||||
|
||||
if (dataRef.current?.yLayout === HeatmapBucketLayout.le) {
|
||||
yAxisValues.unshift(isFromBuckets ? '' : '0.0'); // assumes dense layout where lowest bucket's low bound is 0-ish
|
||||
} else if (dataRef.current?.yLayout === HeatmapBucketLayout.ge) {
|
||||
yAxisValues.push(isFromBuckets ? '' : '+Inf');
|
||||
if (meta.yOrdinalDisplay) {
|
||||
return splits.map((v) => {
|
||||
const txt = meta.yOrdinalDisplay[v];
|
||||
if (!txt && v < 0) {
|
||||
// Check prometheus style labels
|
||||
if ('le' === meta.yMatchWithLabel) {
|
||||
return '0.0';
|
||||
}
|
||||
}
|
||||
return txt;
|
||||
});
|
||||
}
|
||||
|
||||
return yAxisValues;
|
||||
return splits.map((v) => `${v}`);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
@ -363,7 +394,8 @@ export function prepConfig(opts: PrepConfigOpts) {
|
||||
});
|
||||
},
|
||||
gap: cellGap,
|
||||
hideThreshold,
|
||||
hideLE,
|
||||
hideGE,
|
||||
xAlign:
|
||||
dataRef.current?.xLayout === HeatmapBucketLayout.le
|
||||
? -1
|
||||
@ -380,7 +412,7 @@ export function prepConfig(opts: PrepConfigOpts) {
|
||||
fill: {
|
||||
values: (u, seriesIdx) => {
|
||||
let countFacetIdx = heatmapType === DataFrameType.HeatmapScanlines ? 2 : 3;
|
||||
return countsToFills(u.data[seriesIdx][countFacetIdx] as unknown as number[], palette);
|
||||
return valuesToFills(u.data[seriesIdx][countFacetIdx] as unknown as number[], palette, valueMin, valueMax);
|
||||
},
|
||||
index: palette,
|
||||
},
|
||||
@ -465,7 +497,7 @@ export function prepConfig(opts: PrepConfigOpts) {
|
||||
const CRISP_EDGES_GAP_MIN = 4;
|
||||
|
||||
export function heatmapPathsDense(opts: PathbuilderOpts) {
|
||||
const { disp, each, gap = 1, hideThreshold = 0, xAlign = 1, yAlign = 1, ySizeDivisor = 1 } = opts;
|
||||
const { disp, each, gap = 1, hideLE = -Infinity, hideGE = Infinity, xAlign = 1, yAlign = 1, ySizeDivisor = 1 } = opts;
|
||||
|
||||
const pxRatio = devicePixelRatio;
|
||||
|
||||
@ -549,14 +581,7 @@ export function heatmapPathsDense(opts: PathbuilderOpts) {
|
||||
);
|
||||
|
||||
for (let i = 0; i < dlen; i++) {
|
||||
// filter out 0 counts and out of view
|
||||
if (
|
||||
counts[i] > hideThreshold &&
|
||||
xs[i] + xBinIncr >= scaleX.min! &&
|
||||
xs[i] - xBinIncr <= scaleX.max! &&
|
||||
ys[i] + yBinIncr >= scaleY.min! &&
|
||||
ys[i] - yBinIncr <= scaleY.max!
|
||||
) {
|
||||
if (counts[i] > hideLE && counts[i] < hideGE) {
|
||||
let cx = cxs[~~(i / yBinQty)];
|
||||
let cy = cys[i % yBinQty];
|
||||
|
||||
@ -646,7 +671,7 @@ export function heatmapPathsPoints(opts: PointsBuilderOpts, exemplarColor: strin
|
||||
// accepts xMax, yMin, yMax, count
|
||||
// xbinsize? x tile sizes are uniform?
|
||||
export function heatmapPathsSparse(opts: PathbuilderOpts) {
|
||||
const { disp, each, gap = 1, hideThreshold = 0 } = opts;
|
||||
const { disp, each, gap = 1, hideLE = -Infinity, hideGE = Infinity } = opts;
|
||||
|
||||
const pxRatio = devicePixelRatio;
|
||||
|
||||
@ -717,7 +742,7 @@ export function heatmapPathsSparse(opts: PathbuilderOpts) {
|
||||
let xSizeUniform = xOffs.get(xMaxs.find((v) => v !== xMaxs[0])) - xOffs.get(xMaxs[0]);
|
||||
|
||||
for (let i = 0; i < dlen; i++) {
|
||||
if (counts[i] <= hideThreshold) {
|
||||
if (counts[i] <= hideLE || counts[i] >= hideGE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -739,19 +764,11 @@ export function heatmapPathsSparse(opts: PathbuilderOpts) {
|
||||
let x = xMaxPx;
|
||||
let y = yMinPx;
|
||||
|
||||
// filter out 0 counts and out of view
|
||||
// if (
|
||||
// xs[i] + xBinIncr >= scaleX.min! &&
|
||||
// xs[i] - xBinIncr <= scaleX.max! &&
|
||||
// ys[i] + yBinIncr >= scaleY.min! &&
|
||||
// ys[i] - yBinIncr <= scaleY.max!
|
||||
// ) {
|
||||
let fillPath = fillPaths[fills[i]];
|
||||
|
||||
rect(fillPath, x, y, xSize, ySize);
|
||||
|
||||
each(u, 1, i, x, y, xSize, ySize);
|
||||
// }
|
||||
}
|
||||
|
||||
u.ctx.save();
|
||||
@ -772,29 +789,36 @@ export function heatmapPathsSparse(opts: PathbuilderOpts) {
|
||||
};
|
||||
}
|
||||
|
||||
export const countsToFills = (counts: number[], palette: string[]) => {
|
||||
// TODO: integrate 1e-9 hideThreshold?
|
||||
const hideThreshold = 0;
|
||||
export const valuesToFills = (values: number[], palette: string[], minValue?: number, maxValue?: number) => {
|
||||
if (minValue == null) {
|
||||
minValue = Infinity;
|
||||
|
||||
let minCount = Infinity;
|
||||
let maxCount = -Infinity;
|
||||
|
||||
for (let i = 0; i < counts.length; i++) {
|
||||
if (counts[i] > hideThreshold) {
|
||||
minCount = Math.min(minCount, counts[i]);
|
||||
maxCount = Math.max(maxCount, counts[i]);
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
minValue = Math.min(minValue, values[i]);
|
||||
}
|
||||
}
|
||||
|
||||
let range = maxCount - minCount;
|
||||
if (maxValue == null) {
|
||||
maxValue = -Infinity;
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
maxValue = Math.max(maxValue, values[i]);
|
||||
}
|
||||
}
|
||||
|
||||
let range = maxValue - minValue;
|
||||
|
||||
let paletteSize = palette.length;
|
||||
|
||||
let indexedFills = Array(counts.length);
|
||||
let indexedFills = Array(values.length);
|
||||
|
||||
for (let i = 0; i < counts.length; i++) {
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
indexedFills[i] =
|
||||
counts[i] === 0 ? -1 : Math.min(paletteSize - 1, Math.floor((paletteSize * (counts[i] - minCount)) / range));
|
||||
values[i] < minValue
|
||||
? 0
|
||||
: values[i] > maxValue
|
||||
? paletteSize - 1
|
||||
: Math.min(paletteSize - 1, Math.floor((paletteSize * (values[i] - minValue)) / range));
|
||||
}
|
||||
|
||||
return indexedFills;
|
||||
|
Loading…
Reference in New Issue
Block a user