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
86 changes: 86 additions & 0 deletions components/src/utils/axis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// 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('leaves a single right axis to containLabel', () => {
const { axes, rightGridPadding } = getFormattedMultipleYAxesLayout({ show: true }, { unit: 'percent' }, [
{ unit: 'bytes' },
]);
expect(axes).toHaveLength(2);
expect(axes[1]?.offset).toBe(0);
expect(rightGridPadding).toBe(20);
});

it('reserves grid.right only for right axes after the first', () => {
const opsPerSec = { unit: 'ops/sec' as const };
const decimal = { unit: 'decimal' as const };
const formats = [opsPerSec, decimal];
const firstMax = 100;
const secondMax = 1000;
const { axes, rightGridPadding } = getFormattedMultipleYAxesLayout({ show: true }, { unit: 'percent' }, formats, [
firstMax,
secondMax,
]);
const secondAxisWidth = getFormattedMultipleYAxesLayout(
{ show: true },
{ unit: 'percent' },
[decimal, opsPerSec],
[secondMax, firstMax],
).axes[2]?.offset;

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).toBe(secondAxisWidth);
expect(rightGridPadding).toBeLessThan(((axes[2]?.offset as number) ?? 0) + (secondAxisWidth as number));
});

it('still reserves only the outer axis when maxValues is omitted', () => {
const opsPerSec = { unit: 'ops/sec' as const };
const eventsPerSec = { unit: 'events/sec' as const };
const formats = [opsPerSec, eventsPerSec];
const { axes, rightGridPadding } = getFormattedMultipleYAxesLayout(undefined, { unit: 'decimal' }, formats);
const secondAxisWidth = getFormattedMultipleYAxesLayout(undefined, { unit: 'decimal' }, [eventsPerSec, opsPerSec])
.axes[2]?.offset;
expect(axes).toHaveLength(3);
expect(rightGridPadding).toBe(secondAxisWidth);
expect(rightGridPadding).toBeGreaterThan(20);
});
});

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');
});
});
96 changes: 54 additions & 42 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,20 @@ 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[];
/** Extra grid.right for right axes after the first. containLabel covers the first. */
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 +94,49 @@ 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
let outerAxesWidth = 0;
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;
if (index > 0) {
outerAxesWidth += labelWidth;
}
});

return axes;
return {
axes,
rightGridPadding: additionalFormats.length > 1 ? outerAxesWidth : 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