Skip to content
66 changes: 66 additions & 0 deletions components/src/utils/axis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { getFormattedMultipleYAxes, getFormattedMultipleYAxesLayout } from './axis';

beforeAll(() => {
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
configurable: true,
value: () => null,
});
});

describe('getFormattedMultipleYAxesLayout', () => {
it('returns only the left axis when there are no additional formats', () => {
const { axes, rightGridPadding } = getFormattedMultipleYAxesLayout({ show: true, min: 0 }, { unit: 'decimal' }, []);
expect(axes).toHaveLength(1);
expect(axes[0]?.position).toBe('left');
expect(rightGridPadding).toBe(20);
});

it('stacks right axes with increasing offset and grows rightGridPadding', () => {
const formats = [{ unit: 'ops/sec' as const }, { unit: 'decimal' as const }];
const { axes, rightGridPadding } = getFormattedMultipleYAxesLayout(
{ show: true },
{ unit: 'percent' },
formats,
[100, 1000],
);

expect(axes).toHaveLength(3);
expect(axes[0]?.position).toBe('left');
expect(axes[1]?.position).toBe('right');
expect(axes[1]?.offset).toBe(0);
expect(axes[2]?.position).toBe('right');
expect((axes[2]?.offset as number) ?? 0).toBeGreaterThan(0);
expect(rightGridPadding).toBeGreaterThan((axes[2]?.offset as number) ?? 0);
expect(rightGridPadding).toBeGreaterThanOrEqual(40);
});

it('still accumulates padding when maxValues is omitted', () => {
const { axes, rightGridPadding } = getFormattedMultipleYAxesLayout(undefined, { unit: 'decimal' }, [
{ unit: 'ops/sec' },
{ unit: 'events/sec' },
]);
expect(axes).toHaveLength(3);
expect(rightGridPadding).toBeGreaterThan(40);
});
});

describe('getFormattedMultipleYAxes', () => {
it('returns the axes array from the layout helper', () => {
const axes = getFormattedMultipleYAxes({ show: true }, { unit: 'decimal' }, [{ unit: 'ops/sec' }], [50]);
expect(axes).toHaveLength(2);
expect(axes[1]?.position).toBe('right');
});
});
93 changes: 50 additions & 43 deletions components/src/utils/axis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,34 @@ export interface YAxisConfig {
max?: number;
}

// Character width multipliers (approximate for typical UI fonts)
const CHAR_WIDTH_BASE = 6;
const AXIS_LABEL_PADDING = 10; // Extra padding to avoid label clipping
/** Average width of one character at the 12px axis font, when canvas measurement is unavailable. */
const CHAR_WIDTH_BASE = 7;
/** Extra pixels after each right-axis label so the next axis does not sit on the tick. */
const AXIS_LABEL_PADDING = 16;
/** Four characters, so a short tick ("0", "8%") still clears the tick before the next axis. */
const MIN_AXIS_LABEL_WIDTH = CHAR_WIDTH_BASE * 4;
/** Placeholder max when series data has not produced a max yet (keeps first layout stable). */
const DEFAULT_AXIS_MAX_VALUE = 1000;
/** grid.right when there are no additional right axes (single Y-axis chart). */
const DEFAULT_RIGHT_GRID_PADDING = 20;

/**
* Estimate the pixel width needed for an axis label using Canvas API.
*/
function estimateLabelWidth(format: FormatOptions | undefined, maxValue: number): number {
const formattedLabel = formatValue(maxValue, format);
// Create a canvas element (reuse if possible for performance)
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
// Fallback to estimation if canvas not available
return formattedLabel.length * CHAR_WIDTH_BASE;
const fallbackLabelWidth = Math.max(formattedLabel.length * CHAR_WIDTH_BASE, MIN_AXIS_LABEL_WIDTH);
if (typeof document === 'undefined') {
return fallbackLabelWidth;
}
try {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
return fallbackLabelWidth;
}
context.font = '12px sans-serif';
return Math.max(context.measureText(formattedLabel).width, MIN_AXIS_LABEL_WIDTH);
} catch {
return fallbackLabelWidth;
}
context.font = '12px sans-serif';
const metrics = context.measureText(formattedLabel);
return metrics.width;
}

/*
Expand All @@ -62,25 +71,19 @@ export function getFormattedAxis(axis?: YAXisComponentOption | XAXisComponentOpt
return [merge(AXIS_DEFAULT, axis)];
}

/**
* Create multiple Y axes configurations for ECharts
* The first axis (index 0) is always on the left side (default axis from panel settings)
* Additional axes are placed on the right side
*
* @param baseAxis - Base axis configuration from panel settings
* @param baseFormat - Format for the base/default Y axis
* @param additionalFormats - Array of formats for additional right-side Y axes
* @param maxValues - Optional array of max values for each additional format (used to compute dynamic label widths)
*/
export function getFormattedMultipleYAxes(
export interface MultipleYAxesLayout {
axes: YAXisComponentOption[];
rightGridPadding: number;
}

export function getFormattedMultipleYAxesLayout(
baseAxis: YAXisComponentOption | undefined,
baseFormat: FormatOptions | undefined,
additionalFormats: FormatOptions[],
maxValues?: number[],
): YAXisComponentOption[] {
): MultipleYAxesLayout {
const axes: YAXisComponentOption[] = [];

// Base/default Y axis (left side)
const baseAxisConfig: YAXisComponentOption = merge(
{
type: 'value',
Expand All @@ -90,41 +93,45 @@ export function getFormattedMultipleYAxes(
formatter: (value: number): string => {
return formatValue(value, baseFormat);
},
// Let ECharts handle width automatically
overflow: 'truncate',
},
},
baseAxis,
);
axes.push(baseAxisConfig);

// Calculate cumulative offsets based on actual formatted label widths
let cumulativeOffset = 0;

// Additional Y axes (right side) for each unique format
additionalFormats.forEach((format, index) => {
const rightAxisConfig: YAXisComponentOption = {
const labelWidth = estimateLabelWidth(format, maxValues?.[index] ?? DEFAULT_AXIS_MAX_VALUE) + AXIS_LABEL_PADDING;
axes.push({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid using magic number.

type: 'value',
position: 'right',
// Dynamic offset based on cumulative width of preceding axis labels
offset: cumulativeOffset,
boundaryGap: [0, '10%'],
axisLabel: {
formatter: (value: number): string => {
return formatValue(value, format);
},
hideOverlap: true,
},
splitLine: {
show: false, // Hide grid lines for right-side axes to reduce visual noise
show: false,
},
show: baseAxis?.show,
};
axes.push(rightAxisConfig);
// For subsequent axes, add the width of the previous axis's labels
if (maxValues) {
cumulativeOffset += estimateLabelWidth(format, maxValues[index] ?? 1000) + AXIS_LABEL_PADDING;
}
});
cumulativeOffset += labelWidth;
});

return axes;
return {
axes,
rightGridPadding: cumulativeOffset > 0 ? cumulativeOffset : DEFAULT_RIGHT_GRID_PADDING,
};
}

export function getFormattedMultipleYAxes(
baseAxis: YAXisComponentOption | undefined,
baseFormat: FormatOptions | undefined,
additionalFormats: FormatOptions[],
maxValues?: number[],
): YAXisComponentOption[] {
return getFormattedMultipleYAxesLayout(baseAxis, baseFormat, additionalFormats, maxValues).axes;
}
Loading