diff --git a/heatmapchart/schemas/heatmap.cue b/heatmapchart/schemas/heatmap.cue index 37c32ab0..e635d660 100644 --- a/heatmapchart/schemas/heatmap.cue +++ b/heatmapchart/schemas/heatmap.cue @@ -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 }) diff --git a/heatmapchart/schemas/tests/valid/heatmap.json b/heatmapchart/schemas/tests/valid/heatmap.json index 4b85b760..bfa33520 100644 --- a/heatmapchart/schemas/tests/valid/heatmap.json +++ b/heatmapchart/schemas/tests/valid/heatmap.json @@ -9,6 +9,7 @@ "unit": "decimal", "decimalPlaces": 2 }, - "showVisualMap": true + "showVisualMap": true, + "logBase": 10 } } diff --git a/heatmapchart/schemas/tests/valid/heatmap_only_max.json b/heatmapchart/schemas/tests/valid/heatmap_only_max.json new file mode 100644 index 00000000..7a2f527a --- /dev/null +++ b/heatmapchart/schemas/tests/valid/heatmap_only_max.json @@ -0,0 +1,6 @@ +{ + "kind": "HeatMapChart", + "spec": { + "max": 10 + } +} diff --git a/heatmapchart/sdk/go/heatmap.go b/heatmapchart/sdk/go/heatmap.go index 31eb6c04..9477ddc7 100644 --- a/heatmapchart/sdk/go/heatmap.go +++ b/heatmapchart/sdk/go/heatmap.go @@ -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 diff --git a/heatmapchart/sdk/go/options.go b/heatmapchart/sdk/go/options.go index aef64685..1761c0b9 100644 --- a/heatmapchart/sdk/go/options.go +++ b/heatmapchart/sdk/go/options.go @@ -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 + } +} diff --git a/heatmapchart/src/components/HeatMapChart.tsx b/heatmapchart/src/components/HeatMapChart.tsx index aac9a176..ce996ce4 100644 --- a/heatmapchart/src/components/HeatMapChart.tsx +++ b/heatmapchart/src/components/HeatMapChart.tsx @@ -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 = [ @@ -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; @@ -54,14 +56,15 @@ 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({ @@ -69,13 +72,15 @@ export function HeatMapChart({ height, data, xAxisCategories, - yAxisCategories, yAxisFormat, countFormat, countMin, countMax, timeScale, showVisualMap, + min, + max, + logBase, }: HeatMapChartProps): ReactElement | null { const chartsTheme = useChartsTheme(); const theme = useTheme(); @@ -91,7 +96,6 @@ export function HeatMapChart({ label: params.data.label, marker: params.marker, xAxisCategories, - yAxisCategories, theme, yAxisFormat: yAxisFormat, countFormat: countFormat, @@ -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', @@ -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, }, @@ -153,7 +199,6 @@ export function HeatMapChart({ xAxisCategories, timeScale?.rangeMs, timeZone, - yAxisCategories, yAxisFormat, showVisualMap, countMin, @@ -162,6 +207,9 @@ export function HeatMapChart({ theme, data, countFormat, + min, + max, + logBase, ]); const chart = useMemo( diff --git a/heatmapchart/src/components/HeatMapChartOptionsEditorSettings.test.tsx b/heatmapchart/src/components/HeatMapChartOptionsEditorSettings.test.tsx index 46ac540a..3a5d768b 100644 --- a/heatmapchart/src/components/HeatMapChartOptionsEditorSettings.test.tsx +++ b/heatmapchart/src/components/HeatMapChartOptionsEditorSettings.test.tsx @@ -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'; @@ -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, + }) + ); + }); }); diff --git a/heatmapchart/src/components/HeatMapChartOptionsEditorSettings.tsx b/heatmapchart/src/components/HeatMapChartOptionsEditorSettings.tsx index 4618e3d2..9abc76be 100644 --- a/heatmapchart/src/components/HeatMapChartOptionsEditorSettings.tsx +++ b/heatmapchart/src/components/HeatMapChartOptionsEditorSettings.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Switch, SwitchProps } from '@mui/material'; +import { Switch, SwitchProps, TextField } from '@mui/material'; import { FormatControls, FormatControlsProps, @@ -19,11 +19,18 @@ import { OptionsEditorControl, OptionsEditorGrid, OptionsEditorGroup, + SettingsAutocomplete, } from '@perses-dev/components'; import { produce } from 'immer'; import merge from 'lodash/merge'; import { ReactElement } from 'react'; -import { DEFAULT_FORMAT, HeatMapChartOptions, HeatMapChartOptionsEditorProps } from '../heat-map-chart-model'; +import { + DEFAULT_FORMAT, + HeatMapChartOptions, + HeatMapChartOptionsEditorProps, + LOG_BASE_CONFIG, + LOG_BASE_OPTIONS, +} from '../heat-map-chart-model'; export function HeatMapChartOptionsEditorSettings(props: HeatMapChartOptionsEditorProps): ReactElement { const { onChange, value } = props; @@ -56,6 +63,10 @@ export function HeatMapChartOptionsEditorSettings(props: HeatMapChartOptionsEdit const yAxisFormat = merge({}, DEFAULT_FORMAT, value.yAxisFormat); const countFormat = merge({}, DEFAULT_FORMAT, value.countFormat); + // Get the current log base configuration, defaulting to 'none' if not set + const logBaseKey = value.logBase ? String(value.logBase) : 'none'; + const logBase = LOG_BASE_CONFIG[logBaseKey] ?? LOG_BASE_CONFIG['none']; + return ( @@ -70,6 +81,61 @@ export function HeatMapChartOptionsEditorSettings(props: HeatMapChartOptionsEdit + { + const newValue = e.target.value ? Number(e.target.value) : undefined; + onChange( + produce(value, (draft: HeatMapChartOptions) => { + draft.min = newValue; + }) + ); + }} + placeholder="Auto" + sx={{ width: '100%' }} + /> + } + /> + { + const newValue = e.target.value ? Number(e.target.value) : undefined; + onChange( + produce(value, (draft: HeatMapChartOptions) => { + draft.max = newValue; + }) + ); + }} + placeholder="Auto" + sx={{ width: '100%' }} + /> + } + /> + { + onChange( + produce(value, (draft: HeatMapChartOptions) => { + draft.logBase = newValue.log; + }) + ); + }} + disableClearable + /> + } + /> diff --git a/heatmapchart/src/components/HeatMapChartPanel.tsx b/heatmapchart/src/components/HeatMapChartPanel.tsx index 1265c396..5203326b 100644 --- a/heatmapchart/src/components/HeatMapChartPanel.tsx +++ b/heatmapchart/src/components/HeatMapChartPanel.tsx @@ -16,12 +16,22 @@ import { TimeScale, TimeSeries, TimeSeriesData } from '@perses-dev/core'; import { PanelProps } from '@perses-dev/plugin-system'; import merge from 'lodash/merge'; import { ReactElement, useMemo } from 'react'; -import { DEFAULT_FORMAT, HeatMapChartOptions } from '../heat-map-chart-model'; +import { DEFAULT_FORMAT, HeatMapChartOptions, LOG_BASE } from '../heat-map-chart-model'; import { generateCompleteTimestamps, getCommonTimeScaleForQueries } from '../utils'; import { HeatMapChart, HeatMapDataItem } from './HeatMapChart'; -const HEATMAP_MIN_HEIGHT = 200; -const HEATMAP_ITEM_MIN_HEIGHT = 2; +/** + * Helper function to get the effective lower bound for log scale. + * For values <= 0, we use a small fraction of the upper bound. + */ +const getEffectiveLowerBound = (lowerBound: number, upperBound: number, logBase: LOG_BASE): number => { + if (logBase === undefined || lowerBound > 0) { + return lowerBound; + } + // For log scales with non-positive lower bounds, use a small fraction of upper bound + // This ensures the bucket is still visible on the log scale + return upperBound * 0.001; +}; export type HeatMapChartPanelProps = PanelProps; @@ -35,14 +45,16 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | const { data, xAxisCategories, - yAxisCategories, + min, + max, countMin, countMax, timeScale, }: { data: HeatMapDataItem[]; xAxisCategories: number[]; - yAxisCategories: string[]; + min?: number; + max?: number; countMin: number; countMax: number; timeScale?: TimeScale; @@ -51,7 +63,8 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | return { data: [], xAxisCategories: [], - yAxisCategories: [], + min: 0, + max: 0, countMin: 0, countMax: 0, timeScale: undefined, @@ -66,7 +79,8 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | return { data: [], xAxisCategories: [], - yAxisCategories: [], + min: 0, + max: 0, countMin: 0, countMax: 0, timeScale: undefined, @@ -78,20 +92,31 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | const timeScale = getCommonTimeScaleForQueries(queryResults); const xAxisCategories: number[] = generateCompleteTimestamps(timeScale); + const logBase = pluginSpec.logBase; + // Dummy value that will be replaced at the first iteration let lowestBound = Infinity; let highestBound = -Infinity; let countMin = Infinity; let countMax = -Infinity; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const [_, histogram] of series?.histograms ?? []) { + for (const [, histogram] of series?.histograms ?? []) { for (const bucket of histogram?.buckets ?? []) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, lowerBound, upperBound, count] = bucket; - const lowerBoundFloat = parseFloat(lowerBound); + const [, lowerBound, upperBound, count] = bucket; + let lowerBoundFloat = parseFloat(lowerBound); const upperBoundFloat = parseFloat(upperBound); const countFloat = parseFloat(count); + + // For logarithmic scales, skip buckets that would be entirely non-positive + if (logBase !== undefined && upperBoundFloat <= 0) { + continue; + } + + // For log scales, adjust non-positive lower bounds + if (logBase !== undefined) { + lowerBoundFloat = getEffectiveLowerBound(lowerBoundFloat, upperBoundFloat, logBase); + } + if (lowerBoundFloat < lowestBound) { lowestBound = lowerBoundFloat; } @@ -107,45 +132,67 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | } } - const height = contentDimensions?.height ?? HEATMAP_MIN_HEIGHT; - const totalRange = highestBound - lowestBound; - const rangePerItem = (totalRange * HEATMAP_ITEM_MIN_HEIGHT) / height; - const totalItems = Math.ceil(height / HEATMAP_ITEM_MIN_HEIGHT); - - // Generating value of the Y axis based on the height divided by the size of a cell (item) - const yAxisCategories: string[] = Array.from({ length: totalItems }, (_, index) => - (lowestBound + index * rangePerItem).toFixed(3) - ); - const data: HeatMapDataItem[] = []; - // Logic for filling all cells where a bucket is present + // Each bucket becomes a rectangle spanning [lowerBound, upperBound] at the given x index for (const [time, histogram] of series?.histograms ?? []) { const itemIndexOnXaxis = xAxisCategories.findIndex((v) => v === time * 1000); for (const bucket of histogram?.buckets ?? []) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, lowerBound, upperBound, count] = bucket; - const yLowerBoundItem = Math.floor((parseFloat(lowerBound) - lowestBound) / rangePerItem); - const yUpperBoundItem = Math.ceil((parseFloat(upperBound) - lowestBound) / rangePerItem); - - for (let i = 0; i < yUpperBoundItem - yLowerBoundItem; i++) { - // TODO: some bucket may have overlapping cells, we could use avg value. Probably will need to move to a matrix data structure for performance reasons - data.push({ - value: [itemIndexOnXaxis, yLowerBoundItem + i, parseFloat(count)], - label: count, - }); + const [, lowerBound, upperBound, count] = bucket; + let lowerBoundFloat = parseFloat(lowerBound); + const upperBoundFloat = parseFloat(upperBound); + + // For logarithmic scales, skip buckets that would be entirely non-positive + if (logBase !== undefined && upperBoundFloat <= 0) { + continue; } + + // For log scales, adjust non-positive lower bounds + if (logBase !== undefined) { + lowerBoundFloat = getEffectiveLowerBound(lowerBoundFloat, upperBoundFloat, logBase); + } + + data.push({ + value: [itemIndexOnXaxis, lowerBoundFloat, upperBoundFloat, parseFloat(count)], + label: count, + }); } } return { data, xAxisCategories, - yAxisCategories, + min: lowestBound === Infinity ? undefined : lowestBound, + max: highestBound === -Infinity ? undefined : highestBound, countMin, countMax, timeScale, }; - }, [contentDimensions?.height, queryResults]); + }, [pluginSpec.logBase, queryResults]); + + // Use configured min/max if provided, otherwise use calculated values + // For logarithmic scales, ignore user-provided min if it's <= 0 (log of non-positive is undefined) + // and let ECharts auto-calculate the range to avoid rendering issues + const finalMin = useMemo(() => { + if (pluginSpec.logBase !== undefined) { + // For log scale, ignore min if it's <= 0 or let ECharts auto-calculate + if (pluginSpec.min !== undefined && pluginSpec.min <= 0) { + return undefined; // Let ECharts auto-calculate + } + return pluginSpec.min ?? min; + } + return pluginSpec.min ?? min; + }, [pluginSpec.logBase, pluginSpec.min, min]); + + const finalMax = useMemo(() => { + if (pluginSpec.logBase !== undefined) { + // For log scale, ignore max if it's <= 0 + if (pluginSpec.max !== undefined && pluginSpec.max <= 0) { + return undefined; // Let ECharts auto-calculate + } + return pluginSpec.max ?? max; + } + return pluginSpec.max ?? max; + }, [pluginSpec.logBase, pluginSpec.max, max]); // TODO: add support for multiple queries if (queryResults.length > 1) { @@ -178,13 +225,15 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | height={contentDimensions.height} data={data} xAxisCategories={xAxisCategories} - yAxisCategories={yAxisCategories} yAxisFormat={yAxisFormat} countFormat={countFormat} countMin={countMin} countMax={countMax} timeScale={timeScale} showVisualMap={pluginSpec.showVisualMap} + min={finalMin} + max={finalMax} + logBase={pluginSpec.logBase} /> ); diff --git a/heatmapchart/src/components/HeatMapTooltip.ts b/heatmapchart/src/components/HeatMapTooltip.ts index 4cf6fe7d..e429e0a9 100644 --- a/heatmapchart/src/components/HeatMapTooltip.ts +++ b/heatmapchart/src/components/HeatMapTooltip.ts @@ -21,7 +21,6 @@ interface CustomTooltipProps { label: string; marker: string; xAxisCategories: number[]; - yAxisCategories: string[]; theme: Theme; yAxisFormat?: FormatOptions; countFormat?: FormatOptions; @@ -32,13 +31,12 @@ export function generateTooltipHTML({ label, marker, xAxisCategories, - yAxisCategories, theme, yAxisFormat, countFormat, }: CustomTooltipProps): string { - const [x, y] = data; - const xAxisLabel = xAxisCategories[x]; + const [xIndex, yLower, yUpper] = data; + const xAxisLabel = xAxisCategories[xIndex]; const { formattedDate, formattedTime } = getDateAndTime(xAxisLabel); @@ -57,10 +55,8 @@ export function generateTooltipHTML({ margin-right: 16px; `; - const lowerBound = parseFloat(yAxisCategories[y]!); - const upperBound = yAxisCategories[y + 1] - ? parseFloat(yAxisCategories[y + 1]!) - : parseFloat(yAxisCategories[y]!) + parseFloat(yAxisCategories[y]!) - parseFloat(yAxisCategories[y - 1]!); // Top cell, upper bound need to be calculated from previous cell + const lowerBound = yLower; + const upperBound = yUpper; return `
diff --git a/heatmapchart/src/heat-map-chart-model.ts b/heatmapchart/src/heat-map-chart-model.ts index 7b4ff14f..0ea2ed71 100644 --- a/heatmapchart/src/heat-map-chart-model.ts +++ b/heatmapchart/src/heat-map-chart-model.ts @@ -20,6 +20,19 @@ export const DEFAULT_MAX_PERCENT = 100; export const DEFAULT_MIN_PERCENT_DECIMAL = 0; export const DEFAULT_MAX_PERCENT_DECIMAL = 1; +export type LOG_BASE = undefined | 2 | 10; + +export const LOG_BASE_CONFIG: Record = { + none: { label: 'None', log: undefined }, + '2': { label: '2', log: 2 }, + '10': { label: '10', log: 10 }, +}; + +export const LOG_BASE_OPTIONS = Object.entries(LOG_BASE_CONFIG).map(([id, config]) => ({ + id: id as string, + ...config, +})); + /** * The schema for a HeatMapChart panel. */ @@ -34,6 +47,9 @@ export interface HeatMapChartOptions { yAxisFormat?: FormatOptions; countFormat?: FormatOptions; showVisualMap?: boolean; + logBase?: LOG_BASE; + min?: number; + max?: number; } export type HeatMapChartOptionsEditorProps = OptionsEditorProps;