Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions heatmapchart/schemas/heatmap.cue
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,10 @@ spec: close({
countFormat?: common.#format
// The visual map is an helper for highlighting cell with the targeted value
showVisualMap?: bool
min?: number
max?: number
if min != _|_ && max != _|_ {
max: >= min
}
logBase?: 2 | 10
})
3 changes: 2 additions & 1 deletion heatmapchart/schemas/tests/valid/heatmap.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"unit": "decimal",
"decimalPlaces": 2
},
"showVisualMap": true
"showVisualMap": true,
"logBase": 10
}
}
6 changes: 6 additions & 0 deletions heatmapchart/schemas/tests/valid/heatmap_only_max.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"kind": "HeatMapChart",
"spec": {
"max": 10
}
}
3 changes: 3 additions & 0 deletions heatmapchart/sdk/go/heatmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ type PluginSpec struct {
YAxisFormat *common.Format `json:"yAxisFormat,omitempty" yaml:"yAxisFormat,omitempty"`
CountFormat *common.Format `json:"countFormat,omitempty" yaml:"countFormat,omitempty"`
ShowVisualMap bool `json:"showVisualMap,omitempty" yaml:"showVisualMap,omitempty"`
Min float64 `json:"min,omitempty" yaml:"min,omitempty"`
Max float64 `json:"max,omitempty" yaml:"max,omitempty"`
LogBase uint `json:"logBase,omitempty" yaml:"logBase,omitempty"`
}

type Option func(plugin *Builder) error
Expand Down
21 changes: 21 additions & 0 deletions heatmapchart/sdk/go/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,24 @@ func ShowVisualMap(show bool) Option {
return nil
}
}

func Min(min float64) Option {
return func(builder *Builder) error {
builder.Min = min
return nil
}
}

func Max(max float64) Option {
return func(builder *Builder) error {
builder.Max = max
return nil
}
}

func WithLogBase(logBase uint) Option {
return func(builder *Builder) error {
builder.LogBase = logBase
return nil
}
}
96 changes: 72 additions & 24 deletions heatmapchart/src/components/HeatMapChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,17 @@
// limitations under the License.

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

use([EChartsHeatmapChart]);
use([CustomChart]);

