Skip to content

Commit e375f1d

Browse files
ibakshayZPascal
authored andcommitted
[FEATURE]: Add logarithmic scale + max and min configuration for heatmap (perses#562)
* [FEATURE]: Support logbase y axis for heatmap Signed-off-by: Akshay Iyyadurai Balasundaram <akshay.iyyadurai.balasundaram@sap.com> * change echart to custom to use native log values Signed-off-by: Akshay Iyyadurai Balasundaram <akshay.iyyadurai.balasundaram@sap.com> * fix deprecated function + lint Signed-off-by: Akshay Iyyadurai Balasundaram <akshay.iyyadurai.balasundaram@sap.com> * fix: max without min value Signed-off-by: Akshay Iyyadurai Balasundaram <akshay.iyyadurai.balasundaram@sap.com> * add min and max to heatmapchart sdk Signed-off-by: Akshay Iyyadurai Balasundaram <akshay.iyyadurai.balasundaram@sap.com> * fix overlapp y axis tick Signed-off-by: Akshay Iyyadurai Balasundaram <akshay.iyyadurai.balasundaram@sap.com> --------- Signed-off-by: Akshay Iyyadurai Balasundaram <akshay.iyyadurai.balasundaram@sap.com>
1 parent b77542d commit e375f1d

11 files changed

Lines changed: 330 additions & 72 deletions

heatmapchart/schemas/heatmap.cue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,10 @@ spec: close({
2323
countFormat?: common.#format
2424
// The visual map is an helper for highlighting cell with the targeted value
2525
showVisualMap?: bool
26+
min?: number
27+
max?: number
28+
if min != _|_ && max != _|_ {
29+
max: >= min
30+
}
31+
logBase?: 2 | 10
2632
})

heatmapchart/schemas/tests/valid/heatmap.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"unit": "decimal",
1010
"decimalPlaces": 2
1111
},
12-
"showVisualMap": true
12+
"showVisualMap": true,
13+
"logBase": 10
1314
}
1415
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"kind": "HeatMapChart",
3+
"spec": {
4+
"max": 10
5+
}
6+
}

heatmapchart/sdk/go/heatmap.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ type PluginSpec struct {
2424
YAxisFormat *common.Format `json:"yAxisFormat,omitempty" yaml:"yAxisFormat,omitempty"`
2525
CountFormat *common.Format `json:"countFormat,omitempty" yaml:"countFormat,omitempty"`
2626
ShowVisualMap bool `json:"showVisualMap,omitempty" yaml:"showVisualMap,omitempty"`
27+
Min float64 `json:"min,omitempty" yaml:"min,omitempty"`
28+
Max float64 `json:"max,omitempty" yaml:"max,omitempty"`
29+
LogBase uint `json:"logBase,omitempty" yaml:"logBase,omitempty"`
2730
}
2831

2932
type Option func(plugin *Builder) error

heatmapchart/sdk/go/options.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,24 @@ func ShowVisualMap(show bool) Option {
3737
return nil
3838
}
3939
}
40+
41+
func Min(min float64) Option {
42+
return func(builder *Builder) error {
43+
builder.Min = min
44+
return nil
45+
}
46+
}
47+
48+
func Max(max float64) Option {
49+
return func(builder *Builder) error {
50+
builder.Max = max
51+
return nil
52+
}
53+
}
54+
55+
func WithLogBase(logBase uint) Option {
56+
return func(builder *Builder) error {
57+
builder.LogBase = logBase
58+
return nil
59+
}
60+
}

heatmapchart/src/components/HeatMapChart.tsx

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,17 @@
1212
// limitations under the License.
1313

1414
import { ReactElement, useMemo } from 'react';
15-
import { FormatOptions, TimeScale } from '@perses-dev/core';
16-
import { EChart, getFormattedAxis, useChartsTheme, useTimeZone } from '@perses-dev/components';
15+
import { formatValue, FormatOptions, TimeScale } from '@perses-dev/core';
16+
import { EChart, useChartsTheme, useTimeZone } from '@perses-dev/components';
1717
import { use, EChartsCoreOption } from 'echarts/core';
18-
import { HeatmapChart as EChartsHeatmapChart } from 'echarts/charts';
18+
import { CustomChart } from 'echarts/charts';
19+
import type { CustomSeriesRenderItemAPI, CustomSeriesRenderItemParams } from 'echarts';
1920
import { useTheme } from '@mui/material';
21+
import { LOG_BASE } from '../heat-map-chart-model';
2022
import { getFormattedHeatmapAxisLabel } from '../utils';
2123
import { generateTooltipHTML } from './HeatMapTooltip';
2224

