Skip to content

Commit 4f341f1

Browse files
[FEATURE] TimeSeriesChart: support multiple Y-axis (#531)
* V1 that works but has tooltip issues Signed-off-by: AntoineThebaud <antoine.thebaud@yahoo.fr> * V2 tooltip showing all units but nearby series computation buggy Signed-off-by: AntoineThebaud <antoine.thebaud@yahoo.fr> * v3 wrong offset removed Signed-off-by: AntoineThebaud <antoine.thebaud@yahoo.fr> * v4 offset between y axis OK Signed-off-by: AntoineThebaud <antoine.thebaud@yahoo.fr> * v5 last axis unit no longer cropped Signed-off-by: AntoineThebaud <antoine.thebaud@yahoo.fr> * v6 Small display improvement in query settings editor Signed-off-by: AntoineThebaud <antoine.thebaud@yahoo.fr> * v7 Y axis > show=false now hides all Y axes Signed-off-by: AntoineThebaud <antoine.thebaud@yahoo.fr> * v8 fix useless empty space when Y axis > show = false Signed-off-by: AntoineThebaud <antoine.thebaud@yahoo.fr> * small refactor Signed-off-by: AntoineThebaud <antoine.thebaud@yahoo.fr> * add back lost comments Signed-off-by: AntoineThebaud <antoine.thebaud@yahoo.fr> * fix DCO & lint issues Signed-off-by: AntoineThebaud <antoine.thebaud@yahoo.fr> * small fixes Signed-off-by: AntoineThebaud <antoine.thebaud@yahoo.fr> * review comments Signed-off-by: AntoineThebaud <antoine.thebaud@yahoo.fr> --------- Signed-off-by: AntoineThebaud <antoine.thebaud@yahoo.fr>
1 parent 5b1f96f commit 4f341f1

8 files changed

Lines changed: 173 additions & 35 deletions

File tree

timeserieschart/schemas/time-series.cue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ spec: close({
6565
colorValue?: =~"^#(?:[0-9a-fA-F]{3}){1,2}$" // hexadecimal color code
6666
lineStyle?: #lineStyle
6767
areaOpacity?: #areaOpacity
68+
format?: common.#format
6869
}]
6970

7071
#lineStyle: "solid" | "dashed" | "dotted"

timeserieschart/sdk/go/time-series.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,12 @@ const (
121121
)
122122

123123
type QuerySettingsItem struct {
124-
QueryIndex uint `json:"queryIndex" yaml:"queryIndex"`
125-
ColorMode ColorMode `json:"colorMode" yaml:"colorMode"`
126-
ColorValue string `json:"colorValue" yaml:"colorValue"`
124+
QueryIndex uint `json:"queryIndex" yaml:"queryIndex"`
125+
ColorMode ColorMode `json:"colorMode,omitempty" yaml:"colorMode,omitempty"`
126+
ColorValue string `json:"colorValue,omitempty" yaml:"colorValue,omitempty"`
127+
LineStyle string `json:"lineStyle,omitempty" yaml:"lineStyle,omitempty"`
128+
AreaOpacity float64 `json:"areaOpacity,omitempty" yaml:"areaOpacity,omitempty"`
129+
Format *common.Format `json:"format,omitempty" yaml:"format,omitempty"`
127130
}
128131

129132
type Option func(plugin *Builder) error

timeserieschart/src/QuerySettingsEditor.tsx

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import {
2525
Typography,
2626
useTheme,
2727
} from '@mui/material';
28-
import { OptionsColorPicker } from '@perses-dev/components';
28+
import { OptionsColorPicker, UnitSelector } from '@perses-dev/components';
29+
import { FormatOptions } from '@perses-dev/core';
2930
import React, { ReactElement, useEffect, useMemo, useRef, useState } from 'react';
3031
import DeleteIcon from 'mdi-material-ui/DeleteOutline';
3132
import AddIcon from 'mdi-material-ui/Plus';
@@ -205,6 +206,24 @@ export function QuerySettingsEditor(props: TimeSeriesChartOptionsEditorProps): R
205206
});
206207
};
207208

209+
const addUnit = (i: number): void => {
210+
updateQuerySettings(i, (qs) => {
211+
qs.format = { unit: 'decimal' };
212+
});
213+
};
214+
215+
const removeUnit = (i: number): void => {
216+
updateQuerySettings(i, (qs) => {
217+
qs.format = undefined;
218+
});
219+
};
220+
221+
const handleUnitChange = (i: number, format?: FormatOptions): void => {
222+
updateQuerySettings(i, (qs) => {
223+
qs.format = format;
224+
});
225+
};
226+
208227
const queryCount = useQueryCountContext();
209228

210229
// Compute the list of query indexes for which query settings are not already defined.
@@ -266,6 +285,9 @@ export function QuerySettingsEditor(props: TimeSeriesChartOptionsEditorProps): R
266285
onRemoveLineStyle={() => removeLineStyle(i)}
267286
onAddAreaOpacity={() => addAreaOpacity(i)}
268287
onRemoveAreaOpacity={() => removeAreaOpacity(i)}
288+
onAddUnit={() => addUnit(i)}
289+
onRemoveUnit={() => removeUnit(i)}
290+
onUnitChange={(format) => handleUnitChange(i, format)}
269291
/>
270292
))
271293
)}
@@ -295,10 +317,13 @@ interface QuerySettingsInputProps {
295317
onRemoveLineStyle: () => void;
296318
onAddAreaOpacity: () => void;
297319
onRemoveAreaOpacity: () => void;
320+
onAddUnit: () => void;
321+
onRemoveUnit: () => void;
322+
onUnitChange: (format?: FormatOptions) => void;
298323
}
299324