// The default coloring is a blue->yellow->red gradient
const DEFAULT_VISUAL_MAP_COLORS = [
Expand All @@ -37,7 +39,7 @@ const DEFAULT_VISUAL_MAP_COLORS = [
'#a50026',
];

export type HeatMapData = [number, number, number | undefined]; // [x, y, value]
export type HeatMapData = [number, number, number, number | undefined]; // [xIndex, yLower, yUpper, count]

export interface HeatMapDataItem {
value: HeatMapData;
Expand All @@ -54,28 +56,31 @@ export interface HeatMapChartProps {
height: number;
data: HeatMapDataItem[];
xAxisCategories: number[];
yAxisCategories: string[];
yAxisFormat?: FormatOptions;
countFormat?: FormatOptions;
countMin?: number;
countMax?: number;
timeScale?: TimeScale;
showVisualMap?: boolean;
// TODO: exponential?: boolean;
min?: number;
max?: number;
logBase?: LOG_BASE;
}

export function HeatMapChart({
width,
height,
data,
xAxisCategories,
yAxisCategories,
yAxisFormat,
countFormat,
countMin,
countMax,
timeScale,
showVisualMap,
min,
max,
logBase,
}: HeatMapChartProps): ReactElement | null {
const chartsTheme = useChartsTheme();
const theme = useTheme();
Expand All @@ -91,7 +96,6 @@ export function HeatMapChart({
label: params.data.label,
marker: params.marker,
xAxisCategories,
yAxisCategories,
theme,
yAxisFormat: yAxisFormat,
countFormat: countFormat,
Expand All @@ -106,13 +110,24 @@ export function HeatMapChart({
formatter: getFormattedHeatmapAxisLabel(timeScale?.rangeMs ?? 0, timeZone),
},
},
yAxis: getFormattedAxis(
{
type: 'category',
data: yAxisCategories,
yAxis: {
type: logBase !== undefined ? 'log' : 'value',
logBase: logBase,
boundaryGap: [0, '10%'],
min: min,
max: max,
axisLabel: {
hideOverlap: true,
formatter: (value: number): string => {
// On log scales, ECharts may generate a tick at 0 which is mathematically
// invalid (log(0) is undefined). Return empty string to hide such labels.
if (logBase !== undefined && value === 0) {
return '';
}
return formatValue(value, yAxisFormat);
},
},
yAxisFormat
),
},
visualMap: {
show: showVisualMap ?? false,
type: 'continuous',
Expand All @@ -132,18 +147,49 @@ export function HeatMapChart({
textBorderColor: theme.palette.background.default,
textBorderWidth: 5,
},
// Color by the count dimension (index 3)
dimension: 3,
},
series: [
{
name: 'Gaussian',
type: 'heatmap',
data: data,
emphasis: {
itemStyle: {
borderColor: '#333',
borderWidth: 1,
},
name: 'HeatMap',
type: 'custom',
renderItem: function (params: CustomSeriesRenderItemParams, api: CustomSeriesRenderItemAPI) {
const xIndex = api.value(0) as number;
const yLower = api.value(1) as number;
const yUpper = api.value(2) as number;

// Pixel coordinates
const upperStart = api.coord([xIndex, yUpper]);
const lowerStart = api.coord([xIndex, yLower]);
const upperNext = api.coord([xIndex + 1, yUpper]);

const startX = upperStart?.[0];
const upperY = upperStart?.[1];
const lowerY = lowerStart?.[1];
const nextX = upperNext?.[0];

if (startX === undefined || upperY === undefined || lowerY === undefined || nextX === undefined) {
return null;
}

const topY = Math.min(upperY, lowerY);
const bottomY = Math.max(upperY, lowerY);
const width = nextX - startX;
const height = bottomY - topY;

return {
type: 'rect',
shape: { x: startX, y: topY, width, height },
style: {
fill: api.visual('color'),
},
};
},
label: { show: false },
dimensions: ['xIndex', 'yLower', 'yUpper', 'count'],
encode: { x: 0, y: [1, 2], tooltip: [1, 2, 3] },
data: data,
progressive: 1000,
animation: false,
},
Expand All @@ -153,7 +199,6 @@ export function HeatMapChart({
xAxisCategories,
timeScale?.rangeMs,
timeZone,
yAxisCategories,
yAxisFormat,
showVisualMap,
countMin,
Expand All @@ -162,6 +207,9 @@ export function HeatMapChart({
theme,
data,
countFormat,
min,
max,
logBase,
]);

const chart = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import { ChartsProvider, testChartsTheme } from '@perses-dev/components';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { act } from 'react';
import { DEFAULT_FORMAT, HeatMapChartOptions } from '../heat-map-chart-model';
import { HeatMapChartOptionsEditorSettings } from './HeatMapChartOptionsEditorSettings';
Expand Down Expand Up @@ -47,4 +48,49 @@ describe('HeatMapChartOptionsEditorSettings', () => {
expect(onChange).toHaveBeenCalledTimes(1);
expect(showVisualMap).toBe(true);
});

it('can modify y-axis log base', async () => {
const onChange = jest.fn();
renderHeatMapChartOptionsEditorSettings(
{
yAxisFormat: DEFAULT_FORMAT,
countFormat: DEFAULT_FORMAT,
},
onChange
);
const logBaseSelector = screen.getByRole('combobox', { name: 'Log Base' });
userEvent.click(logBaseSelector);
const log10Option = screen.getByRole('option', {
name: '10',
});
userEvent.click(log10Option);
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
logBase: 10,
})
);
});

it('can clear y-axis log base to none', async () => {
const onChange = jest.fn();
renderHeatMapChartOptionsEditorSettings(
{
yAxisFormat: DEFAULT_FORMAT,
countFormat: DEFAULT_FORMAT,
logBase: 10,
},
onChange
);
const logBaseSelector = screen.getByRole('combobox', { name: 'Log Base' });
userEvent.click(logBaseSelector);
const noneOption = screen.getByRole('option', {
name: 'None',
});
userEvent.click(noneOption);
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
logBase: undefined,
})
);
});
});
Loading