diff --git a/components/src/utils/axis.test.ts b/components/src/utils/axis.test.ts new file mode 100644 index 00000000..5675e1bb --- /dev/null +++ b/components/src/utils/axis.test.ts @@ -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'); + }); +}); diff --git a/components/src/utils/axis.ts b/components/src/utils/axis.ts index f46fa5eb..784103ef 100644 --- a/components/src/utils/axis.ts +++ b/components/src/utils/axis.ts @@ -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; } /* @@ -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', @@ -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({ 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; }