@@ -47,6 +47,7 @@ import {
4747 TooltipConfig ,
4848 DEFAULT_TOOLTIP_CONFIG ,
4949 TimeChartSeriesMapping ,
50+ getFormattedMultipleYAxes ,
5051} from '@perses-dev/components' ;
5152import {
5253 TimeSeriesChartOptions ,
@@ -126,18 +127,53 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement
126127 return convertPanelYAxis ( yAxis ) ;
127128 } , [ yAxis ] ) ;
128129
130+ // Collect unique formats from query settings that differ from the base format
131+ // These will create additional Y axes on the right side
132+ const { additionalFormats, formatToYAxisIndex, seriesFormatMap } = useMemo ( ( ) => {
133+ const baseUnit = format ?. unit ?? 'decimal' ;
134+ const additionalFormats : Array < typeof format > = [ ] ;
135+ const formatToYAxisIndex = new Map < string , number > ( ) ;
136+ const seriesFormatMap = new Map < string , typeof format > ( ) ;
137+
138+ // Index 0 is reserved for the base Y axis
139+ formatToYAxisIndex . set ( baseUnit , 0 ) ;
140+
141+ // Collect unique formats from query settings
142+ for ( const qs of querySettingsList ?? [ ] ) {
143+ if ( qs . format ?. unit && qs . format . unit !== baseUnit ) {
144+ const unitKey = qs . format . unit ;
145+ if ( ! formatToYAxisIndex . has ( unitKey ) ) {
146+ // Add new format - index is 1 + position in additionalFormats array
147+ formatToYAxisIndex . set ( unitKey , 1 + additionalFormats . length ) ;
148+ additionalFormats . push ( qs . format ) ;
149+ }
150+ }
151+ }
152+
153+ return { additionalFormats, formatToYAxisIndex, seriesFormatMap } ;
154+ } , [ format , querySettingsList ] ) ;
155+
129156 const [ selectedLegendItems , setSelectedLegendItems ] = useState < SelectedLegendItemState > ( 'ALL' ) ;
130157 const [ legendSorting , setLegendSorting ] = useState < NonNullable < LegendProps [ 'tableProps' ] > [ 'sorting' ] > ( ) ;
131158
132159 const { setTimeRange } = useTimeRange ( ) ;
133160
134161 // Populate series data based on query results
135- const { timeScale, timeChartData, timeSeriesMapping, legendItems } = useMemo ( ( ) => {
162+ const {
163+ timeScale,
164+ timeChartData,
165+ timeSeriesMapping,
166+ legendItems,
167+ seriesFormatMap : computedSeriesFormatMap ,
168+ maxValuesByFormat,
169+ } = useMemo ( ( ) => {
136170 const timeScale = getCommonTimeScaleForQueries ( queryResults ) ;
137171 if ( timeScale === undefined ) {
138172 return {
139173 timeChartData : [ ] ,
140174 timeSeriesMapping : [ ] ,
175+ seriesFormatMap : new Map ( ) ,
176+ maxValuesByFormat : new Map < string , number > ( ) ,
141177 } ;
142178 }
143179
@@ -148,6 +184,9 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement
148184 const timeChartData : TimeSeries [ ] = [ ] ;
149185 const timeSeriesMapping : TimeChartSeriesMapping = [ ] ;
150186
187+ // Track max values for each format unit (used for dynamic Y axis offset calculation)
188+ const maxValuesByFormat = new Map < string , number > ( ) ;
189+
151190 // Index is counted across multiple queries which ensures the categorical color palette does not reset for every query
152191 let seriesIndex = 0 ;
153192
@@ -209,12 +248,40 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement
209248 // off-by-one error, seriesIndex cannot be used since it's needed to cycle through palette
210249 const datasetIndex = timeChartData . length ;
211250
251+ // Determine yAxisIndex based on the query's format setting
252+ const queryFormat = querySettings ?. format ;
253+ const yAxisIndex = queryFormat ?. unit ? ( formatToYAxisIndex . get ( queryFormat . unit ) ?? 0 ) : 0 ;
254+
212255 // Each series is stored as a separate dataset source.
213256 // https://apache.github.io/echarts-handbook/en/concepts/dataset/#how-to-reference-several-datasets
214257 timeSeriesMapping . push (
215- getTimeSeries ( seriesId , datasetIndex , formattedSeriesName , visual , timeScale , seriesColor , querySettings )
258+ getTimeSeries (
259+ seriesId ,
260+ datasetIndex ,
261+ formattedSeriesName ,
262+ visual ,
263+ timeScale ,
264+ seriesColor ,
265+ querySettings ,
266+ yAxisIndex
267+ )
216268 ) ;
217269
270+ // Store the format for this series for tooltip formatting
271+ if ( queryFormat ) {
272+ seriesFormatMap . set ( seriesId , queryFormat ) ;
273+
274+ // Track max value for this format unit (used for dynamic Y axis offset calculation)
275+ const unitKey = queryFormat . unit ;
276+ if ( unitKey ) {
277+ const seriesMax = Math . max ( ...timeSeries . values . map ( ( v ) => Math . abs ( v [ 1 ] ?? 0 ) ) ) ;
278+ const currentMax = maxValuesByFormat . get ( unitKey ) ?? 0 ;
279+ if ( seriesMax > currentMax ) {
280+ maxValuesByFormat . set ( unitKey , seriesMax ) ;
281+ }
282+ }
283+ }
284+
218285 timeChartData . push ( {
219286 name : formattedSeriesName ,
220287 values : getTimeSeriesValues ( timeSeries , timeScale ) ,
@@ -278,6 +345,8 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement
278345 timeChartData,
279346 timeSeriesMapping,
280347 legendItems,
348+ seriesFormatMap,
349+ maxValuesByFormat,
281350 } ;
282351 } , [
283352 queryResults ,
@@ -292,8 +361,24 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement
292361 chartId ,
293362 chartsTheme . thresholds ,
294363 muiTheme . palette . primary . main ,
364+ formatToYAxisIndex ,
365+ seriesFormatMap ,
295366 ] ) ;
296367
368+ // Create multiple Y axes if there are additional formats
369+ // Uses max values from data to compute dynamic offsets that adapt to label widths
370+ const multipleYAxes = useMemo ( ( ) => {
371+ if ( additionalFormats . length === 0 ) {
372+ return undefined ; // Use single Y axis (default behavior)
373+ }
374+ // Build array of max values for each additional format (in order)
375+ const maxValues = additionalFormats . map ( ( fmt ) => {
376+ const unitKey = fmt . unit ;
377+ return unitKey ? ( maxValuesByFormat ?. get ( unitKey ) ?? 1000 ) : 1000 ;
378+ } ) ;
379+ return getFormattedMultipleYAxes ( echartsYAxis , format , additionalFormats , maxValues ) ;
380+ } , [ echartsYAxis , format , additionalFormats , maxValuesByFormat ] ) ;
381+
297382 // Translate the legend values into columns for the table legend.
298383 const legendColumns = useMemo ( ( ) => {
299384 if ( ! legend ?. values ) {
@@ -331,18 +416,29 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement
331416 ) ;
332417 } , [ legend ?. values , format ] ) ;
333418
419+ const gridOverrides : GridComponentOption = useMemo ( ( ) => {
420+ // When Y axes are hidden, disable containLabel to prevent auto-spacing, but add bottom padding for X axis
421+ return echartsYAxis . show === false
422+ ? {
423+ left : 0 ,
424+ right : 0 ,
425+ bottom : 30 ,
426+ containLabel : false ,
427+ }
428+ : {
429+ left : yAxis && yAxis . label ? 30 : 20 ,
430+ // With containLabel: true in theme, ECharts auto-reserves space for axis labels.
431+ // For multiple right axes, add extra padding for the last axis labels that extend beyond the grid.
432+ right : additionalFormats . length > 0 ? 10 : 20 ,
433+ bottom : 0 ,
434+ containLabel : true ,
435+ } ;
436+ } , [ echartsYAxis . show , yAxis , additionalFormats . length ] ) ;
437+
334438 if ( adjustedContentDimensions === undefined ) {
335439 return null ;
336440 }
337441
338- // override default spacing, see: https://echarts.apache.org/en/option.html#grid
339- const gridLeft = yAxis && yAxis . label ? 30 : 20 ;
340- const gridOverrides : GridComponentOption = {
341- left : ! echartsYAxis . show ? 0 : gridLeft ,
342- right : 20 ,
343- bottom : 0 ,
344- } ;
345-
346442 const handleDataZoom = ( event : ZoomEventData ) : void => {
347443 // TODO: add ECharts transition animation on zoom
348444 setTimeRange ( { start : new Date ( event . start ) , end : new Date ( event . end ) } ) ;
@@ -402,8 +498,9 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement
402498 data = { timeChartData }
403499 seriesMapping = { timeSeriesMapping }
404500 timeScale = { timeScale }
405- yAxis = { echartsYAxis }
501+ yAxis = { multipleYAxes ?? echartsYAxis }
406502 format = { format }
503+ seriesFormatMap = { computedSeriesFormatMap }
407504 grid = { gridOverrides }
408505 isStackedBar = { isStackedBar }
409506 tooltipConfig = { tooltipConfig }
0 commit comments