From aa8e24f9c6debc99ab6c974ecc17aa28182753c3 Mon Sep 17 00:00:00 2001 From: Akshay Iyyadurai Balasundaram Date: Sat, 7 Feb 2026 21:22:52 +0530 Subject: [PATCH 1/6] [FEATURE]: Support logbase y axis for heatmap Signed-off-by: Akshay Iyyadurai Balasundaram --- heatmapchart/schemas/heatmap.cue | 1 + heatmapchart/schemas/tests/valid/heatmap.json | 3 +- heatmapchart/sdk/go/heatmap.go | 1 + heatmapchart/sdk/go/options.go | 7 ++ heatmapchart/src/components/HeatMapChart.tsx | 1 - ...HeatMapChartOptionsEditorSettings.test.tsx | 46 +++++++ .../HeatMapChartOptionsEditorSettings.tsx | 30 ++++- .../src/components/HeatMapChartPanel.tsx | 114 ++++++++++++++++-- heatmapchart/src/heat-map-chart-model.ts | 14 +++ 9 files changed, 203 insertions(+), 14 deletions(-) diff --git a/heatmapchart/schemas/heatmap.cue b/heatmapchart/schemas/heatmap.cue index 37c32ab0..2d1d704c 100644 --- a/heatmapchart/schemas/heatmap.cue +++ b/heatmapchart/schemas/heatmap.cue @@ -23,4 +23,5 @@ spec: close({ countFormat?: common.#format // The visual map is an helper for highlighting cell with the targeted value showVisualMap?: bool + 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/sdk/go/heatmap.go b/heatmapchart/sdk/go/heatmap.go index 31eb6c04..a2008e0c 100644 --- a/heatmapchart/sdk/go/heatmap.go +++ b/heatmapchart/sdk/go/heatmap.go @@ -24,6 +24,7 @@ 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"` + 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..faa77b99 100644 --- a/heatmapchart/sdk/go/options.go +++ b/heatmapchart/sdk/go/options.go @@ -37,3 +37,10 @@ func ShowVisualMap(show bool) Option { 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..8cb4aed2 100644 --- a/heatmapchart/src/components/HeatMapChart.tsx +++ b/heatmapchart/src/components/HeatMapChart.tsx @@ -61,7 +61,6 @@ export interface HeatMapChartProps { countMax?: number; timeScale?: TimeScale; showVisualMap?: boolean; - // TODO: exponential?: boolean; } export function HeatMapChart({ 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..157119d6 100644 --- a/heatmapchart/src/components/HeatMapChartOptionsEditorSettings.tsx +++ b/heatmapchart/src/components/HeatMapChartOptionsEditorSettings.tsx @@ -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,23 @@ export function HeatMapChartOptionsEditorSettings(props: HeatMapChartOptionsEdit + { + 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..5cfeffe9 100644 --- a/heatmapchart/src/components/HeatMapChartPanel.tsx +++ b/heatmapchart/src/components/HeatMapChartPanel.tsx @@ -16,13 +16,41 @@ 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 apply logarithmic transformation to a value. + * Returns the log of the value in the specified base. + * + * NOTE: This custom log transformation is required because ECharts heatmap + * charts require category-type axes (type: 'category'), which do not support + * native logarithmic scaling. Only value-type axes support type: 'log'. + * Unlike HistogramChart or TimeSeriesChart which can use ECharts' native + * log axis, we must manually transform the data and Y-axis categories. + */ +const logTransform = (value: number, logBase: number): number => { + if (value <= 0) return -Infinity; + return Math.log(value) / Math.log(logBase); +}; + +/** + * 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; export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | null { @@ -78,6 +106,8 @@ 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; @@ -89,9 +119,21 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | for (const bucket of histogram?.buckets ?? []) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, lowerBound, upperBound, count] = bucket; - const lowerBoundFloat = parseFloat(lowerBound); + let lowerBoundFloat = parseFloat(lowerBound); const upperBoundFloat = parseFloat(upperBound); const countFloat = parseFloat(count); + + // For logarithmic scales, skip buckets that would be entirely non-positive + // Log scales cannot represent non-positive values, so we exclude any buckets where the upper bound is <= 0 + 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; } @@ -108,14 +150,39 @@ 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); + + // Calculate range and item size based on scale type + let totalRange: number; + let rangePerItem: number; + let totalItems: number; + + if (logBase !== undefined) { + // For logarithmic scale, work in log space + const logLowest = logTransform(lowestBound, logBase); + const logHighest = logTransform(highestBound, logBase); + totalRange = logHighest - logLowest; + totalItems = Math.ceil(height / HEATMAP_ITEM_MIN_HEIGHT); + rangePerItem = totalRange / totalItems; + } else { + // Linear scale (original behavior) + totalRange = highestBound - lowestBound; + rangePerItem = (totalRange * HEATMAP_ITEM_MIN_HEIGHT) / height; + 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) - ); + // For log scale, we generate categories from log-transformed space but display original values + const yAxisCategories: string[] = Array.from({ length: totalItems }, (_, index) => { + if (logBase !== undefined) { + // Convert from log space back to linear space for display + const logLowest = logTransform(lowestBound, logBase); + const logValue = logLowest + index * rangePerItem; + const linearValue = Math.pow(logBase, logValue); + return linearValue.toFixed(3); + } else { + return (lowestBound + index * rangePerItem).toFixed(3); + } + }); const data: HeatMapDataItem[] = []; // Logic for filling all cells where a bucket is present @@ -125,8 +192,33 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | 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); + 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); + } + + let yLowerBoundItem: number; + let yUpperBoundItem: number; + + if (logBase !== undefined) { + // Calculate Y positions in log space + const logLowest = logTransform(lowestBound, logBase); + const logLower = logTransform(lowerBoundFloat, logBase); + const logUpper = logTransform(upperBoundFloat, logBase); + yLowerBoundItem = Math.floor((logLower - logLowest) / rangePerItem); + yUpperBoundItem = Math.ceil((logUpper - logLowest) / rangePerItem); + } else { + yLowerBoundItem = Math.floor((lowerBoundFloat - lowestBound) / rangePerItem); + yUpperBoundItem = Math.ceil((upperBoundFloat - 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 @@ -145,7 +237,7 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | countMax, timeScale, }; - }, [contentDimensions?.height, queryResults]); + }, [contentDimensions?.height, pluginSpec.logBase, queryResults]); // TODO: add support for multiple queries if (queryResults.length > 1) { diff --git a/heatmapchart/src/heat-map-chart-model.ts b/heatmapchart/src/heat-map-chart-model.ts index 7b4ff14f..42ce6dcb 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,7 @@ export interface HeatMapChartOptions { yAxisFormat?: FormatOptions; countFormat?: FormatOptions; showVisualMap?: boolean; + logBase?: LOG_BASE; } export type HeatMapChartOptionsEditorProps = OptionsEditorProps; From 7c7c0672cbff4cd32871ba4f7c725269b3f492ed Mon Sep 17 00:00:00 2001 From: Akshay Iyyadurai Balasundaram Date: Sun, 8 Feb 2026 22:36:16 +0530 Subject: [PATCH 2/6] change echart to custom to use native log values Signed-off-by: Akshay Iyyadurai Balasundaram --- heatmapchart/schemas/heatmap.cue | 4 +- heatmapchart/src/components/HeatMapChart.tsx | 73 +++++++--- .../HeatMapChartOptionsEditorSettings.tsx | 40 +++++- .../src/components/HeatMapChartPanel.tsx | 129 ++++++------------ heatmapchart/src/components/HeatMapTooltip.ts | 12 +- heatmapchart/src/heat-map-chart-model.ts | 2 + 6 files changed, 147 insertions(+), 113 deletions(-) diff --git a/heatmapchart/schemas/heatmap.cue b/heatmapchart/schemas/heatmap.cue index 2d1d704c..bfb89e50 100644 --- a/heatmapchart/schemas/heatmap.cue +++ b/heatmapchart/schemas/heatmap.cue @@ -23,5 +23,7 @@ spec: close({ countFormat?: common.#format // The visual map is an helper for highlighting cell with the targeted value showVisualMap?: bool - logBase?: 2 | 10 + min?: number + max?: number & >= min + logBase?: 2 | 10 }) diff --git a/heatmapchart/src/components/HeatMapChart.tsx b/heatmapchart/src/components/HeatMapChart.tsx index 8cb4aed2..3a7ffb82 100644 --- a/heatmapchart/src/components/HeatMapChart.tsx +++ b/heatmapchart/src/components/HeatMapChart.tsx @@ -15,12 +15,14 @@ import { ReactElement, useMemo } from 'react'; import { FormatOptions, TimeScale } from '@perses-dev/core'; import { EChart, getFormattedAxis, 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 { getFormattedHeatmapAxisLabel } from '../utils'; import { generateTooltipHTML } from './HeatMapTooltip'; +import { LOG_BASE } from '../heat-map-chart-model'; -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,13 +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; + min?: number; + max?: number; + logBase?: LOG_BASE; } export function HeatMapChart({ @@ -68,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(); @@ -90,7 +96,6 @@ export function HeatMapChart({ label: params.data.label, marker: params.marker, xAxisCategories, - yAxisCategories, theme, yAxisFormat: yAxisFormat, countFormat: countFormat, @@ -107,8 +112,10 @@ export function HeatMapChart({ }, yAxis: getFormattedAxis( { - type: 'category', - data: yAxisCategories, + type: logBase !== undefined ? 'log' : 'value', + logBase: logBase, + min: min, + max: max, }, yAxisFormat ), @@ -131,18 +138,48 @@ 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; + + const style = api.style?.(); + return { + type: 'rect', + shape: { x: startX, y: topY, width, height }, + style: style, + }; }, + label: { show: false }, + dimensions: ['xIndex', 'yLower', 'yUpper', 'count'], + encode: { x: 0, y: [1, 2], tooltip: [1, 2, 3] }, + data: data, progressive: 1000, animation: false, }, @@ -152,7 +189,6 @@ export function HeatMapChart({ xAxisCategories, timeScale?.rangeMs, timeZone, - yAxisCategories, yAxisFormat, showVisualMap, countMin, @@ -161,6 +197,9 @@ export function HeatMapChart({ theme, data, countFormat, + min, + max, + logBase, ]); const chart = useMemo( diff --git a/heatmapchart/src/components/HeatMapChartOptionsEditorSettings.tsx b/heatmapchart/src/components/HeatMapChartOptionsEditorSettings.tsx index 157119d6..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, @@ -81,6 +81,44 @@ 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%' }} + /> + } + /> { - if (value <= 0) return -Infinity; - return Math.log(value) / Math.log(logBase); -}; - /** * Helper function to get the effective lower bound for log scale. * For values <= 0, we use a small fraction of the upper bound. @@ -63,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; @@ -79,7 +63,8 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | return { data: [], xAxisCategories: [], - yAxisCategories: [], + min: 0, + max: 0, countMin: 0, countMax: 0, timeScale: undefined, @@ -94,7 +79,8 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | return { data: [], xAxisCategories: [], - yAxisCategories: [], + min: 0, + max: 0, countMin: 0, countMax: 0, timeScale: undefined, @@ -114,17 +100,14 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | let countMin = Infinity; let countMax = -Infinity; - // eslint-disable-next-line @typescript-eslint/no-unused-vars 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; let lowerBoundFloat = parseFloat(lowerBound); const upperBoundFloat = parseFloat(upperBound); const countFloat = parseFloat(count); // For logarithmic scales, skip buckets that would be entirely non-positive - // Log scales cannot represent non-positive values, so we exclude any buckets where the upper bound is <= 0 if (logBase !== undefined && upperBoundFloat <= 0) { continue; } @@ -149,48 +132,12 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | } } - const height = contentDimensions?.height ?? HEATMAP_MIN_HEIGHT; - - // Calculate range and item size based on scale type - let totalRange: number; - let rangePerItem: number; - let totalItems: number; - - if (logBase !== undefined) { - // For logarithmic scale, work in log space - const logLowest = logTransform(lowestBound, logBase); - const logHighest = logTransform(highestBound, logBase); - totalRange = logHighest - logLowest; - totalItems = Math.ceil(height / HEATMAP_ITEM_MIN_HEIGHT); - rangePerItem = totalRange / totalItems; - } else { - // Linear scale (original behavior) - totalRange = highestBound - lowestBound; - rangePerItem = (totalRange * HEATMAP_ITEM_MIN_HEIGHT) / height; - 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) - // For log scale, we generate categories from log-transformed space but display original values - const yAxisCategories: string[] = Array.from({ length: totalItems }, (_, index) => { - if (logBase !== undefined) { - // Convert from log space back to linear space for display - const logLowest = logTransform(lowestBound, logBase); - const logValue = logLowest + index * rangePerItem; - const linearValue = Math.pow(logBase, logValue); - return linearValue.toFixed(3); - } else { - return (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; let lowerBoundFloat = parseFloat(lowerBound); const upperBoundFloat = parseFloat(upperBound); @@ -205,40 +152,48 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | lowerBoundFloat = getEffectiveLowerBound(lowerBoundFloat, upperBoundFloat, logBase); } - let yLowerBoundItem: number; - let yUpperBoundItem: number; - - if (logBase !== undefined) { - // Calculate Y positions in log space - const logLowest = logTransform(lowestBound, logBase); - const logLower = logTransform(lowerBoundFloat, logBase); - const logUpper = logTransform(upperBoundFloat, logBase); - yLowerBoundItem = Math.floor((logLower - logLowest) / rangePerItem); - yUpperBoundItem = Math.ceil((logUpper - logLowest) / rangePerItem); - } else { - yLowerBoundItem = Math.floor((lowerBoundFloat - lowestBound) / rangePerItem); - yUpperBoundItem = Math.ceil((upperBoundFloat - 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, - }); - } + 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, 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) { return ( @@ -270,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 42ce6dcb..0ea2ed71 100644 --- a/heatmapchart/src/heat-map-chart-model.ts +++ b/heatmapchart/src/heat-map-chart-model.ts @@ -48,6 +48,8 @@ export interface HeatMapChartOptions { countFormat?: FormatOptions; showVisualMap?: boolean; logBase?: LOG_BASE; + min?: number; + max?: number; } export type HeatMapChartOptionsEditorProps = OptionsEditorProps; From 7895ac8ac12b59e102d0aa0187a9fe4d96c47103 Mon Sep 17 00:00:00 2001 From: Akshay Iyyadurai Balasundaram Date: Sun, 8 Feb 2026 22:50:48 +0530 Subject: [PATCH 3/6] fix deprecated function + lint Signed-off-by: Akshay Iyyadurai Balasundaram --- heatmapchart/src/components/HeatMapChart.tsx | 7 ++++--- heatmapchart/src/components/HeatMapChartPanel.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/heatmapchart/src/components/HeatMapChart.tsx b/heatmapchart/src/components/HeatMapChart.tsx index 3a7ffb82..6ff7a583 100644 --- a/heatmapchart/src/components/HeatMapChart.tsx +++ b/heatmapchart/src/components/HeatMapChart.tsx @@ -18,9 +18,9 @@ import { use, EChartsCoreOption } from 'echarts/core'; 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'; -import { LOG_BASE } from '../heat-map-chart-model'; use([CustomChart]); @@ -169,11 +169,12 @@ export function HeatMapChart({ const width = nextX - startX; const height = bottomY - topY; - const style = api.style?.(); return { type: 'rect', shape: { x: startX, y: topY, width, height }, - style: style, + style: { + fill: api.visual('color'), + }, }; }, label: { show: false }, diff --git a/heatmapchart/src/components/HeatMapChartPanel.tsx b/heatmapchart/src/components/HeatMapChartPanel.tsx index 9945c88c..5203326b 100644 --- a/heatmapchart/src/components/HeatMapChartPanel.tsx +++ b/heatmapchart/src/components/HeatMapChartPanel.tsx @@ -100,9 +100,9 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | let countMin = Infinity; let countMax = -Infinity; - for (const [_, histogram] of series?.histograms ?? []) { + for (const [, histogram] of series?.histograms ?? []) { for (const bucket of histogram?.buckets ?? []) { - const [_, lowerBound, upperBound, count] = bucket; + const [, lowerBound, upperBound, count] = bucket; let lowerBoundFloat = parseFloat(lowerBound); const upperBoundFloat = parseFloat(upperBound); const countFloat = parseFloat(count); @@ -138,7 +138,7 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | const itemIndexOnXaxis = xAxisCategories.findIndex((v) => v === time * 1000); for (const bucket of histogram?.buckets ?? []) { - const [_, lowerBound, upperBound, count] = bucket; + const [, lowerBound, upperBound, count] = bucket; let lowerBoundFloat = parseFloat(lowerBound); const upperBoundFloat = parseFloat(upperBound); @@ -167,7 +167,7 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | countMax, timeScale, }; - }, [contentDimensions?.height, pluginSpec.logBase, 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) From 2c2581a979d76bad12fa947d62cf83185e4dbd65 Mon Sep 17 00:00:00 2001 From: Akshay Iyyadurai Balasundaram Date: Sun, 8 Feb 2026 23:13:17 +0530 Subject: [PATCH 4/6] fix: max without min value Signed-off-by: Akshay Iyyadurai Balasundaram --- heatmapchart/schemas/heatmap.cue | 5 ++++- heatmapchart/schemas/tests/valid/heatmap_only_max.json | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 heatmapchart/schemas/tests/valid/heatmap_only_max.json diff --git a/heatmapchart/schemas/heatmap.cue b/heatmapchart/schemas/heatmap.cue index bfb89e50..e635d660 100644 --- a/heatmapchart/schemas/heatmap.cue +++ b/heatmapchart/schemas/heatmap.cue @@ -24,6 +24,9 @@ spec: close({ // The visual map is an helper for highlighting cell with the targeted value showVisualMap?: bool min?: number - max?: number & >= min + max?: number + if min != _|_ && max != _|_ { + max: >= min + } logBase?: 2 | 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 + } +} From 7a141c5cd5fc4dbd6d705edfbabe9854206ead5c Mon Sep 17 00:00:00 2001 From: Akshay Iyyadurai Balasundaram Date: Sun, 8 Feb 2026 23:22:47 +0530 Subject: [PATCH 5/6] add min and max to heatmapchart sdk Signed-off-by: Akshay Iyyadurai Balasundaram --- heatmapchart/sdk/go/heatmap.go | 2 ++ heatmapchart/sdk/go/options.go | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/heatmapchart/sdk/go/heatmap.go b/heatmapchart/sdk/go/heatmap.go index a2008e0c..9477ddc7 100644 --- a/heatmapchart/sdk/go/heatmap.go +++ b/heatmapchart/sdk/go/heatmap.go @@ -24,6 +24,8 @@ 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"` } diff --git a/heatmapchart/sdk/go/options.go b/heatmapchart/sdk/go/options.go index faa77b99..1761c0b9 100644 --- a/heatmapchart/sdk/go/options.go +++ b/heatmapchart/sdk/go/options.go @@ -38,6 +38,20 @@ func ShowVisualMap(show bool) Option { } } +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 From ca5a5610b370ac1e47a12f70bfb371d9cd8255b5 Mon Sep 17 00:00:00 2001 From: Akshay Iyyadurai Balasundaram Date: Mon, 9 Feb 2026 00:05:26 +0530 Subject: [PATCH 6/6] fix overlapp y axis tick Signed-off-by: Akshay Iyyadurai Balasundaram --- heatmapchart/src/components/HeatMapChart.tsx | 29 +++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/heatmapchart/src/components/HeatMapChart.tsx b/heatmapchart/src/components/HeatMapChart.tsx index 6ff7a583..ce996ce4 100644 --- a/heatmapchart/src/components/HeatMapChart.tsx +++ b/heatmapchart/src/components/HeatMapChart.tsx @@ -12,8 +12,8 @@ // 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 { CustomChart } from 'echarts/charts'; import type { CustomSeriesRenderItemAPI, CustomSeriesRenderItemParams } from 'echarts'; @@ -110,15 +110,24 @@ export function HeatMapChart({ formatter: getFormattedHeatmapAxisLabel(timeScale?.rangeMs ?? 0, timeZone), }, }, - yAxis: getFormattedAxis( - { - type: logBase !== undefined ? 'log' : 'value', - logBase: logBase, - min: min, - max: max, + 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',