From ab58becb35d0b78b2874024fb16189485e0a99fc Mon Sep 17 00:00:00 2001 From: AntoineThebaud Date: Thu, 15 Jan 2026 16:10:42 +0100 Subject: [PATCH 01/10] [FEATURE] Enable multiple Y axis Signed-off-by: AntoineThebaud --- .../src/FormatControls/UnitSelector.tsx | 58 ++++++++++++ components/src/FormatControls/index.ts | 1 + .../TimeSeriesTooltip/TimeChartTooltip.tsx | 6 ++ .../src/TimeSeriesTooltip/nearby-series.ts | 93 ++++++++++++++++--- components/src/utils/axis.ts | 89 +++++++++++++++++- 5 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 components/src/FormatControls/UnitSelector.tsx diff --git a/components/src/FormatControls/UnitSelector.tsx b/components/src/FormatControls/UnitSelector.tsx new file mode 100644 index 0000000..cfa3889 --- /dev/null +++ b/components/src/FormatControls/UnitSelector.tsx @@ -0,0 +1,58 @@ +// 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 { FormatOptions, UNIT_CONFIG, UnitConfig } from '@perses-dev/core'; +import { ReactElement } from 'react'; +import { SettingsAutocomplete } from '../SettingsAutocomplete'; + +export interface UnitSelectorProps { + value?: FormatOptions; + onChange: (format: FormatOptions | undefined) => void; + disabled?: boolean; +} + +type AutocompleteUnitOption = UnitConfig & { + id: NonNullable; +}; + +const KIND_OPTIONS: readonly AutocompleteUnitOption[] = Object.entries(UNIT_CONFIG) + .map(([id, config]) => { + return { + ...config, + id: id as AutocompleteUnitOption['id'], + group: config.group || 'Decimal', + }; + }) + .filter((config) => !config.disableSelectorOption); + +export function UnitSelector({ value, onChange, disabled = false }: UnitSelectorProps): ReactElement { + const unitConfig = UNIT_CONFIG[value?.unit || 'decimal']; + + const handleChange = (_: unknown, newValue: AutocompleteUnitOption | null): void => { + if (newValue === null) { + onChange(undefined); + } else { + onChange({ unit: newValue.id } as FormatOptions); + } + }; + + return ( + + value={value ? { id: value.unit || 'decimal', ...unitConfig } : null} + options={KIND_OPTIONS} + groupBy={(option) => option.group ?? 'Decimal'} + onChange={handleChange} + disabled={disabled} + /> + ); +} diff --git a/components/src/FormatControls/index.ts b/components/src/FormatControls/index.ts index 28751e4..f84ff28 100644 --- a/components/src/FormatControls/index.ts +++ b/components/src/FormatControls/index.ts @@ -12,3 +12,4 @@ // limitations under the License. export * from './FormatControls'; +export * from './UnitSelector'; diff --git a/components/src/TimeSeriesTooltip/TimeChartTooltip.tsx b/components/src/TimeSeriesTooltip/TimeChartTooltip.tsx index 5793865..a0b13e7 100644 --- a/components/src/TimeSeriesTooltip/TimeChartTooltip.tsx +++ b/components/src/TimeSeriesTooltip/TimeChartTooltip.tsx @@ -36,6 +36,10 @@ export interface TimeChartTooltipProps { containerId?: string; onUnpinClick?: () => void; format?: FormatOptions; + /** + * Map of series ID to format options for per-series formatting (used with multiple Y axes) + */ + seriesFormatMap?: Map; wrapLabels?: boolean; } @@ -47,6 +51,7 @@ export const TimeChartTooltip = memo(function TimeChartTooltip({ enablePinning = true, wrapLabels, format, + seriesFormatMap, onUnpinClick, pinnedPos, }: TimeChartTooltipProps) { @@ -79,6 +84,7 @@ export const TimeChartTooltip = memo(function TimeChartTooltip({ pinnedPos, chart, format, + seriesFormatMap, showAllSeries, }); if (nearbySeries.length === 0) { diff --git a/components/src/TimeSeriesTooltip/nearby-series.ts b/components/src/TimeSeriesTooltip/nearby-series.ts index fa21070..3fa5f73 100644 --- a/components/src/TimeSeriesTooltip/nearby-series.ts +++ b/components/src/TimeSeriesTooltip/nearby-series.ts @@ -19,8 +19,8 @@ import { batchDispatchNearbySeriesActions, getPointInGrid, getClosestTimestamp } import { CursorCoordinates, CursorData, EMPTY_TOOLTIP_DATA } from './tooltip-model'; // increase multipliers to show more series in tooltip -export const INCREASE_NEARBY_SERIES_MULTIPLIER = 5.5; // adjusts how many series show in tooltip (higher == more series shown) -export const DYNAMIC_NEARBY_SERIES_MULTIPLIER = 30; // used for adjustment after series number divisor +export const INCREASE_NEARBY_SERIES_MULTIPLIER = 2.5; // adjusts how many series show in tooltip (higher == more series shown) +export const DYNAMIC_NEARBY_SERIES_MULTIPLIER = 15; // used for adjustment after series number divisor export const SHOW_FEWER_SERIES_LIMIT = 5; export interface NearbySeriesInfo { @@ -47,7 +47,9 @@ export function checkforNearbyTimeSeries( pointInGrid: number[], yBuffer: number, chart: EChartsInstance, - format?: FormatOptions + format?: FormatOptions, + seriesFormatMap?: Map, + cursorPixelY?: number ): NearbySeriesArray { const currentNearbySeriesData: NearbySeriesArray = []; const cursorX: number | null = pointInGrid[0] ?? null; @@ -76,6 +78,18 @@ export function checkforNearbyTimeSeries( return EMPTY_TOOLTIP_DATA; } + // For multi-axis support: convert yBuffer to pixel space for consistent comparison + // This allows us to compare series on different Y axes fairly + let yBufferPixels: number | null = null; + if (cursorPixelY !== undefined) { + // Convert a point at cursorY and cursorY + yBuffer to pixels to get the buffer in pixel space + const cursorPoint = chart.convertToPixel('grid', [0, cursorY]); + const bufferPoint = chart.convertToPixel('grid', [0, cursorY + yBuffer]); + if (cursorPoint && bufferPoint && cursorPoint[1] !== undefined && bufferPoint[1] !== undefined) { + yBufferPixels = Math.abs(bufferPoint[1] - cursorPoint[1]); + } + } + // find the timestamp with data that is closest to cursorX for (let seriesIdx = 0; seriesIdx < totalSeries; seriesIdx++) { const currentSeries = seriesMapping[seriesIdx]; @@ -88,7 +102,12 @@ export function checkforNearbyTimeSeries( if (currentDatasetValues === undefined || !Array.isArray(currentDatasetValues)) break; const lineSeries = currentSeries as LineSeriesOption; const currentSeriesName = lineSeries.name ? lineSeries.name.toString() : ''; + const seriesId = lineSeries.id ? lineSeries.id.toString() : ''; const markerColor = lineSeries.color ?? '#000'; + + // Get the format for this series (from seriesFormatMap or fallback to default format) + const seriesFormat = seriesFormatMap?.get(seriesId) ?? format; + if (Array.isArray(data)) { for (let datumIdx = 0; datumIdx < currentDatasetValues.length; datumIdx++) { const nearbyTimeSeries = currentDatasetValues[datumIdx]; @@ -99,15 +118,53 @@ export function checkforNearbyTimeSeries( // TODO: ensure null values not displayed in tooltip if (yValue !== undefined && yValue !== null) { if (closestTimestamp === xValue) { - if (cursorY <= yValue + yBuffer && cursorY >= yValue - yBuffer) { + // Check if this series is nearby the cursor + let isNearby = false; + + // For multi-axis: compare in pixel space + if (cursorPixelY !== undefined && yBufferPixels !== null) { + const dataPointPixel = chart.convertToPixel({ seriesIndex: seriesIdx }, [datumIdx, yValue]); + if (dataPointPixel && dataPointPixel[1] !== undefined) { + const pixelDistance = Math.abs(cursorPixelY - dataPointPixel[1]); + isNearby = pixelDistance <= yBufferPixels; + } else { + // Fallback to data-space comparison for primary axis + isNearby = cursorY <= yValue + yBuffer && cursorY >= yValue - yBuffer; + } + } else { + // Fallback to original data-space comparison + isNearby = cursorY <= yValue + yBuffer && cursorY >= yValue - yBuffer; + } + + if (isNearby) { // show fewer bold series in tooltip when many total series const minPercentRange = totalSeries > SHOW_FEWER_SERIES_LIMIT ? 2 : 5; const percentRangeToCheck = Math.max(minPercentRange, 100 / totalSeries); - const isClosestToCursor = isWithinPercentageRange({ - valueToCheck: cursorY, - baseValue: yValue, - percentage: percentRangeToCheck, - }); + + // For isClosestToCursor, also use pixel space for multi-axis + let isClosestToCursor = false; + if (cursorPixelY !== undefined) { + const dataPointPixel = chart.convertToPixel({ seriesIndex: seriesIdx }, [datumIdx, yValue]); + if (dataPointPixel && dataPointPixel[1] !== undefined) { + const pixelDistance = Math.abs(cursorPixelY - dataPointPixel[1]); + // Use percentage of buffer for "closest" determination + const tightBufferPixels = (yBufferPixels ?? 50) * (percentRangeToCheck / 100); + isClosestToCursor = pixelDistance <= Math.max(tightBufferPixels, 5); + } else { + isClosestToCursor = isWithinPercentageRange({ + valueToCheck: cursorY, + baseValue: yValue, + percentage: percentRangeToCheck, + }); + } + } else { + isClosestToCursor = isWithinPercentageRange({ + valueToCheck: cursorY, + baseValue: yValue, + percentage: percentRangeToCheck, + }); + } + if (isClosestToCursor) { // shows as bold in tooltip, customize 'emphasis' options in getTimeSeries util emphasizedSeriesIndexes.push(seriesIdx); @@ -140,7 +197,7 @@ export function checkforNearbyTimeSeries( seriesIndex: seriesIdx, }); } - const formattedY = formatValue(yValue, format); + const formattedY = formatValue(yValue, seriesFormat); currentNearbySeriesData.push({ seriesIdx: seriesIdx, datumIdx: datumIdx, @@ -294,6 +351,7 @@ export function getNearbySeriesData({ seriesMapping, chart, format, + seriesFormatMap, showAllSeries = false, }: { mousePos: CursorData['coords']; @@ -302,6 +360,7 @@ export function getNearbySeriesData({ seriesMapping: TimeChartSeriesMapping; chart?: EChartsInstance; format?: FormatOptions; + seriesFormatMap?: Map; showAllSeries?: boolean; }): NearbySeriesArray { if (chart === undefined || mousePos === null) return EMPTY_TOOLTIP_DATA; @@ -332,7 +391,8 @@ export function getNearbySeriesData({ // mousemove position undefined when not hovering over chart canvas if (mousePos.plotCanvas.x === undefined || mousePos.plotCanvas.y === undefined) return EMPTY_TOOLTIP_DATA; - const pointInGrid = getPointInGrid(mousePos.plotCanvas.x, mousePos.plotCanvas.y, chart); + const cursorPixelY = mousePos.plotCanvas.y; + const pointInGrid = getPointInGrid(mousePos.plotCanvas.x, cursorPixelY, chart); if (pointInGrid !== null) { const chartModel = chart['_model']; const yAxisScale = chartModel.getComponent('yAxis').axis.scale; @@ -351,7 +411,16 @@ export function getNearbySeriesData({ } const totalSeries = data.length; const yBuffer = getYBuffer({ yInterval, totalSeries, showAllSeries }); - return checkforNearbyTimeSeries(data, seriesMapping, pointInGrid, yBuffer, chart, format); + return checkforNearbyTimeSeries( + data, + seriesMapping, + pointInGrid, + yBuffer, + chart, + format, + seriesFormatMap, + cursorPixelY + ); } // no nearby series found diff --git a/components/src/utils/axis.ts b/components/src/utils/axis.ts index 7072690..e89b365 100644 --- a/components/src/utils/axis.ts +++ b/components/src/utils/axis.ts @@ -15,11 +15,32 @@ import merge from 'lodash/merge'; import type { XAXisComponentOption, YAXisComponentOption } from 'echarts'; import { formatValue, FormatOptions } from '@perses-dev/core'; +export interface YAxisConfig { + format?: FormatOptions; + position?: 'left' | 'right'; + show?: boolean; + min?: number; + max?: number; +} + +// Average character width in pixels (approximate for typical UI fonts) +const AVG_CHAR_WIDTH = 7; +// Base padding for axis labels (spacing, axis line, etc.) +const AXIS_LABEL_PADDING = 12; + +/** + * Estimate the pixel width needed for an axis label based on the formatted max value. + * This provides dynamic spacing that adapts to the actual data scale. + */ +function estimateLabelWidth(format: FormatOptions | undefined, maxValue: number): number { + const formattedLabel = formatValue(maxValue, format); + return formattedLabel.length * AVG_CHAR_WIDTH + AXIS_LABEL_PADDING; +} + /* * Populate yAxis or xAxis properties, returns an Array since multiple axes will be supported in the future */ export function getFormattedAxis(axis?: YAXisComponentOption | XAXisComponentOption, unit?: FormatOptions): unknown[] { - // TODO: support alternate yAxis that shows on right side const AXIS_DEFAULT = { type: 'value', boundaryGap: [0, '10%'], @@ -31,3 +52,69 @@ 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( + baseAxis: YAXisComponentOption | undefined, + baseFormat: FormatOptions | undefined, + additionalFormats: FormatOptions[], + maxValues?: number[] +): YAXisComponentOption[] { + const axes: YAXisComponentOption[] = []; + + // Base/default Y axis (left side) + const baseAxisConfig: YAXisComponentOption = merge( + { + type: 'value', + position: 'left', + boundaryGap: [0, '10%'], + axisLabel: { + formatter: (value: number): string => { + return formatValue(value, baseFormat); + }, + }, + }, + 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) => { + // For subsequent axes, add the width of the previous axis's labels + if (index > 0 && maxValues) { + const prevMaxValue = maxValues[index - 1] ?? 1000; + cumulativeOffset += estimateLabelWidth(additionalFormats[index - 1], prevMaxValue); + } + + const rightAxisConfig: YAXisComponentOption = { + 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); + }, + }, + splitLine: { + show: false, // Hide grid lines for right-side axes to reduce visual noise + }, + }; + axes.push(rightAxisConfig); + }); + + return axes; +} From 2443910dba3b5fbac3c070829085ac744310a885 Mon Sep 17 00:00:00 2001 From: AntoineThebaud Date: Tue, 20 Jan 2026 22:52:40 +0100 Subject: [PATCH 02/10] fix to make Y axis > show property apply to all defined Y axes Signed-off-by: AntoineThebaud --- components/src/utils/axis.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/components/src/utils/axis.ts b/components/src/utils/axis.ts index e89b365..f4b8b94 100644 --- a/components/src/utils/axis.ts +++ b/components/src/utils/axis.ts @@ -112,6 +112,7 @@ export function getFormattedMultipleYAxes( splitLine: { show: false, // Hide grid lines for right-side axes to reduce visual noise }, + show: baseAxis?.show, }; axes.push(rightAxisConfig); }); From c3bfb29ac157bc2c0457082144287d4047c6f17b Mon Sep 17 00:00:00 2001 From: AntoineThebaud Date: Tue, 20 Jan 2026 23:52:16 +0100 Subject: [PATCH 03/10] fix deprecated comment Signed-off-by: AntoineThebaud --- components/src/utils/axis.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/src/utils/axis.ts b/components/src/utils/axis.ts index f4b8b94..669f3a7 100644 --- a/components/src/utils/axis.ts +++ b/components/src/utils/axis.ts @@ -38,7 +38,7 @@ function estimateLabelWidth(format: FormatOptions | undefined, maxValue: number) } /* - * Populate yAxis or xAxis properties, returns an Array since multiple axes will be supported in the future + * Populate yAxis or xAxis properties, returns an Array since multiple axes are supported */ export function getFormattedAxis(axis?: YAXisComponentOption | XAXisComponentOption, unit?: FormatOptions): unknown[] { const AXIS_DEFAULT = { From edc8178de12901a426f17321699e499d61ce8c77 Mon Sep 17 00:00:00 2001 From: AntoineThebaud Date: Wed, 21 Jan 2026 09:10:58 +0100 Subject: [PATCH 04/10] Refactor FormatControls to rely on the new UnitSelector Signed-off-by: AntoineThebaud --- .../src/FormatControls/FormatControls.tsx | 34 +++---------------- .../src/TimeSeriesTooltip/nearby-series.ts | 1 + 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/components/src/FormatControls/FormatControls.tsx b/components/src/FormatControls/FormatControls.tsx index faf3bea..315eaee 100644 --- a/components/src/FormatControls/FormatControls.tsx +++ b/components/src/FormatControls/FormatControls.tsx @@ -13,8 +13,6 @@ import { Switch, SwitchProps } from '@mui/material'; import { FormatOptions, - UNIT_CONFIG, - UnitConfig, isUnitWithDecimalPlaces, isUnitWithShortValues, shouldShortenValues, @@ -22,6 +20,7 @@ import { import { ReactElement } from 'react'; import { OptionsEditorControl } from '../OptionsEditorLayout'; import { SettingsAutocomplete } from '../SettingsAutocomplete'; +import { UnitSelector } from './UnitSelector'; export interface FormatControlsProps { value: FormatOptions; @@ -29,20 +28,6 @@ export interface FormatControlsProps { disabled?: boolean; } -type AutocompleteUnitOption = UnitConfig & { - id: NonNullable; -}; - -const KIND_OPTIONS: readonly AutocompleteUnitOption[] = Object.entries(UNIT_CONFIG) - .map(([id, config]) => { - return { - ...config, - id: id as AutocompleteUnitOption['id'], - group: config.group || 'Decimal', - }; - }) - .filter((config) => !config.disableSelectorOption); - const DECIMAL_PLACES_OPTIONS: Array<{ id: string; label: string; decimalPlaces?: number }> = [ { id: 'default', label: 'Default', decimalPlaces: undefined }, { id: '0', label: '0', decimalPlaces: 0 }, @@ -62,8 +47,8 @@ export function FormatControls({ value, onChange, disabled = false }: FormatCont const hasDecimalPlaces = isUnitWithDecimalPlaces(value); const hasShortValues = isUnitWithShortValues(value); - const handleKindChange = (_: unknown, newValue: AutocompleteUnitOption | null): void => { - onChange({ unit: newValue?.id || 'decimal' } as FormatOptions); // Fallback to 'decimal' if no unit is selected + const handleUnitChange = (newValue: FormatOptions | undefined): void => { + onChange(newValue || { unit: 'decimal' }); // Fallback to 'decimal' if undefined }; const handleDecimalPlacesChange = ({ @@ -90,8 +75,6 @@ export function FormatControls({ value, onChange, disabled = false }: FormatCont } }; - const unitConfig = UNIT_CONFIG[value?.unit || 'decimal']; - return ( <> - value={{ id: value?.unit || 'decimal', ...unitConfig }} - options={KIND_OPTIONS} - groupBy={(option) => option.group ?? 'Decimal'} - onChange={handleKindChange} - disableClearable - disabled={disabled} - /> - } + control={} /> , + // in the case of multi-axis, we need the cursor Y position in pixel space cursorPixelY?: number ): NearbySeriesArray { const currentNearbySeriesData: NearbySeriesArray = []; From 006103273514b24da8729a6d4a261910801aae3c Mon Sep 17 00:00:00 2001 From: AntoineThebaud Date: Wed, 21 Jan 2026 14:31:51 +0100 Subject: [PATCH 05/10] Remove deprecated function (no longer used for a while) Signed-off-by: AntoineThebaud --- .../src/TimeSeriesTooltip/nearby-series.ts | 60 ------------------- 1 file changed, 60 deletions(-) diff --git a/components/src/TimeSeriesTooltip/nearby-series.ts b/components/src/TimeSeriesTooltip/nearby-series.ts index c300341..a445543 100644 --- a/components/src/TimeSeriesTooltip/nearby-series.ts +++ b/components/src/TimeSeriesTooltip/nearby-series.ts @@ -428,66 +428,6 @@ export function getNearbySeriesData({ return EMPTY_TOOLTIP_DATA; } -/** - * [DEPRECATED] Uses mouse position to determine whether user is hovering over a chart canvas - * If yes, convert from pixel values to logical cartesian coordinates and return all nearby series - */ -export function legacyGetNearbySeriesData({ - mousePos, - pinnedPos, - chartData, - chart, - format, - showAllSeries = false, -}: { - mousePos: CursorData['coords']; - pinnedPos: CursorCoordinates | null; - chartData: EChartsDataFormat; - chart?: EChartsInstance; - format?: FormatOptions; - showAllSeries?: boolean; -}): NearbySeriesArray { - if (chart === undefined || mousePos === null) return []; - - // prevents multiple tooltips showing from adjacent charts unless tooltip is pinned - let cursorTargetMatchesChart = false; - if (mousePos.target !== null) { - const currentParent = (mousePos.target).parentElement; - if (currentParent !== null) { - const currentGrandparent = currentParent.parentElement; - if (currentGrandparent !== null) { - const chartDom = chart.getDom(); - if (chartDom === currentGrandparent) { - cursorTargetMatchesChart = true; - } - } - } - } - - // allows moving cursor inside tooltip without it fading away - if (pinnedPos !== null) { - mousePos = pinnedPos; - cursorTargetMatchesChart = true; - } - - if (cursorTargetMatchesChart === false) return []; - - if (chart['_model'] === undefined) return []; - const chartModel = chart['_model']; - const yInterval = chartModel.getComponent('yAxis').axis.scale._interval; - const totalSeries = chartData.timeSeries.length; - const yBuffer = getYBuffer({ yInterval, totalSeries, showAllSeries }); - const pointInPixel = [mousePos.plotCanvas.x ?? 0, mousePos.plotCanvas.y ?? 0]; - if (chart.containPixel('grid', pointInPixel)) { - const pointInGrid = chart.convertFromPixel('grid', pointInPixel); - if (pointInGrid[0] !== undefined && pointInGrid[1] !== undefined) { - return legacyCheckforNearbySeries(chartData, pointInGrid, yBuffer, chart, format); - } - } - - return []; -} - /* * Check if two numbers are within a specified percentage range */ From 175fb65d99b61032d2742d4a7b24d9cc1dc9567b Mon Sep 17 00:00:00 2001 From: AntoineThebaud Date: Wed, 21 Jan 2026 15:51:24 +0100 Subject: [PATCH 06/10] fix lint issue Signed-off-by: AntoineThebaud --- components/src/FormatControls/FormatControls.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/components/src/FormatControls/FormatControls.tsx b/components/src/FormatControls/FormatControls.tsx index 315eaee..7c1b2a6 100644 --- a/components/src/FormatControls/FormatControls.tsx +++ b/components/src/FormatControls/FormatControls.tsx @@ -11,12 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. import { Switch, SwitchProps } from '@mui/material'; -import { - FormatOptions, - isUnitWithDecimalPlaces, - isUnitWithShortValues, - shouldShortenValues, -} from '@perses-dev/core'; +import { FormatOptions, isUnitWithDecimalPlaces, isUnitWithShortValues, shouldShortenValues } from '@perses-dev/core'; import { ReactElement } from 'react'; import { OptionsEditorControl } from '../OptionsEditorLayout'; import { SettingsAutocomplete } from '../SettingsAutocomplete'; From a3af628ede861445209d6bab640f50b321e0c618 Mon Sep 17 00:00:00 2001 From: AntoineThebaud Date: Wed, 21 Jan 2026 15:57:11 +0100 Subject: [PATCH 07/10] fix nearby-series behavior that was only using the "new" behavior even for regular single-Y-axis situations Signed-off-by: AntoineThebaud --- components/src/TimeSeriesTooltip/nearby-series.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/components/src/TimeSeriesTooltip/nearby-series.ts b/components/src/TimeSeriesTooltip/nearby-series.ts index a445543..1ae42cb 100644 --- a/components/src/TimeSeriesTooltip/nearby-series.ts +++ b/components/src/TimeSeriesTooltip/nearby-series.ts @@ -19,8 +19,8 @@ import { batchDispatchNearbySeriesActions, getPointInGrid, getClosestTimestamp } import { CursorCoordinates, CursorData, EMPTY_TOOLTIP_DATA } from './tooltip-model'; // increase multipliers to show more series in tooltip -export const INCREASE_NEARBY_SERIES_MULTIPLIER = 2.5; // adjusts how many series show in tooltip (higher == more series shown) -export const DYNAMIC_NEARBY_SERIES_MULTIPLIER = 15; // used for adjustment after series number divisor +export const INCREASE_NEARBY_SERIES_MULTIPLIER = 5.5; // adjusts how many series show in tooltip (higher == more series shown) +export const DYNAMIC_NEARBY_SERIES_MULTIPLIER = 30; // used for adjustment after series number divisor export const SHOW_FEWER_SERIES_LIMIT = 5; export interface NearbySeriesInfo { @@ -412,6 +412,10 @@ export function getNearbySeriesData({ } const totalSeries = data.length; const yBuffer = getYBuffer({ yInterval, totalSeries, showAllSeries }); + + // Detect if chart has multiple Y-axes by checking if any series uses yAxisIndex > 0 + const hasMultipleYAxes = seriesMapping.some((series) => series.yAxisIndex !== undefined && series.yAxisIndex > 0); + return checkforNearbyTimeSeries( data, seriesMapping, @@ -420,7 +424,7 @@ export function getNearbySeriesData({ chart, format, seriesFormatMap, - cursorPixelY + hasMultipleYAxes ? cursorPixelY : undefined ); } From 4730f7e93e375045b80268d3688a94f1b7a0c28d Mon Sep 17 00:00:00 2001 From: AntoineThebaud Date: Wed, 21 Jan 2026 17:29:37 +0100 Subject: [PATCH 08/10] Change to preserve a11y props that were lost due to have 1 more component (UnitSelector) in the call hierarchy Signed-off-by: AntoineThebaud --- components/src/FormatControls/UnitSelector.tsx | 3 ++- .../OptionsEditorLayout/OptionsEditorControl.tsx | 6 +++++- .../SettingsAutocomplete.tsx | 16 +++++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/components/src/FormatControls/UnitSelector.tsx b/components/src/FormatControls/UnitSelector.tsx index cfa3889..5bf83e6 100644 --- a/components/src/FormatControls/UnitSelector.tsx +++ b/components/src/FormatControls/UnitSelector.tsx @@ -35,7 +35,7 @@ const KIND_OPTIONS: readonly AutocompleteUnitOption[] = Object.entries(UNIT_CONF }) .filter((config) => !config.disableSelectorOption); -export function UnitSelector({ value, onChange, disabled = false }: UnitSelectorProps): ReactElement { +export function UnitSelector({ value, onChange, disabled = false, ...otherProps }: UnitSelectorProps): ReactElement { const unitConfig = UNIT_CONFIG[value?.unit || 'decimal']; const handleChange = (_: unknown, newValue: AutocompleteUnitOption | null): void => { @@ -53,6 +53,7 @@ export function UnitSelector({ value, onChange, disabled = false }: UnitSelector groupBy={(option) => option.group ?? 'Decimal'} onChange={handleChange} disabled={disabled} + {...otherProps} /> ); } diff --git a/components/src/OptionsEditorLayout/OptionsEditorControl.tsx b/components/src/OptionsEditorLayout/OptionsEditorControl.tsx index 625de67..abc195e 100644 --- a/components/src/OptionsEditorLayout/OptionsEditorControl.tsx +++ b/components/src/OptionsEditorLayout/OptionsEditorControl.tsx @@ -26,16 +26,20 @@ export const OptionsEditorControl = ({ label, control, description }: OptionsEdi // controls for a11y. const generatedControlId = useId('EditorSectionControl'); const controlId = `${generatedControlId}-control`; + const labelId = `${generatedControlId}-label`; const controlProps = { id: controlId, + 'aria-labelledby': labelId, }; return ( - {label} + + {label} + {description && ( ({ options, renderInput = (params): ReactElement => , + id, + 'aria-labelledby': ariaLabelledby, ...otherProps }: SettingsAutocompleteProps): ReactElement { const getOptionLabel: UseAutocompleteProps['getOptionLabel'] = ( @@ -82,6 +84,18 @@ export function SettingsAutocomplete< return option.label ?? option.id; }; + // Merge id and aria-labelledby props into the input element for proper accessibility + // and form association, while still allowing custom renderInput implementations. + const handleRenderInput: AutocompleteProps['renderInput'] = ( + params + ) => { + const mergedParams = { + ...params, + inputProps: { ...params.inputProps, id, 'aria-labelledby': ariaLabelledby }, + }; + return renderInput(mergedParams); + }; + // Note: this component currently is not virtualized because it is specific // to being used for settings, which generally have a pretty small list of // options. If this changes to include values with many options, virtualization @@ -92,7 +106,7 @@ export function SettingsAutocomplete< getOptionDisabled={(option) => !!option.disabled} getOptionLabel={getOptionLabel} options={options} - renderInput={renderInput} + renderInput={handleRenderInput} renderOption={({ key, ...props }, option) => { return (
  • From 9e05e58c6819d23244d8605231ec589f86a34ba8 Mon Sep 17 00:00:00 2001 From: AntoineThebaud Date: Mon, 26 Jan 2026 16:13:18 +0100 Subject: [PATCH 09/10] Fix cropped labels in legend + improve spacing Signed-off-by: AntoineThebaud --- components/src/utils/axis.ts | 59 +++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/components/src/utils/axis.ts b/components/src/utils/axis.ts index 669f3a7..90acd54 100644 --- a/components/src/utils/axis.ts +++ b/components/src/utils/axis.ts @@ -23,10 +23,44 @@ export interface YAxisConfig { max?: number; } -// Average character width in pixels (approximate for typical UI fonts) -const AVG_CHAR_WIDTH = 7; -// Base padding for axis labels (spacing, axis line, etc.) -const AXIS_LABEL_PADDING = 12; +// Character width multipliers (approximate for typical UI fonts) +const CHAR_WIDTH_BASE = 8; +const CHAR_WIDTH_MULTIPLIERS = { + dot: 0.5, // Dots and periods are very narrow + uppercase: 0.9, + lowercase: 0.65, // Lowercase letters slightly narrower + digit: 0.7, + symbol: 0.7, // Symbols like %, $, etc. + space: 0.5, // Spaces +}; +const AXIS_LABEL_PADDING = 14; + +/** + * Calculate the width of a single character based on its type + */ +function getCharWidth(char?: string): number { + if (!char || char.length === 0) { + return 0; + } + + if (char === '.' || char === ',' || char === ':') { + return CHAR_WIDTH_BASE * CHAR_WIDTH_MULTIPLIERS.dot; + } + if (char === ' ') { + return CHAR_WIDTH_BASE * CHAR_WIDTH_MULTIPLIERS.space; + } + if (char >= 'A' && char <= 'Z') { + return CHAR_WIDTH_BASE * CHAR_WIDTH_MULTIPLIERS.uppercase; + } + if (char >= 'a' && char <= 'z') { + return CHAR_WIDTH_BASE * CHAR_WIDTH_MULTIPLIERS.lowercase; + } + if (char >= '0' && char <= '9') { + return CHAR_WIDTH_BASE * CHAR_WIDTH_MULTIPLIERS.digit; + } + // Symbols like %, $, -, +, etc. + return CHAR_WIDTH_BASE * CHAR_WIDTH_MULTIPLIERS.symbol; +} /** * Estimate the pixel width needed for an axis label based on the formatted max value. @@ -34,7 +68,12 @@ const AXIS_LABEL_PADDING = 12; */ function estimateLabelWidth(format: FormatOptions | undefined, maxValue: number): number { const formattedLabel = formatValue(maxValue, format); - return formattedLabel.length * AVG_CHAR_WIDTH + AXIS_LABEL_PADDING; + // Calculate width based on individual character types + let totalWidth = 0; + for (let i = 0; i < formattedLabel.length; i++) { + totalWidth += getCharWidth(formattedLabel[i]); + } + return totalWidth; } /* @@ -92,12 +131,6 @@ export function getFormattedMultipleYAxes( // Additional Y axes (right side) for each unique format additionalFormats.forEach((format, index) => { - // For subsequent axes, add the width of the previous axis's labels - if (index > 0 && maxValues) { - const prevMaxValue = maxValues[index - 1] ?? 1000; - cumulativeOffset += estimateLabelWidth(additionalFormats[index - 1], prevMaxValue); - } - const rightAxisConfig: YAXisComponentOption = { type: 'value', position: 'right', @@ -115,6 +148,10 @@ export function getFormattedMultipleYAxes( 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; + } }); return axes; From a8a54d6492d9ff1f416252c4741c2ceae0dd4bfe Mon Sep 17 00:00:00 2001 From: AntoineThebaud Date: Tue, 27 Jan 2026 11:15:42 +0100 Subject: [PATCH 10/10] Improve further axis offset computation using Canvas API Signed-off-by: AntoineThebaud --- components/src/utils/axis.ts | 58 +++++++++--------------------------- 1 file changed, 14 insertions(+), 44 deletions(-) diff --git a/components/src/utils/axis.ts b/components/src/utils/axis.ts index 90acd54..2b6537e 100644 --- a/components/src/utils/axis.ts +++ b/components/src/utils/axis.ts @@ -24,56 +24,24 @@ export interface YAxisConfig { } // Character width multipliers (approximate for typical UI fonts) -const CHAR_WIDTH_BASE = 8; -const CHAR_WIDTH_MULTIPLIERS = { - dot: 0.5, // Dots and periods are very narrow - uppercase: 0.9, - lowercase: 0.65, // Lowercase letters slightly narrower - digit: 0.7, - symbol: 0.7, // Symbols like %, $, etc. - space: 0.5, // Spaces -}; -const AXIS_LABEL_PADDING = 14; +const CHAR_WIDTH_BASE = 6; +const AXIS_LABEL_PADDING = 10; // Extra padding to avoid label clipping /** - * Calculate the width of a single character based on its type - */ -function getCharWidth(char?: string): number { - if (!char || char.length === 0) { - return 0; - } - - if (char === '.' || char === ',' || char === ':') { - return CHAR_WIDTH_BASE * CHAR_WIDTH_MULTIPLIERS.dot; - } - if (char === ' ') { - return CHAR_WIDTH_BASE * CHAR_WIDTH_MULTIPLIERS.space; - } - if (char >= 'A' && char <= 'Z') { - return CHAR_WIDTH_BASE * CHAR_WIDTH_MULTIPLIERS.uppercase; - } - if (char >= 'a' && char <= 'z') { - return CHAR_WIDTH_BASE * CHAR_WIDTH_MULTIPLIERS.lowercase; - } - if (char >= '0' && char <= '9') { - return CHAR_WIDTH_BASE * CHAR_WIDTH_MULTIPLIERS.digit; - } - // Symbols like %, $, -, +, etc. - return CHAR_WIDTH_BASE * CHAR_WIDTH_MULTIPLIERS.symbol; -} - -/** - * Estimate the pixel width needed for an axis label based on the formatted max value. - * This provides dynamic spacing that adapts to the actual data scale. + * 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); - // Calculate width based on individual character types - let totalWidth = 0; - for (let i = 0; i < formattedLabel.length; i++) { - totalWidth += getCharWidth(formattedLabel[i]); + // 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; } - return totalWidth; + context.font = '12px sans-serif'; + const metrics = context.measureText(formattedLabel); + return metrics.width; } /* @@ -120,6 +88,8 @@ export function getFormattedMultipleYAxes( formatter: (value: number): string => { return formatValue(value, baseFormat); }, + // Let ECharts handle width automatically + overflow: 'truncate', }, }, baseAxis