300325
function QuerySettingsInput({
301-
querySettings: { queryIndex, colorMode, colorValue, lineStyle, areaOpacity },
326+
querySettings: { queryIndex, colorMode, colorValue, lineStyle, areaOpacity, format },
302327
availableQueryIndexes,
303328
onQueryIndexChange,
304329
onColorModeChange,
@@ -313,6 +338,9 @@ function QuerySettingsInput({
313338
onRemoveLineStyle,
314339
onAddAreaOpacity,
315340
onRemoveAreaOpacity,
341+
onAddUnit,
342+
onRemoveUnit,
343+
onUnitChange,
316344
}: QuerySettingsInputProps): ReactElement {
317345
// current query index should also be selectable
318346
const selectableQueryIndexes = availableQueryIndexes.concat(queryIndex).sort((a, b) => a - b);
@@ -326,8 +354,9 @@ function QuerySettingsInput({
326354
if (!colorMode) options.push({ key: 'color', label: 'Color', action: onAddColor });
327355
if (!lineStyle) options.push({ key: 'lineStyle', label: 'Line Style', action: onAddLineStyle });
328356
if (areaOpacity === undefined) options.push({ key: 'opacity', label: 'Opacity', action: onAddAreaOpacity });
357+
if (format === undefined) options.push({ key: 'unit', label: 'Unit', action: onAddUnit });
329358
return options;
330-
}, [colorMode, lineStyle, areaOpacity, onAddColor, onAddLineStyle, onAddAreaOpacity]);
359+
}, [colorMode, lineStyle, areaOpacity, format, onAddColor, onAddLineStyle, onAddAreaOpacity, onAddUnit]);
331360

332361
const handleAddMenuClick = (event: React.MouseEvent<HTMLElement>) => {
333362
if (availableOptions.length === 1 && availableOptions[0]) {
@@ -349,8 +378,8 @@ function QuerySettingsInput({
349378
};
350379

351380
return (
352-
<Stack spacing={2} sx={{ borderBottom: '1px solid', borderColor: 'divider', borderRadius: 1, p: 2 }}>
353-
<Stack direction="row" alignItems="center" spacing={1} sx={{ flexWrap: 'wrap', gap: 1 }}>
381+
<Stack sx={{ borderBottom: '1px solid', borderColor: 'divider', borderRadius: 1, p: 2 }}>
382+
<Stack direction="row" alignItems="center" sx={{ flexWrap: 'wrap', gap: 2 }}>
354383
{/* Query Index Selection */}
355384
<TextField
356385
select
@@ -425,6 +454,15 @@ function QuerySettingsInput({
425454
</SettingsSection>
426455
)}
427456

457+
{/* Unit section */}
458+
{format !== undefined && (
459+
<SettingsSection label="Unit" onRemove={onRemoveUnit}>
460+
<Box sx={{ minWidth: '180px' }}>
461+
<UnitSelector value={format} onChange={onUnitChange} />
462+
</Box>
463+
</SettingsSection>
464+
)}
465+
428466
{/* Add Options Button - only show if there are available options */}
429467
{availableOptions.length > 0 && (
430468
<>

timeserieschart/src/TimeSeriesChartBase.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,12 @@ export interface TimeChartProps {
8282
data: TimeSeries[];
8383
seriesMapping: TimeChartSeriesMapping;
8484
timeScale?: TimeScale;
85-
yAxis?: YAXisComponentOption;
85+
yAxis?: YAXisComponentOption | YAXisComponentOption[];
8686
format?: FormatOptions;
87+
/**
88+
* Map of series ID to format options, used for tooltip formatting when series have different units
89+
*/
90+
seriesFormatMap?: Map<string, FormatOptions>;
8791
grid?: GridComponentOption;
8892
tooltipConfig?: TooltipConfig;
8993
noDataVariant?: 'chart' | 'message';
@@ -102,6 +106,7 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
102106
timeScale: timeScaleProp,
103107
yAxis,
104108
format,
109+
seriesFormatMap,
105110
grid,
106111
isStackedBar = false,
107112
tooltipConfig = DEFAULT_TOOLTIP_CONFIG,
@@ -226,7 +231,8 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
226231
snap: false, // important so shared crosshair does not lag
227232
},
228233
},
229-
yAxis: getFormattedAxis(yAxis, format),
234+
// If yAxis is already an array (multiple Y axes), use it directly; otherwise use getFormattedAxis
235+
yAxis: Array.isArray(yAxis) ? yAxis : getFormattedAxis(yAxis, format),
230236
animation: false,
231237
tooltip: {
232238
show: true,
@@ -435,6 +441,7 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
435441
enablePinning={isPinningEnabled}
436442
pinnedPos={tooltipPinnedCoords}
437443
format={format}
444+
seriesFormatMap={seriesFormatMap}
438445
onUnpinClick={() => {
439446
// Unpins tooltip when clicking Pin icon in TooltipHeader.
440447
setTooltipPinnedCoords(null);

timeserieschart/src/TimeSeriesChartPanel.tsx

Lines changed: 108 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
TooltipConfig,
4848
DEFAULT_TOOLTIP_CONFIG,
4949
TimeChartSeriesMapping,
50+
getFormattedMultipleYAxes,
5051
} from '@perses-dev/components';
5152
import {
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}

timeserieschart/src/time-series-chart-model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export interface QuerySettingsOptions {
4444
colorValue?: string;
4545
lineStyle?: LineStyleType;
4646
areaOpacity?: number;
47+
format?: FormatOptions;
4748
}
4849

4950
export type TimeSeriesChartOptionsEditorProps = OptionsEditorProps<TimeSeriesChartOptions>;

0 commit comments

Comments
 (0)