From 426663285064b56871763b2748aa2b86a71170f6 Mon Sep 17 00:00:00 2001 From: colivi Date: Fri, 25 Sep 2026 22:05:26 +0200 Subject: [PATCH 1/6] fix(components): reserve grid.right for multi Y-axis label stack ECharts containLabel does not cover right axes with offset>0, so the outermost axis tick labels were clipped. Return rightGridPadding from layout helper and always accumulate per-axis label widths. Signed-off-by: colivi --- components/src/utils/axis.ts | 73 ++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/components/src/utils/axis.ts b/components/src/utils/axis.ts index f46fa5eb..332283de 100644 --- a/components/src/utils/axis.ts +++ b/components/src/utils/axis.ts @@ -26,24 +26,21 @@ export interface YAxisConfig { } // Character width multipliers (approximate for typical UI fonts) -const CHAR_WIDTH_BASE = 6; -const AXIS_LABEL_PADDING = 10; // Extra padding to avoid label clipping +const CHAR_WIDTH_BASE = 7; +const AXIS_LABEL_PADDING = 16; -/** - * 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) + if (typeof document === 'undefined') { + return Math.max(formattedLabel.length * CHAR_WIDTH_BASE, 28); + } 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; + return Math.max(formattedLabel.length * CHAR_WIDTH_BASE, 28); } context.font = '12px sans-serif'; - const metrics = context.measureText(formattedLabel); - return metrics.width; + return Math.max(context.measureText(formattedLabel).width, 28); } /* @@ -62,25 +59,24 @@ export function getFormattedAxis(axis?: YAXisComponentOption | XAXisComponentOpt return [merge(AXIS_DEFAULT, axis)]; } +export interface MultipleYAxesLayout { + axes: YAXisComponentOption[]; + /** Pixels for ECharts grid.right so the outermost right-axis labels are not clipped. */ + rightGridPadding: number; +} + /** - * 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) + * Create multiple Y axes for ECharts (left base + right additional) and the grid.right + * padding required so the last right axis labels are fully visible. */ -export function getFormattedMultipleYAxes( +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 +86,46 @@ 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] ?? 1000) + 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, + // Full stack of right-axis label bands (containLabel alone does not cover offset>0 axes). + rightGridPadding: cumulativeOffset > 0 ? cumulativeOffset : 20, + }; +} + +export function getFormattedMultipleYAxes( + baseAxis: YAXisComponentOption | undefined, + baseFormat: FormatOptions | undefined, + additionalFormats: FormatOptions[], + maxValues?: number[], +): YAXisComponentOption[] { + return getFormattedMultipleYAxesLayout(baseAxis, baseFormat, additionalFormats, maxValues).axes; } From b64e26cb8d85375ba2152b046da4593c18252737 Mon Sep 17 00:00:00 2001 From: colivi Date: Fri, 25 Sep 2026 22:06:27 +0200 Subject: [PATCH 2/6] test(components): cover multi Y-axis layout and rightGridPadding Signed-off-by: colivi --- components/src/utils/axis.test.ts | 65 +++++++++++++++++++++++++++++++ components/src/utils/axis.ts | 19 +++++---- 2 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 components/src/utils/axis.test.ts diff --git a/components/src/utils/axis.test.ts b/components/src/utils/axis.test.ts new file mode 100644 index 00000000..770206eb --- /dev/null +++ b/components/src/utils/axis.test.ts @@ -0,0 +1,65 @@ +// 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'; + +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); + + // Full label stack must be reserved on grid.right (outer axis not clipped). + 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 332283de..f388ebe2 100644 --- a/components/src/utils/axis.ts +++ b/components/src/utils/axis.ts @@ -31,16 +31,21 @@ const AXIS_LABEL_PADDING = 16; function estimateLabelWidth(format: FormatOptions | undefined, maxValue: number): number { const formattedLabel = formatValue(maxValue, format); + const fallback = Math.max(formattedLabel.length * CHAR_WIDTH_BASE, 28); if (typeof document === 'undefined') { - return Math.max(formattedLabel.length * CHAR_WIDTH_BASE, 28); + return fallback; } - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - if (!context) { - return Math.max(formattedLabel.length * CHAR_WIDTH_BASE, 28); + try { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) { + return fallback; + } + context.font = '12px sans-serif'; + return Math.max(context.measureText(formattedLabel).width, 28); + } catch { + return fallback; } - context.font = '12px sans-serif'; - return Math.max(context.measureText(formattedLabel).width, 28); } /* From 6890f1201d991e711430756c9e34c58d2d866882 Mon Sep 17 00:00:00 2001 From: colivi Date: Fri, 25 Sep 2026 22:06:44 +0200 Subject: [PATCH 3/6] test(components): silence jsdom canvas in multi-Y axis tests Signed-off-by: colivi --- components/src/utils/axis.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/components/src/utils/axis.test.ts b/components/src/utils/axis.test.ts index 770206eb..06b2aaa7 100644 --- a/components/src/utils/axis.test.ts +++ b/components/src/utils/axis.test.ts @@ -13,6 +13,14 @@ import { getFormattedMultipleYAxes, getFormattedMultipleYAxesLayout } from './axis'; +beforeAll(() => { + // jsdom has no canvas metrics; force the length-based fallback path. + 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( From 6e03275b24d948de18498c63f470208d9d76d5a5 Mon Sep 17 00:00:00 2001 From: colivi Date: Fri, 25 Sep 2026 22:07:16 +0200 Subject: [PATCH 4/6] style(components): drop verbose comments on multi-Y axis fix Signed-off-by: colivi --- components/src/utils/axis.test.ts | 9 +-------- components/src/utils/axis.ts | 7 ------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/components/src/utils/axis.test.ts b/components/src/utils/axis.test.ts index 06b2aaa7..5675e1bb 100644 --- a/components/src/utils/axis.test.ts +++ b/components/src/utils/axis.test.ts @@ -14,7 +14,6 @@ import { getFormattedMultipleYAxes, getFormattedMultipleYAxesLayout } from './axis'; beforeAll(() => { - // jsdom has no canvas metrics; force the length-based fallback path. Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { configurable: true, value: () => null, @@ -23,11 +22,7 @@ beforeAll(() => { describe('getFormattedMultipleYAxesLayout', () => { it('returns only the left axis when there are no additional formats', () => { - const { axes, rightGridPadding } = getFormattedMultipleYAxesLayout( - { show: true, min: 0 }, - { unit: 'decimal' }, - [], - ); + const { axes, rightGridPadding } = getFormattedMultipleYAxesLayout({ show: true, min: 0 }, { unit: 'decimal' }, []); expect(axes).toHaveLength(1); expect(axes[0]?.position).toBe('left'); expect(rightGridPadding).toBe(20); @@ -48,8 +43,6 @@ describe('getFormattedMultipleYAxesLayout', () => { expect(axes[1]?.offset).toBe(0); expect(axes[2]?.position).toBe('right'); expect((axes[2]?.offset as number) ?? 0).toBeGreaterThan(0); - - // Full label stack must be reserved on grid.right (outer axis not clipped). expect(rightGridPadding).toBeGreaterThan((axes[2]?.offset as number) ?? 0); expect(rightGridPadding).toBeGreaterThanOrEqual(40); }); diff --git a/components/src/utils/axis.ts b/components/src/utils/axis.ts index f388ebe2..25123f14 100644 --- a/components/src/utils/axis.ts +++ b/components/src/utils/axis.ts @@ -25,7 +25,6 @@ export interface YAxisConfig { max?: number; } -// Character width multipliers (approximate for typical UI fonts) const CHAR_WIDTH_BASE = 7; const AXIS_LABEL_PADDING = 16; @@ -66,14 +65,9 @@ export function getFormattedAxis(axis?: YAXisComponentOption | XAXisComponentOpt export interface MultipleYAxesLayout { axes: YAXisComponentOption[]; - /** Pixels for ECharts grid.right so the outermost right-axis labels are not clipped. */ rightGridPadding: number; } -/** - * Create multiple Y axes for ECharts (left base + right additional) and the grid.right - * padding required so the last right axis labels are fully visible. - */ export function getFormattedMultipleYAxesLayout( baseAxis: YAXisComponentOption | undefined, baseFormat: FormatOptions | undefined, @@ -121,7 +115,6 @@ export function getFormattedMultipleYAxesLayout( return { axes, - // Full stack of right-axis label bands (containLabel alone does not cover offset>0 axes). rightGridPadding: cumulativeOffset > 0 ? cumulativeOffset : 20, }; } From a0aa1246bb92a343db2da830557227ed88d4402d Mon Sep 17 00:00:00 2001 From: colivi Date: Mon, 28 Sep 2026 11:12:27 +0200 Subject: [PATCH 5/6] fix(components): name multi-Y axis layout magic numbers Rename fallback width and extract MIN_AXIS_LABEL_WIDTH, DEFAULT_AXIS_MAX_VALUE, and DEFAULT_RIGHT_GRID_PADDING. Signed-off-by: colivi --- components/src/utils/axis.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/components/src/utils/axis.ts b/components/src/utils/axis.ts index 25123f14..a51e3069 100644 --- a/components/src/utils/axis.ts +++ b/components/src/utils/axis.ts @@ -27,23 +27,29 @@ export interface YAxisConfig { const CHAR_WIDTH_BASE = 7; const AXIS_LABEL_PADDING = 16; +/** Minimum width so short labels (e.g. "0", "1.2") still leave room for axis ticks. */ +const MIN_AXIS_LABEL_WIDTH = 28; +/** 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; function estimateLabelWidth(format: FormatOptions | undefined, maxValue: number): number { const formattedLabel = formatValue(maxValue, format); - const fallback = Math.max(formattedLabel.length * CHAR_WIDTH_BASE, 28); + const fallbackLabelWidth = Math.max(formattedLabel.length * CHAR_WIDTH_BASE, MIN_AXIS_LABEL_WIDTH); if (typeof document === 'undefined') { - return fallback; + return fallbackLabelWidth; } try { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) { - return fallback; + return fallbackLabelWidth; } context.font = '12px sans-serif'; - return Math.max(context.measureText(formattedLabel).width, 28); + return Math.max(context.measureText(formattedLabel).width, MIN_AXIS_LABEL_WIDTH); } catch { - return fallback; + return fallbackLabelWidth; } } @@ -93,7 +99,7 @@ export function getFormattedMultipleYAxesLayout( let cumulativeOffset = 0; additionalFormats.forEach((format, index) => { - const labelWidth = estimateLabelWidth(format, maxValues?.[index] ?? 1000) + AXIS_LABEL_PADDING; + const labelWidth = estimateLabelWidth(format, maxValues?.[index] ?? DEFAULT_AXIS_MAX_VALUE) + AXIS_LABEL_PADDING; axes.push({ type: 'value', position: 'right', @@ -115,7 +121,7 @@ export function getFormattedMultipleYAxesLayout( return { axes, - rightGridPadding: cumulativeOffset > 0 ? cumulativeOffset : 20, + rightGridPadding: cumulativeOffset > 0 ? cumulativeOffset : DEFAULT_RIGHT_GRID_PADDING, }; } From daa86aa79f61d9a2a408ca4da8446fae9425468a Mon Sep 17 00:00:00 2001 From: colivi Date: Fri, 2 Oct 2026 17:12:36 +0200 Subject: [PATCH 6/6] fix(components): derive the minimum right-axis label width MIN_AXIS_LABEL_WIDTH is four average characters, which answers the review note on the previous literal 28. 1000 and 20 stay named DEFAULT_AXIS_MAX_VALUE and DEFAULT_RIGHT_GRID_PADDING. Signed-off-by: colivi --- components/src/utils/axis.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/components/src/utils/axis.ts b/components/src/utils/axis.ts index a51e3069..784103ef 100644 --- a/components/src/utils/axis.ts +++ b/components/src/utils/axis.ts @@ -25,10 +25,12 @@ export interface YAxisConfig { max?: number; } +/** 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; -/** Minimum width so short labels (e.g. "0", "1.2") still leave room for axis ticks. */ -const MIN_AXIS_LABEL_WIDTH = 28; +/** 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). */