From 5a8f3c4ed199cc98007cddbf2e6127b8c11c1984 Mon Sep 17 00:00:00 2001 From: Markus Wieland Date: Thu, 4 Jun 2026 14:07:00 +0200 Subject: [PATCH 1/6] [ENHANCEMENT] timeserieschart: add per-query stack override Signed-off-by: Markus Wieland --- timeserieschart/schemas/time-series.cue | 1 + timeserieschart/sdk/go/time-series.go | 1 + timeserieschart/src/QuerySettingsEditor.tsx | 45 ++++++++++++++++++- timeserieschart/src/TimeSeriesChartBase.tsx | 2 +- timeserieschart/src/TimeSeriesChartPanel.tsx | 2 +- timeserieschart/src/VisualOptionsEditor.tsx | 2 +- .../src/time-series-chart-model.ts | 1 + timeserieschart/src/utils/data-transform.ts | 10 +++-- 8 files changed, 55 insertions(+), 9 deletions(-) diff --git a/timeserieschart/schemas/time-series.cue b/timeserieschart/schemas/time-series.cue index 80bdc7c38..e5b076e42 100644 --- a/timeserieschart/schemas/time-series.cue +++ b/timeserieschart/schemas/time-series.cue @@ -66,6 +66,7 @@ spec: close({ lineStyle?: #lineStyle areaOpacity?: #areaOpacity format?: common.#format + stack?: bool }] #lineStyle: "solid" | "dashed" | "dotted" diff --git a/timeserieschart/sdk/go/time-series.go b/timeserieschart/sdk/go/time-series.go index 3f63ba1c9..b68cb34b7 100644 --- a/timeserieschart/sdk/go/time-series.go +++ b/timeserieschart/sdk/go/time-series.go @@ -127,6 +127,7 @@ type QuerySettingsItem struct { LineStyle string `json:"lineStyle,omitempty" yaml:"lineStyle,omitempty"` AreaOpacity float64 `json:"areaOpacity,omitempty" yaml:"areaOpacity,omitempty"` Format *common.Format `json:"format,omitempty" yaml:"format,omitempty"` + Stack *bool `json:"stack,omitempty" yaml:"stack,omitempty"` } type Option func(plugin *Builder) error diff --git a/timeserieschart/src/QuerySettingsEditor.tsx b/timeserieschart/src/QuerySettingsEditor.tsx index 9de8b8d3a..8e86da7b9 100644 --- a/timeserieschart/src/QuerySettingsEditor.tsx +++ b/timeserieschart/src/QuerySettingsEditor.tsx @@ -19,6 +19,7 @@ import { MenuItem, Slider, Stack, + Switch, TextField, ToggleButton, ToggleButtonGroup, @@ -217,6 +218,24 @@ export function QuerySettingsEditor(props: TimeSeriesChartOptionsEditorProps): R }); }; + const addStack = (i: number): void => { + updateQuerySettings(i, (qs) => { + qs.stack = false; + }); + }; + + const removeStack = (i: number): void => { + updateQuerySettings(i, (qs) => { + qs.stack = undefined; + }); + }; + + const handleStackChange = (i: number, checked: boolean): void => { + updateQuerySettings(i, (qs) => { + qs.stack = checked; + }); + }; + const handleFormatChange = (i: number, format?: FormatOptions): void => { updateQuerySettings(i, (qs) => { qs.format = format; @@ -287,6 +306,9 @@ export function QuerySettingsEditor(props: TimeSeriesChartOptionsEditorProps): R onAddFormat={() => addFormat(i)} onRemoveFormat={() => removeFormat(i)} onFormatChange={(format) => handleFormatChange(i, format)} + onAddStack={() => addStack(i)} + onRemoveStack={() => removeStack(i)} + onStackChange={(checked) => handleStackChange(i, checked)} /> )) )} @@ -319,10 +341,13 @@ interface QuerySettingsInputProps { onAddFormat: () => void; onRemoveFormat: () => void; onFormatChange: (format?: FormatOptions) => void; + onAddStack: () => void; + onRemoveStack: () => void; + onStackChange: (checked: boolean) => void; } function QuerySettingsInput({ - querySettings: { queryIndex, colorMode, colorValue, lineStyle, areaOpacity, format }, + querySettings: { queryIndex, colorMode, colorValue, lineStyle, areaOpacity, format, stack }, availableQueryIndexes, onQueryIndexChange, onColorModeChange, @@ -340,6 +365,9 @@ function QuerySettingsInput({ onAddFormat, onRemoveFormat, onFormatChange, + onAddStack, + onRemoveStack, + onStackChange, }: QuerySettingsInputProps): ReactElement { // current query index should also be selectable const selectableQueryIndexes = availableQueryIndexes.concat(queryIndex).sort((a, b) => a - b); @@ -354,8 +382,9 @@ function QuerySettingsInput({ if (!lineStyle) options.push({ key: 'lineStyle', label: 'Line Style', action: onAddLineStyle }); if (areaOpacity === undefined) options.push({ key: 'opacity', label: 'Opacity', action: onAddAreaOpacity }); if (format === undefined) options.push({ key: 'format', label: 'Format', action: onAddFormat }); + if (stack === undefined) options.push({ key: 'stack', label: 'Stack', action: onAddStack }); return options; - }, [colorMode, lineStyle, areaOpacity, format, onAddColor, onAddLineStyle, onAddAreaOpacity, onAddFormat]); + }, [colorMode, lineStyle, areaOpacity, format, stack, onAddColor, onAddLineStyle, onAddAreaOpacity, onAddFormat, onAddStack]); const handleAddMenuClick = (event: React.MouseEvent): void => { if (availableOptions.length === 1 && availableOptions[0]) { @@ -472,6 +501,18 @@ function QuerySettingsInput({ )} + {/* Stack section */} + {stack !== undefined && ( + + onStackChange(e.target.checked)} + slotProps={{ input: { 'aria-label': 'stack override' } }} + /> + + + )} + {/* Add Options Button - only show if there are available options */} {availableOptions.length > 0 && ( <> diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index 3a212dd39..9f2f3eada 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -37,6 +37,7 @@ import { TooltipComponent, } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; +import { getCommonTimeScale } from '@perses-dev/core'; import { ChartInstance, ChartInstanceFocusOpts, @@ -48,7 +49,6 @@ import { enableDataZoom, FormatOptions, getClosestTimestamp, - getCommonTimeScale, getFormattedAxis, getPointInGrid, OnEventsType, diff --git a/timeserieschart/src/TimeSeriesChartPanel.tsx b/timeserieschart/src/TimeSeriesChartPanel.tsx index b9339f638..7f8812b7e 100644 --- a/timeserieschart/src/TimeSeriesChartPanel.tsx +++ b/timeserieschart/src/TimeSeriesChartPanel.tsx @@ -42,8 +42,8 @@ import { DEFAULT_LEGEND, StepOptions, formatValue, - getTimeSeriesValues, } from '@perses-dev/components'; +import { getTimeSeriesValues } from '@perses-dev/core'; import { TimeSeries, TimeSeriesData, TimeSeriesValueTuple } from '@perses-dev/spec'; import { useAnnotationsWithData } from '@perses-dev/dashboards'; import { diff --git a/timeserieschart/src/VisualOptionsEditor.tsx b/timeserieschart/src/VisualOptionsEditor.tsx index 5026b48df..6bab71caa 100644 --- a/timeserieschart/src/VisualOptionsEditor.tsx +++ b/timeserieschart/src/VisualOptionsEditor.tsx @@ -74,7 +74,7 @@ export function VisualOptionsEditor({ value, onChange }: VisualOptionsEditorProp onChange={(__, newValue) => { const updatedValue: TimeSeriesChartVisualOptions = { ...value, - stack: newValue.id === 'none' ? undefined : newValue.id, // stack is optional so remove property when 'None' is selected + stack: newValue.id === 'none' ? undefined : newValue.id, }; // stacked area chart preset to automatically set area under a curve shading if (newValue.id === 'all' && !value.areaOpacity) { diff --git a/timeserieschart/src/time-series-chart-model.ts b/timeserieschart/src/time-series-chart-model.ts index d0458b541..ca221a626 100644 --- a/timeserieschart/src/time-series-chart-model.ts +++ b/timeserieschart/src/time-series-chart-model.ts @@ -46,6 +46,7 @@ export interface QuerySettingsOptions { lineStyle?: LineStyleType; areaOpacity?: number; format?: FormatOptions; + stack?: boolean; } export type TimeSeriesChartOptionsEditorProps = OptionsEditorProps; diff --git a/timeserieschart/src/utils/data-transform.ts b/timeserieschart/src/utils/data-transform.ts index 4048737aa..a2450fc44 100644 --- a/timeserieschart/src/utils/data-transform.ts +++ b/timeserieschart/src/utils/data-transform.ts @@ -13,6 +13,7 @@ import type { YAXisComponentOption } from 'echarts'; import { LineSeriesOption, BarSeriesOption } from 'echarts/charts'; +import { getCommonTimeScale } from '@perses-dev/core'; import { OPTIMIZED_MODE_SERIES_LIMIT, LegacyTimeSeries, @@ -20,7 +21,6 @@ import { EChartsValues, TimeSeriesOption, StepOptions, - getCommonTimeScale, } from '@perses-dev/components'; import { useTimeSeriesQueries, PanelData } from '@perses-dev/plugin-system'; import { TimeScale, TimeSeries, TimeSeriesData, TimeSeriesValueTuple } from '@perses-dev/spec'; @@ -69,11 +69,13 @@ export function getTimeSeries( visual: TimeSeriesChartVisualOptions, timeScale: TimeScale, paletteColor: string, - querySettings?: { lineStyle?: LineStyleType; areaOpacity?: number }, + querySettings?: { lineStyle?: LineStyleType; areaOpacity?: number; stack?: boolean }, yAxisIndex?: number ): TimeSeriesOption { const lineWidth = visual.lineWidth ?? DEFAULT_LINE_WIDTH; const pointRadius = visual.pointRadius ?? DEFAULT_POINT_RADIUS; + const shouldStack = + querySettings?.stack !== undefined ? querySettings.stack : visual.stack === 'all'; // Shows datapoint symbols when selected time range is roughly 15 minutes or less const minuteMs = 60000; @@ -90,7 +92,7 @@ export function getTimeSeries( datasetIndex, name: formattedName, color: paletteColor, - stack: visual.stack === 'all' ? visual.stack : undefined, + stack: shouldStack ? 'all' : undefined, yAxisIndex: yAxisIndex, label: { show: false, @@ -106,7 +108,7 @@ export function getTimeSeries( name: formattedName, connectNulls: visual.connectNulls ?? DEFAULT_CONNECT_NULLS, color: paletteColor, - stack: visual.stack === 'all' ? visual.stack : undefined, + stack: shouldStack ? 'all' : undefined, yAxisIndex: yAxisIndex, sampling: 'lttb', progressiveThreshold: OPTIMIZED_MODE_SERIES_LIMIT, // https://echarts.apache.org/en/option.html#series-lines.progressiveThreshold From 25b27cf4985414e6760b9129725608d68b6632b4 Mon Sep 17 00:00:00 2001 From: Markus Wieland <44964229+SoWieMarkus@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:00:21 +0200 Subject: [PATCH 2/6] Update timeserieschart/src/utils/data-transform.ts Co-authored-by: Akshay Iyyadurai Balasundaram Signed-off-by: Markus Wieland <44964229+SoWieMarkus@users.noreply.github.com> --- timeserieschart/src/utils/data-transform.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timeserieschart/src/utils/data-transform.ts b/timeserieschart/src/utils/data-transform.ts index a2450fc44..b558848e3 100644 --- a/timeserieschart/src/utils/data-transform.ts +++ b/timeserieschart/src/utils/data-transform.ts @@ -13,7 +13,7 @@ import type { YAXisComponentOption } from 'echarts'; import { LineSeriesOption, BarSeriesOption } from 'echarts/charts'; -import { getCommonTimeScale } from '@perses-dev/core'; +import { getCommonTimeScale } from '@perses-dev/components'; import { OPTIMIZED_MODE_SERIES_LIMIT, LegacyTimeSeries, From 010cbca6d304cc2ba3f82ce3049ba4779aca1259 Mon Sep 17 00:00:00 2001 From: Markus Wieland <44964229+SoWieMarkus@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:01:00 +0200 Subject: [PATCH 3/6] Update timeserieschart/src/TimeSeriesChartPanel.tsx Co-authored-by: Akshay Iyyadurai Balasundaram Signed-off-by: Markus Wieland <44964229+SoWieMarkus@users.noreply.github.com> --- timeserieschart/src/TimeSeriesChartPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timeserieschart/src/TimeSeriesChartPanel.tsx b/timeserieschart/src/TimeSeriesChartPanel.tsx index 7f8812b7e..c8dbe297f 100644 --- a/timeserieschart/src/TimeSeriesChartPanel.tsx +++ b/timeserieschart/src/TimeSeriesChartPanel.tsx @@ -43,7 +43,7 @@ import { StepOptions, formatValue, } from '@perses-dev/components'; -import { getTimeSeriesValues } from '@perses-dev/core'; +import { getTimeSeriesValues } from '@perses-dev/components'; import { TimeSeries, TimeSeriesData, TimeSeriesValueTuple } from '@perses-dev/spec'; import { useAnnotationsWithData } from '@perses-dev/dashboards'; import { From f694f6728291c38e697303fec2bfbda05ba72f3c Mon Sep 17 00:00:00 2001 From: Markus Wieland <44964229+SoWieMarkus@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:02:35 +0200 Subject: [PATCH 4/6] Update timeserieschart/src/TimeSeriesChartBase.tsx Co-authored-by: Akshay Iyyadurai Balasundaram Signed-off-by: Markus Wieland <44964229+SoWieMarkus@users.noreply.github.com> --- timeserieschart/src/TimeSeriesChartBase.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index 9f2f3eada..473dd63b8 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -37,7 +37,7 @@ import { TooltipComponent, } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; -import { getCommonTimeScale } from '@perses-dev/core'; +import { getCommonTimeScale } from '@perses-dev/components'; import { ChartInstance, ChartInstanceFocusOpts, From 64c38af4d4d0724a29c12f7321015ec366cf56f3 Mon Sep 17 00:00:00 2001 From: Markus Wieland Date: Tue, 23 Jun 2026 12:08:56 +0200 Subject: [PATCH 5/6] refactor: remove redundant import of getCommonTimeScale and adjust import structure Signed-off-by: Markus Wieland --- timeserieschart/src/TimeSeriesChartPanel.tsx | 2 +- timeserieschart/src/utils/data-transform.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/timeserieschart/src/TimeSeriesChartPanel.tsx b/timeserieschart/src/TimeSeriesChartPanel.tsx index c8dbe297f..b9339f638 100644 --- a/timeserieschart/src/TimeSeriesChartPanel.tsx +++ b/timeserieschart/src/TimeSeriesChartPanel.tsx @@ -42,8 +42,8 @@ import { DEFAULT_LEGEND, StepOptions, formatValue, + getTimeSeriesValues, } from '@perses-dev/components'; -import { getTimeSeriesValues } from '@perses-dev/components'; import { TimeSeries, TimeSeriesData, TimeSeriesValueTuple } from '@perses-dev/spec'; import { useAnnotationsWithData } from '@perses-dev/dashboards'; import { diff --git a/timeserieschart/src/utils/data-transform.ts b/timeserieschart/src/utils/data-transform.ts index b558848e3..17f27638d 100644 --- a/timeserieschart/src/utils/data-transform.ts +++ b/timeserieschart/src/utils/data-transform.ts @@ -13,7 +13,6 @@ import type { YAXisComponentOption } from 'echarts'; import { LineSeriesOption, BarSeriesOption } from 'echarts/charts'; -import { getCommonTimeScale } from '@perses-dev/components'; import { OPTIMIZED_MODE_SERIES_LIMIT, LegacyTimeSeries, @@ -21,6 +20,7 @@ import { EChartsValues, TimeSeriesOption, StepOptions, + getCommonTimeScale } from '@perses-dev/components'; import { useTimeSeriesQueries, PanelData } from '@perses-dev/plugin-system'; import { TimeScale, TimeSeries, TimeSeriesData, TimeSeriesValueTuple } from '@perses-dev/spec'; From a5810e499659397fc95ddeb19cd07362cae1ed0c Mon Sep 17 00:00:00 2001 From: Markus Wieland Date: Tue, 23 Jun 2026 15:41:48 +0200 Subject: [PATCH 6/6] feat: add tests for stack logic Signed-off-by: Markus Wieland --- timeserieschart/src/TimeSeriesChartPanel.tsx | 7 ++- .../src/utils/data-transform.test.ts | 43 ++++++++++++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/timeserieschart/src/TimeSeriesChartPanel.tsx b/timeserieschart/src/TimeSeriesChartPanel.tsx index b9339f638..7bd59b145 100644 --- a/timeserieschart/src/TimeSeriesChartPanel.tsx +++ b/timeserieschart/src/TimeSeriesChartPanel.tsx @@ -450,8 +450,11 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement setTimeRange({ start: new Date(event.start), end: new Date(event.end) }); }; - // Used to opt in to ECharts trigger item which show subgroup data accurately - const isStackedBar = visual.display === 'bar' && visual.stack === 'all'; + // Used to opt in to ECharts trigger item which show subgroup data accurately. + // Derived from the actual series mapping rather than `visual.stack` alone so that + // bar charts stacked only via per-query overrides also use the right tooltip mode. + const isStackedBar = + visual.display === 'bar' && timeSeriesMapping.some((s) => s?.type === 'bar' && s.stack === 'all'); // Turn on tooltip pinning by default but opt out for stacked bar or if explicitly set in tooltip panel spec let enablePinning = true; diff --git a/timeserieschart/src/utils/data-transform.test.ts b/timeserieschart/src/utils/data-transform.test.ts index 87c1decdc..cf1e752bc 100644 --- a/timeserieschart/src/utils/data-transform.test.ts +++ b/timeserieschart/src/utils/data-transform.test.ts @@ -12,8 +12,9 @@ // limitations under the License. import { LegacyTimeSeries } from '@perses-dev/components'; -import { TimeSeriesChartYAxisOptions } from '../time-series-chart-model'; -import { convertPercentThreshold, convertPanelYAxis, roundDown } from './data-transform'; +import { TimeScale } from '@perses-dev/spec'; +import { TimeSeriesChartVisualOptions, TimeSeriesChartYAxisOptions } from '../time-series-chart-model'; +import { convertPercentThreshold, convertPanelYAxis, getTimeSeries, roundDown } from './data-transform'; const MAX_VALUE = 120; const MOCK_ECHART_TIME_SERIES_DATA: LegacyTimeSeries[] = [ @@ -144,3 +145,41 @@ describe('roundDown', () => { expect(roundDown(value)).toEqual(expected); }); }); + +describe('getTimeSeries stack behavior', () => { + const TIME_SCALE: TimeScale = { startMs: 0, endMs: 60_000, stepMs: 1000, rangeMs: 60_000 }; + + // 6 combinations of (global visual.stack) x (per-query stack override) -> whether + // the resulting series should have stack === 'all'. Run for both line and bar display. + const STACK_CASES: Array<{ + label: string; + globalStack: 'all' | undefined; + querySettingsStack: boolean | undefined; + expectStacked: boolean; + }> = [ + { label: 'global=none + query=unset → not stacked', globalStack: undefined, querySettingsStack: undefined, expectStacked: false }, + { label: 'global=none + query=true → stacked', globalStack: undefined, querySettingsStack: true, expectStacked: true }, + { label: 'global=none + query=false → not stacked', globalStack: undefined, querySettingsStack: false, expectStacked: false }, + { label: 'global=all + query=unset → stacked', globalStack: 'all', querySettingsStack: undefined, expectStacked: true }, + { label: 'global=all + query=true → stacked', globalStack: 'all', querySettingsStack: true, expectStacked: true }, + { label: 'global=all + query=false → not stacked', globalStack: 'all', querySettingsStack: false, expectStacked: false }, + ]; + + describe.each(['line', 'bar'] as const)('display: %s', (display) => { + it.each(STACK_CASES)('$label', ({ globalStack, querySettingsStack, expectStacked }) => { + const visual: TimeSeriesChartVisualOptions = { display, stack: globalStack }; + const series = getTimeSeries( + 'series-id', + 0, + 'series', + visual, + TIME_SCALE, + '#000000', + { stack: querySettingsStack } + ); + + expect(series.type).toEqual(display); + expect(series.stack).toEqual(expectStacked ? 'all' : undefined); + }); + }); +});