23-
use([EChartsHeatmapChart]);
25+
use([CustomChart]);
2426

2527
// The default coloring is a blue->yellow->red gradient
2628
const DEFAULT_VISUAL_MAP_COLORS = [
@@ -37,7 +39,7 @@ const DEFAULT_VISUAL_MAP_COLORS = [
3739
'#a50026',
3840
];
3941

40-
export type HeatMapData = [number, number, number | undefined]; // [x, y, value]
42+
export type HeatMapData = [number, number, number, number | undefined]; // [xIndex, yLower, yUpper, count]
4143

4244
export interface HeatMapDataItem {
4345
value: HeatMapData;
@@ -54,28 +56,31 @@ export interface HeatMapChartProps {
5456
height: number;
5557
data: HeatMapDataItem[];
5658
xAxisCategories: number[];
57-
yAxisCategories: string[];
5859
yAxisFormat?: FormatOptions;
5960
countFormat?: FormatOptions;
6061
countMin?: number;
6162
countMax?: number;
6263
timeScale?: TimeScale;
6364
showVisualMap?: boolean;
64-
// TODO: exponential?: boolean;
65+
min?: number;
66+
max?: number;
67+
logBase?: LOG_BASE;
6568
}
6669

6770
export function HeatMapChart({
6871
width,
6972
height,
7073
data,
7174
xAxisCategories,
72-
yAxisCategories,
7375
yAxisFormat,
7476
countFormat,
7577
countMin,
7678
countMax,
7779
timeScale,
7880
showVisualMap,
81+
min,
82+
max,
83+
logBase,
7984
}: HeatMapChartProps): ReactElement | null {
8085
const chartsTheme = useChartsTheme();
8186
const theme = useTheme();
@@ -91,7 +96,6 @@ export function HeatMapChart({
9196
label: params.data.label,
9297
marker: params.marker,
9398
xAxisCategories,
94-
yAxisCategories,
9599
theme,
96100
yAxisFormat: yAxisFormat,
97101
countFormat: countFormat,
@@ -106,13 +110,24 @@ export function HeatMapChart({
106110
formatter: getFormattedHeatmapAxisLabel(timeScale?.rangeMs ?? 0, timeZone),
107111
},
108112
},
109-
yAxis: getFormattedAxis(
110-
{
111-
type: 'category',
112-
data: yAxisCategories,
113+
yAxis: {
114+
type: logBase !== undefined ? 'log' : 'value',
115+
logBase: logBase,
116+
boundaryGap: [0, '10%'],
117+
min: min,
118+
max: max,
119+
axisLabel: {
120+
hideOverlap: true,
121+
formatter: (value: number): string => {
122+
// On log scales, ECharts may generate a tick at 0 which is mathematically
123+
// invalid (log(0) is undefined). Return empty string to hide such labels.
124+
if (logBase !== undefined && value === 0) {
125+
return '';
126+
}
127+
return formatValue(value, yAxisFormat);
128+
},
113129
},
114-
yAxisFormat
115-
),
130+
},
116131
visualMap: {
117132
show: showVisualMap ?? false,
118133
type: 'continuous',
@@ -132,18 +147,49 @@ export function HeatMapChart({
132147
textBorderColor: theme.palette.background.default,
133148
textBorderWidth: 5,
134149
},
150+
// Color by the count dimension (index 3)
151+
dimension: 3,
135152
},
136153
series: [
137154
{
138-
name: 'Gaussian',
139-
type: 'heatmap',
140-
data: data,
141-
emphasis: {
142-
itemStyle: {
143-
borderColor: '#333',
144-
borderWidth: 1,
145-
},
155+
name: 'HeatMap',
156+
type: 'custom',
157+
renderItem: function (params: CustomSeriesRenderItemParams, api: CustomSeriesRenderItemAPI) {
158+
const xIndex = api.value(0) as number;
159+
const yLower = api.value(1) as number;
160+
const yUpper = api.value(2) as number;
161+
162+
// Pixel coordinates
163+
const upperStart = api.coord([xIndex, yUpper]);
164+
const lowerStart = api.coord([xIndex, yLower]);
165+
const upperNext = api.coord([xIndex + 1, yUpper]);
166+
167+
const startX = upperStart?.[0];
168+
const upperY = upperStart?.[1];
169+
const lowerY = lowerStart?.[1];
170+
const nextX = upperNext?.[0];
171+
172+
if (startX === undefined || upperY === undefined || lowerY === undefined || nextX === undefined) {
173+
return null;
174+
}
175+
176+
const topY = Math.min(upperY, lowerY);
177+
const bottomY = Math.max(upperY, lowerY);
178+
const width = nextX - startX;
179+
const height = bottomY - topY;
180+
181+
return {
182+
type: 'rect',
183+
shape: { x: startX, y: topY, width, height },
184+
style: {
185+
fill: api.visual('color'),
186+
},
187+
};
146188
},
189+
label: { show: false },
190+
dimensions: ['xIndex', 'yLower', 'yUpper', 'count'],
191+
encode: { x: 0, y: [1, 2], tooltip: [1, 2, 3] },
192+
data: data,
147193
progressive: 1000,
148194
animation: false,
149195
},
@@ -153,7 +199,6 @@ export function HeatMapChart({
153199
xAxisCategories,
154200
timeScale?.rangeMs,
155201
timeZone,
156-
yAxisCategories,
157202
yAxisFormat,
158203
showVisualMap,
159204
countMin,
@@ -162,6 +207,9 @@ export function HeatMapChart({
162207
theme,
163208
data,
164209
countFormat,
210+
min,
211+
max,
212+
logBase,
165213
]);
166214

167215
const chart = useMemo(

heatmapchart/src/components/HeatMapChartOptionsEditorSettings.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import { ChartsProvider, testChartsTheme } from '@perses-dev/components';
1515
import { fireEvent, render, screen } from '@testing-library/react';
16+
import userEvent from '@testing-library/user-event';
1617
import React, { act } from 'react';
1718
import { DEFAULT_FORMAT, HeatMapChartOptions } from '../heat-map-chart-model';
1819
import { HeatMapChartOptionsEditorSettings } from './HeatMapChartOptionsEditorSettings';
@@ -47,4 +48,49 @@ describe('HeatMapChartOptionsEditorSettings', () => {
4748
expect(onChange).toHaveBeenCalledTimes(1);
4849
expect(showVisualMap).toBe(true);
4950
});
51+
52+
it('can modify y-axis log base', async () => {
53+
const onChange = jest.fn();
54+
renderHeatMapChartOptionsEditorSettings(
55+
{
56+
yAxisFormat: DEFAULT_FORMAT,
57+
countFormat: DEFAULT_FORMAT,
58+
},
59+
onChange
60+
);
61+
const logBaseSelector = screen.getByRole('combobox', { name: 'Log Base' });
62+
userEvent.click(logBaseSelector);
63+
const log10Option = screen.getByRole('option', {
64+
name: '10',
65+
});
66+
userEvent.click(log10Option);
67+
expect(onChange).toHaveBeenCalledWith(
68+
expect.objectContaining({
69+
logBase: 10,
70+
})
71+
);
72+
});
73+
74+
it('can clear y-axis log base to none', async () => {
75+
const onChange = jest.fn();
76+
renderHeatMapChartOptionsEditorSettings(
77+
{
78+
yAxisFormat: DEFAULT_FORMAT,
79+
countFormat: DEFAULT_FORMAT,
80+
logBase: 10,
81+
},
82+
onChange
83+
);
84+
const logBaseSelector = screen.getByRole('combobox', { name: 'Log Base' });
85+
userEvent.click(logBaseSelector);
86+
const noneOption = screen.getByRole('option', {
87+
name: 'None',
88+
});
89+
userEvent.click(noneOption);
90+
expect(onChange).toHaveBeenCalledWith(
91+
expect.objectContaining({
92+
logBase: undefined,
93+
})
94+
);
95+
});
5096
});

0 commit comments

Comments
 (0)