diff --git a/components/src/FormatControls/FormatControls.tsx b/components/src/FormatControls/FormatControls.tsx index faf3bea..7c1b2a6 100644 --- a/components/src/FormatControls/FormatControls.tsx +++ b/components/src/FormatControls/FormatControls.tsx @@ -11,17 +11,11 @@ // See the License for the specific language governing permissions and // limitations under the License. import { Switch, SwitchProps } from '@mui/material'; -import { - FormatOptions, - UNIT_CONFIG, - UnitConfig, - 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'; +import { UnitSelector } from './UnitSelector'; export interface FormatControlsProps { value: FormatOptions; @@ -29,20 +23,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 +42,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 +70,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={} /> 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, ...otherProps }: 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} + {...otherProps} + /> + ); +} 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/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 (
  • 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..1ae42cb 100644 --- a/components/src/TimeSeriesTooltip/nearby-series.ts +++ b/components/src/TimeSeriesTooltip/nearby-series.ts @@ -47,7 +47,10 @@ export function checkforNearbyTimeSeries( pointInGrid: number[], yBuffer: number, chart: EChartsInstance, - format?: FormatOptions + format?: FormatOptions, + seriesFormatMap?: Map, + // in the case of multi-axis, we need the cursor Y position in pixel space + cursorPixelY?: number ): NearbySeriesArray { const currentNearbySeriesData: NearbySeriesArray = []; const cursorX: number | null = pointInGrid[0] ?? null; @@ -76,6 +79,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 +103,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 +119,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 +198,7 @@ export function checkforNearbyTimeSeries( seriesIndex: seriesIdx, }); } - const formattedY = formatValue(yValue, format); + const formattedY = formatValue(yValue, seriesFormat); currentNearbySeriesData.push({ seriesIdx: seriesIdx, datumIdx: datumIdx, @@ -294,6 +352,7 @@ export function getNearbySeriesData({ seriesMapping, chart, format, + seriesFormatMap, showAllSeries = false, }: { mousePos: CursorData['coords']; @@ -302,6 +361,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 +392,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,73 +412,26 @@ export function getNearbySeriesData({ } const totalSeries = data.length; const yBuffer = getYBuffer({ yInterval, totalSeries, showAllSeries }); - return checkforNearbyTimeSeries(data, seriesMapping, pointInGrid, yBuffer, chart, format); + + // 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, + pointInGrid, + yBuffer, + chart, + format, + seriesFormatMap, + hasMultipleYAxes ? cursorPixelY : undefined + ); } // no nearby series found 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 */ diff --git a/components/src/utils/axis.ts b/components/src/utils/axis.ts index 7072690..2b6537e 100644 --- a/components/src/utils/axis.ts +++ b/components/src/utils/axis.ts @@ -15,11 +15,39 @@ 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; +} + +// Character width multipliers (approximate for typical UI fonts) +const CHAR_WIDTH_BASE = 6; +const AXIS_LABEL_PADDING = 10; // Extra padding to avoid label clipping + +/** + * 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; + } + context.font = '12px sans-serif'; + const metrics = context.measureText(formattedLabel); + return metrics.width; +} + /* - * 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[] { - // TODO: support alternate yAxis that shows on right side const AXIS_DEFAULT = { type: 'value', boundaryGap: [0, '10%'], @@ -31,3 +59,70 @@ 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); + }, + // 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 = { + 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 + }, + 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; +}