diff --git a/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.tsx b/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.tsx index 3f283b36d70..b6d51fcfb2e 100644 --- a/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.tsx +++ b/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.tsx @@ -5,6 +5,7 @@ import BarChart from 'container/DashboardContainer/visualization/charts/BarChart import { useIsDarkMode } from 'hooks/useDarkMode'; import { useResizeObserver } from 'hooks/useDimensions'; import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils'; +import { StackMode } from 'lib/uPlotV2/config/types'; import { LegendPosition, TooltipRenderArgs, @@ -131,9 +132,9 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
{containerDimensions.width > 0 && containerDimensions.height > 0 && ( { expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500); }); - it('sets stacking bands, padding, and focus alpha for behavioral parity', () => { + it('sets padding and focus alpha for behavioral parity', () => { const builder = prepareBillingBarConfig({ ...baseProps, apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']), }); const config = builder.getConfig(); - expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]); + // Stacking bands come from the chart now — see useChartStacking. expect(config.padding).toStrictEqual([32, 32, 16, 16]); expect(config.focus).toStrictEqual({ alpha: 0.3 }); }); - it('sets no bands when result is empty', () => { - const builder = prepareBillingBarConfig({ - ...baseProps, - apiResponse: makeApiResponse([]), - }); - const config = builder.getConfig(); - expect(config.bands).toBeUndefined(); - }); - it('uses queryName as label when legend is undefined', () => { const apiResponse: MetricRangePayloadProps = { data: { diff --git a/frontend/src/container/BillingContainer/BillingUsageGraph/prepareBillingBarConfig.ts b/frontend/src/container/BillingContainer/BillingUsageGraph/prepareBillingBarConfig.ts index 070d00be0b0..bbb60284a8b 100644 --- a/frontend/src/container/BillingContainer/BillingUsageGraph/prepareBillingBarConfig.ts +++ b/frontend/src/container/BillingContainer/BillingUsageGraph/prepareBillingBarConfig.ts @@ -1,7 +1,6 @@ import { Color } from '@signozhq/design-tokens'; import type { Timezone } from 'components/CustomTimePicker/timezoneUtils'; import { PANEL_TYPES } from 'constants/queryBuilder'; -import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils'; import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder'; import { DrawStyle } from 'lib/uPlotV2/config/types'; import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder'; @@ -63,7 +62,6 @@ export function prepareBillingBarConfig({ }); }); - builder.setBands(getInitialStackedBands(results.length)); builder.setPadding([32, 32, 16, 16]); builder.setFocus({ alpha: 0.3 }); diff --git a/frontend/src/container/DashboardContainer/visualization/charts/BarChart/BarChart.tsx b/frontend/src/container/DashboardContainer/visualization/charts/BarChart/BarChart.tsx index b19a37064e8..79ee74650c8 100644 --- a/frontend/src/container/DashboardContainer/visualization/charts/BarChart/BarChart.tsx +++ b/frontend/src/container/DashboardContainer/visualization/charts/BarChart/BarChart.tsx @@ -6,25 +6,24 @@ import { TooltipRenderArgs, } from 'lib/uPlotV2/components/types'; -import { useBarChartStacking } from '../../hooks/useBarChartStacking'; +import { StackMode } from 'lib/uPlotV2/config/types'; + import { BarChartProps } from '../types'; export default function BarChart(props: BarChartProps): JSX.Element { const { children, - isStackedBarChart, customTooltip, config, data, + stack = StackMode.None, pinnedTooltipElement, ...rest } = props; - const chartData = useBarChartStacking({ - data, - isStackedBarChart, - config, - }); + // Written during render so it lands before UPlotChart's effect reads the config, + // which derives the fill bands, percent axis unit and percent range from it. + config.setStackMode(stack); const renderTooltip = useCallback( (props: TooltipRenderArgs): React.ReactNode => { @@ -37,7 +36,6 @@ export default function BarChart(props: BarChartProps): JSX.Element { timezone: rest.timezone, yAxisUnit: rest.yAxisUnit, decimalPrecision: rest.decimalPrecision, - isStackedBarChart: isStackedBarChart, canPinTooltip: rest.canPinTooltip, renderTooltipFooter: rest.renderTooltipFooter, }; @@ -48,7 +46,6 @@ export default function BarChart(props: BarChartProps): JSX.Element { rest.timezone, rest.yAxisUnit, rest.decimalPrecision, - isStackedBarChart, rest.canPinTooltip, rest.renderTooltipFooter, ], @@ -58,7 +55,7 @@ export default function BarChart(props: BarChartProps): JSX.Element { diff --git a/frontend/src/container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper.tsx b/frontend/src/container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper.tsx index 191ec5af228..38c5558aac4 100644 --- a/frontend/src/container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper.tsx +++ b/frontend/src/container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper.tsx @@ -6,12 +6,15 @@ import { TooltipRenderArgs, } from 'lib/uPlotV2/components/types'; import UPlotChart from 'lib/uPlotV2/components/UPlotChart/UPlotChart'; +import { StackMode } from 'lib/uPlotV2/config/types'; +import { prepareAlignedData } from 'lib/uPlotV2/components/UPlotChart/utils'; import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext'; import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin'; import noop from 'lodash-es/noop'; import uPlot from 'uplot'; -import { ChartProps } from '../types'; +import { ChartWrapperProps } from '../types'; +import { useChartStacking } from './useChartStacking'; const TOOLTIP_WIDTH_PADDING = 120; const TOOLTIP_MIN_WIDTH = 300; @@ -39,9 +42,20 @@ export default function ChartWrapper({ pinnedTooltipElement, tooltipPortalRoot, 'data-testid': testId, -}: ChartProps): JSX.Element { +}: ChartWrapperProps): JSX.Element { const plotInstanceRef = useRef(null); + const stack = config.getStackMode(); + const chartData = useChartStacking({ data, config }); + + // Tooltips need pre-stack values, gap-processed exactly as UPlotChart processes the + // plot data — otherwise the cursor's index addresses a shorter array. + const unstackedData = useMemo( + () => + stack === StackMode.None ? undefined : prepareAlignedData({ data, config }), + [data, config, stack], + ); + const legendComponent = useCallback( (averageLegendWidth: number): React.ReactNode => { if (!showLegend) { @@ -61,11 +75,11 @@ export default function ChartWrapper({ const renderTooltipCallback = useCallback( (args: TooltipRenderArgs): React.ReactNode => { if (customTooltip) { - return customTooltip(args); + return customTooltip({ ...args, unstackedData }); } return null; }, - [customTooltip], + [customTooltip, unstackedData], ); const syncMetadata = useMemo( @@ -91,7 +105,7 @@ export default function ChartWrapper({ {({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => ( { diff --git a/frontend/src/container/DashboardContainer/visualization/charts/ChartWrapper/__tests__/useChartStacking.test.ts b/frontend/src/container/DashboardContainer/visualization/charts/ChartWrapper/__tests__/useChartStacking.test.ts new file mode 100644 index 00000000000..74f2ef6a1e3 --- /dev/null +++ b/frontend/src/container/DashboardContainer/visualization/charts/ChartWrapper/__tests__/useChartStacking.test.ts @@ -0,0 +1,98 @@ +import { renderHook } from '@testing-library/react'; +import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder'; +import { StackMode } from 'lib/uPlotV2/config/types'; +import uPlot from 'uplot'; + +import { useChartStacking } from '../useChartStacking'; + +type Hooks = Record void>; + +function createConfig(stack: StackMode): { + config: UPlotConfigBuilder; + hooks: Hooks; +} { + const hooks: Hooks = {}; + const config = { + getStackMode: (): StackMode => stack, + addHook: jest.fn((type: string, hook: (...args: unknown[]) => void) => { + hooks[type] = hook; + return jest.fn(); + }), + } as unknown as UPlotConfigBuilder; + return { config, hooks }; +} + +const data = [[1], [30], [10]] as unknown as uPlot.AlignedData; + +describe('useChartStacking', () => { + it('returns the data untouched and registers nothing when the config says `none`', () => { + const { config } = createConfig(StackMode.None); + const { result } = renderHook(() => useChartStacking({ data, config })); + + expect(result.current).toBe(data); + expect(config.addHook).not.toHaveBeenCalled(); + }); + + it('treats a missing config as unstacked', () => { + const { result } = renderHook(() => useChartStacking({ data, config: null })); + + expect(result.current).toBe(data); + }); + + it('accumulates raw values when the config declares `normal`', () => { + const { config } = createConfig(StackMode.Normal); + const { result } = renderHook(() => useChartStacking({ data, config })); + + expect(result.current).toStrictEqual([[1], [40], [10]]); + }); + + it('rescales each column to its total when the config declares `percent`', () => { + const { config } = createConfig(StackMode.Percent); + const { result } = renderHook(() => useChartStacking({ data, config })); + + expect(result.current).toStrictEqual([[1], [100], [25]]); + }); + + it('registers the uPlot hooks that re-stack on data and visibility changes', () => { + const { config } = createConfig(StackMode.Normal); + renderHook(() => useChartStacking({ data, config })); + + expect( + (config.addHook as jest.Mock).mock.calls.map(([type]) => type), + ).toStrictEqual(['setData', 'setSeries']); + }); + + it('re-stacks from the raw values when the legend hides a series', () => { + const { config, hooks } = createConfig(StackMode.Normal); + renderHook(() => useChartStacking({ data, config })); + + const plot = { + data: [[1]], + series: [{}, { show: true }, { show: false }], + delBand: jest.fn(), + addBand: jest.fn(), + setData: jest.fn(), + }; + hooks.setSeries(plot, 2, { show: false }); + + // The hidden series keeps its raw value and stops contributing to the total. + expect(plot.setData).toHaveBeenCalledWith([[1], [30], [10]]); + expect(plot.delBand).toHaveBeenCalledWith(null); + }); + + it('ignores a focus-only setSeries so hovering does not re-stack', () => { + const { config, hooks } = createConfig(StackMode.Normal); + renderHook(() => useChartStacking({ data, config })); + + const plot = { + data: [[1]], + series: [{}, { show: true }, { show: true }], + delBand: jest.fn(), + addBand: jest.fn(), + setData: jest.fn(), + }; + hooks.setSeries(plot, 1, { focus: true }); + + expect(plot.setData).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/container/DashboardContainer/visualization/charts/ChartWrapper/useChartStacking.ts b/frontend/src/container/DashboardContainer/visualization/charts/ChartWrapper/useChartStacking.ts new file mode 100644 index 00000000000..b988c47b856 --- /dev/null +++ b/frontend/src/container/DashboardContainer/visualization/charts/ChartWrapper/useChartStacking.ts @@ -0,0 +1,132 @@ +import { + MutableRefObject, + useCallback, + useLayoutEffect, + useMemo, + useRef, +} from 'react'; +import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder'; +import { StackMode } from 'lib/uPlotV2/config/types'; +import { has } from 'lodash-es'; +import uPlot from 'uplot'; + +import { stackSeries } from '../utils/stackSeriesUtils'; + +/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */ +function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean { + return !plot.series[seriesIndex]?.show; +} + +function canApplyStacking( + unstackedData: uPlot.AlignedData | null, + plot: uPlot, + isUpdating: boolean, +): boolean { + return ( + !isUpdating && + !!unstackedData && + !!plot.data && + unstackedData[0]?.length === plot.data[0]?.length + ); +} + +function setupStackingHooks( + config: UPlotConfigBuilder, + updateStacksInChart: (plot: uPlot) => void, + isUpdatingRef: MutableRefObject, +): () => void { + const onDataChange = (plot: uPlot): void => { + if (!isUpdatingRef.current) { + updateStacksInChart(plot); + } + }; + + const onSeriesVisibilityChange = ( + plot: uPlot, + _seriesIdx: number | null, + opts: uPlot.Series, + ): void => { + // uPlot fires setSeries for hover focus too; only visibility changes restack. + if (!has(opts, 'focus')) { + updateStacksInChart(plot); + } + }; + + const removeSetDataHook = config.addHook('setData', onDataChange); + const removeSetSeriesHook = config.addHook( + 'setSeries', + onSeriesVisibilityChange, + ); + + return (): void => { + removeSetDataHook?.(); + removeSetSeriesHook?.(); + }; +} + +export interface UseChartStackingParams { + data: uPlot.AlignedData; + config: UPlotConfigBuilder | null; +} + +/** + * Stacks a chart's data for the mode declared on its config, and re-stacks on data or + * visibility changes. The pre-stack values live in a ref because the uPlot hooks that + * read them run outside React's render cycle. + */ +export function useChartStacking({ + data, + config, +}: UseChartStackingParams): uPlot.AlignedData { + const stack = config?.getStackMode() ?? StackMode.None; + const unstackedDataRef = useRef(null); + unstackedDataRef.current = stack === 'none' ? null : data; + + // Guards the re-entrant setData below, which would otherwise re-trigger our own hook. + const isUpdatingChartRef = useRef(false); + + const chartData = useMemo((): uPlot.AlignedData => { + if (stack === StackMode.None || !data || data.length < 2) { + return data; + } + const noSeriesHidden = (): boolean => false; // include all series in initial stack + return stackSeries(data, noSeriesHidden, stack).data; + }, [data, stack]); + + const updateStacksInChart = useCallback( + (plot: uPlot): void => { + const unstacked = unstackedDataRef.current; + if ( + !unstacked || + !canApplyStacking(unstacked, plot, isUpdatingChartRef.current) + ) { + return; + } + + const shouldExcludeSeries = (idx: number): boolean => + isSeriesHidden(plot, idx); + const { data: stacked, bands } = stackSeries( + unstacked, + shouldExcludeSeries, + stack, + ); + + plot.delBand(null); + bands.forEach((band: uPlot.Band) => plot.addBand(band)); + + isUpdatingChartRef.current = true; + plot.setData(stacked); + isUpdatingChartRef.current = false; + }, + [stack], + ); + + useLayoutEffect(() => { + if (stack === StackMode.None || !config) { + return undefined; + } + return setupStackingHooks(config, updateStacksInChart, isUpdatingChartRef); + }, [stack, config, updateStacksInChart]); + + return chartData; +} diff --git a/frontend/src/container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries.tsx b/frontend/src/container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries.tsx index 86eced330a2..2eebdbaff3c 100644 --- a/frontend/src/container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries.tsx +++ b/frontend/src/container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries.tsx @@ -6,10 +6,16 @@ import { TooltipRenderArgs, } from 'lib/uPlotV2/components/types'; +import { StackMode } from 'lib/uPlotV2/config/types'; + import { TimeSeriesChartProps } from '../types'; export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element { - const { children, customTooltip, ...rest } = props; + const { children, customTooltip, stack = StackMode.None, ...rest } = props; + + // Written during render so it lands before UPlotChart's effect reads the config, + // which derives the fill bands, percent axis unit and percent range from it. + rest.config.setStackMode(stack); const renderTooltip = useCallback( (props: TooltipRenderArgs): React.ReactNode => { diff --git a/frontend/src/container/DashboardContainer/visualization/charts/types.ts b/frontend/src/container/DashboardContainer/visualization/charts/types.ts index a83b61a1093..dad0cc3190e 100644 --- a/frontend/src/container/DashboardContainer/visualization/charts/types.ts +++ b/frontend/src/container/DashboardContainer/visualization/charts/types.ts @@ -14,6 +14,7 @@ import { ChartClickData, } from 'lib/uPlotV2/plugins/TooltipPlugin/types'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import type { StackMode } from 'lib/uPlotV2/config/types'; interface BaseChartProps { width: number; @@ -52,26 +53,25 @@ interface UPlotChartDataProps { groupByPerQuery?: Record; } -export interface TimeSeriesChartProps - extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps { - timezone?: Timezone; -} +/** Everything the shared uPlot shell consumes; each chart's props narrow it. */ +export interface ChartWrapperProps + extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {} -export interface HistogramChartProps - extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps { - isQueriesMerged?: boolean; +export interface TimeSeriesChartProps extends ChartWrapperProps { + timezone?: Timezone; + /** How series compose. Defaults to `none`, which draws them independently. */ + stack?: StackMode; } -export interface BarChartProps - extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps { - isStackedBarChart?: boolean; +export interface BarChartProps extends ChartWrapperProps { timezone?: Timezone; + /** How series compose. Defaults to `none`, which draws them independently. */ + stack?: StackMode; } -export type ChartProps = - | TimeSeriesChartProps - | BarChartProps - | HistogramChartProps; +export interface HistogramChartProps extends ChartWrapperProps { + isQueriesMerged?: boolean; +} /** * One resolved pie/donut slice: a display label, its (already parsed) positive diff --git a/frontend/src/container/DashboardContainer/visualization/charts/utils/__tests__/stackSeriesUtils.test.ts b/frontend/src/container/DashboardContainer/visualization/charts/utils/__tests__/stackSeriesUtils.test.ts new file mode 100644 index 00000000000..52a50851f87 --- /dev/null +++ b/frontend/src/container/DashboardContainer/visualization/charts/utils/__tests__/stackSeriesUtils.test.ts @@ -0,0 +1,158 @@ +import { AlignedData } from 'uplot'; + +import { StackMode } from 'lib/uPlotV2/config/types'; + +import { stackSeries } from '../stackSeriesUtils'; + +const includeAll = (): boolean => false; + +// Stacking is top-down: the first series carries the column total, the last its own +// raw value. Every expectation below reads in that order. +describe('stackSeries', () => { + it('is a no-op under `none`, returning the data and no bands', () => { + const data: AlignedData = [[1], [30], [10]]; + + const { data: result, bands } = stackSeries(data, includeAll, StackMode.None); + + expect(result).toBe(data); + expect(bands).toStrictEqual([]); + }); + + describe('normal', () => { + it('accumulates raw values from the bottom series upward', () => { + const data: AlignedData = [ + [1, 2], + [10, 20], + [1, 2], + ]; + + expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([ + [1, 2], + [11, 22], + [1, 2], + ]); + }); + + it('treats nulls as 0 without breaking the running total', () => { + const data: AlignedData = [ + [1, 2], + [10, null], + [1, 2], + ]; + + expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([ + [1, 2], + [11, 2], + [1, 2], + ]); + }); + + it('emits one band per adjacent pair of participating series', () => { + const data: AlignedData = [[1], [10], [5], [1]]; + + expect(stackSeries(data, includeAll, StackMode.Normal).bands).toStrictEqual([ + { series: [1, 2] }, + { series: [2, 3] }, + ]); + }); + + it('copies omitted series through unstacked and skips their bands', () => { + const data: AlignedData = [[1], [10], [5], [1]]; + const omitMiddle = (seriesIndex: number): boolean => seriesIndex === 2; + + const { data: stacked, bands } = stackSeries( + data, + omitMiddle, + StackMode.Normal, + ); + + expect(stacked).toStrictEqual([[1], [11], [5], [1]]); + expect(bands).toStrictEqual([{ series: [1, 3] }]); + }); + }); + + describe('percent', () => { + it('rescales each column to its total so the top series reads 100', () => { + const data: AlignedData = [ + [1, 2], + [30, 10], + [10, 10], + ]; + + expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([ + [1, 2], + [100, 100], + [25, 50], + ]); + }); + + it('normalises per column, so an identical series differs across x', () => { + const data: AlignedData = [ + [1, 2], + [1, 3], + [1, 1], + ]; + + expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([ + [1, 2], + [100, 100], + [50, 25], + ]); + }); + + it('excludes omitted series from the total, so the visible ones still reach 100', () => { + const data: AlignedData = [[1], [30], [10], [60]]; + const omitLast = (seriesIndex: number): boolean => seriesIndex === 3; + + expect(stackSeries(data, omitLast, StackMode.Percent).data).toStrictEqual([ + [1], + [100], + [25], + [60], + ]); + }); + + it('yields 0 for a column whose participating series sum to zero', () => { + const data: AlignedData = [ + [1, 2], + [0, 5], + [0, 5], + ]; + + expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([ + [1, 2], + [0, 100], + [0, 50], + ]); + }); + + it('divides by the signed total when a column mixes signs', () => { + // 30 + (-10) = 20, so the shares are 150% and -50% and still sum to 100. + const data: AlignedData = [[1], [30], [-10]]; + + expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([ + [1], + [100], + [-50], + ]); + }); + + it('yields 0 across a column whose signed total cancels to zero', () => { + const data: AlignedData = [[1], [10], [-10]]; + + expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([ + [1], + [0], + [0], + ]); + }); + }); + + it('defaults to normal when no mode is given', () => { + const data: AlignedData = [[1], [30], [10]]; + + expect(stackSeries(data, includeAll).data).toStrictEqual( + stackSeries(data, includeAll, StackMode.Normal).data, + ); + }); +}); diff --git a/frontend/src/container/DashboardContainer/visualization/charts/utils/__tests__/stackUtils.test.ts b/frontend/src/container/DashboardContainer/visualization/charts/utils/__tests__/stackUtils.test.ts deleted file mode 100644 index 5406afa9a30..00000000000 --- a/frontend/src/container/DashboardContainer/visualization/charts/utils/__tests__/stackUtils.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { AlignedData } from 'uplot'; - -import { getInitialStackedBands, stack } from '../stackUtils'; - -describe('stackUtils', () => { - describe('stack', () => { - const neverOmit = (): boolean => false; - - it('preserves time axis as first row', () => { - const data: AlignedData = [ - [100, 200, 300], - [1, 2, 3], - [4, 5, 6], - ]; - const { data: result } = stack(data, neverOmit); - expect(result[0]).toStrictEqual([100, 200, 300]); - }); - - it('stacks value series cumulatively (last = raw, first = total)', () => { - // Time, then 3 value series. Stack order: last series stays raw, then we add upward. - const data: AlignedData = [ - [0, 1, 2], - [1, 2, 3], // series 1 - [4, 5, 6], // series 2 - [7, 8, 9], // series 3 - ]; - const { data: result } = stack(data, neverOmit); - // result[1] = s1+s2+s3, result[2] = s2+s3, result[3] = s3 - expect(result[1]).toStrictEqual([12, 15, 18]); // 1+4+7, 2+5+8, 3+6+9 - expect(result[2]).toStrictEqual([11, 13, 15]); // 4+7, 5+8, 6+9 - expect(result[3]).toStrictEqual([7, 8, 9]); - }); - - it('treats null values as 0 when stacking', () => { - const data: AlignedData = [ - [0, 1], - [1, null], - [null, 10], - ]; - const { data: result } = stack(data, neverOmit); - expect(result[1]).toStrictEqual([1, 10]); // total - expect(result[2]).toStrictEqual([0, 10]); // last series with null→0 - }); - - it('copies omitted series as-is without accumulating', () => { - // Omit series 2 (index 2); series 1 and 3 are stacked. - const data: AlignedData = [ - [0, 1], - [10, 20], // series 1 - [100, 200], // series 2 - omitted - [1, 2], // series 3 - ]; - const omitSeries2 = (i: number): boolean => i === 2; - const { data: result } = stack(data, omitSeries2); - // series 3 raw: [1, 2]; series 2 omitted: [100, 200] as-is; series 1 stacked with s3: [11, 22] - expect(result[1]).toStrictEqual([11, 22]); // 10+1, 20+2 - expect(result[2]).toStrictEqual([100, 200]); // copied, not stacked - expect(result[3]).toStrictEqual([1, 2]); - }); - - it('returns bands between consecutive visible series when none omitted', () => { - const data: AlignedData = [ - [0, 1], - [1, 2], - [3, 4], - [5, 6], - ]; - const { bands } = stack(data, neverOmit); - expect(bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]); - }); - - it('returns bands only between visible series when some are omitted', () => { - // 4 value series; omit index 2. Visible: 1, 3, 4. Bands: [1,3], [3,4] - const data: AlignedData = [[0], [1], [2], [3], [4]]; - const omitSeries2 = (i: number): boolean => i === 2; - const { bands } = stack(data, omitSeries2); - expect(bands).toStrictEqual([{ series: [1, 3] }, { series: [3, 4] }]); - }); - - it('returns empty bands when only one value series', () => { - const data: AlignedData = [ - [0, 1], - [1, 2], - ]; - const { bands } = stack(data, neverOmit); - expect(bands).toStrictEqual([]); - }); - }); - - describe('getInitialStackedBands', () => { - it('returns one band between each consecutive pair for seriesCount 3', () => { - expect(getInitialStackedBands(3)).toStrictEqual([ - { series: [1, 2] }, - { series: [2, 3] }, - ]); - }); - - it('returns empty array for seriesCount 0 or 1', () => { - expect(getInitialStackedBands(0)).toStrictEqual([]); - expect(getInitialStackedBands(1)).toStrictEqual([]); - }); - - it('returns single band for seriesCount 2', () => { - expect(getInitialStackedBands(2)).toStrictEqual([{ series: [1, 2] }]); - }); - - it('returns bands [1,2], [2,3], ..., [n-1, n] for seriesCount n', () => { - const bands = getInitialStackedBands(5); - expect(bands).toStrictEqual([ - { series: [1, 2] }, - { series: [2, 3] }, - { series: [3, 4] }, - { series: [4, 5] }, - ]); - }); - }); -}); diff --git a/frontend/src/container/DashboardContainer/visualization/charts/utils/stackSeriesUtils.ts b/frontend/src/container/DashboardContainer/visualization/charts/utils/stackSeriesUtils.ts index 1fc52321fde..5a2a94d0f3a 100644 --- a/frontend/src/container/DashboardContainer/visualization/charts/utils/stackSeriesUtils.ts +++ b/frontend/src/container/DashboardContainer/visualization/charts/utils/stackSeriesUtils.ts @@ -1,13 +1,20 @@ +import { StackMode } from 'lib/uPlotV2/config/types'; import uPlot, { AlignedData } from 'uplot'; /** * Stack data cumulatively (top-down: first series = top, last = bottom). - * When `omit(seriesIndex)` returns true, that series is excluded from stacking. + * When `omit(seriesIndex)` returns true, that series keeps its raw values and + * contributes nothing to the total. `None` is a no-op. */ export function stackSeries( data: AlignedData, omit: (seriesIndex: number) => boolean, + mode: StackMode = StackMode.Normal, ): { data: AlignedData; bands: uPlot.Band[] } { + if (mode === StackMode.None) { + return { data, bands: [] }; + } + const timeAxis = data[0]; const pointCount = timeAxis.length; const valueSeriesCount = data.length - 1; // exclude time axis @@ -17,6 +24,7 @@ export function stackSeries( valueSeriesCount, pointCount, omit, + mode, }); const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices @@ -31,6 +39,46 @@ interface BuildStackedSeriesParams { valueSeriesCount: number; pointCount: number; omit: (seriesIndex: number) => boolean; + mode: StackMode; +} + +/** Per-point total. Mixed-sign columns sum signed, as "share of total" implies. */ +function columnTotals({ + data, + valueSeriesCount, + pointCount, + omit, +}: Omit): number[] { + const totals = Array(pointCount).fill(0) as number[]; + + for (let seriesIndex = 1; seriesIndex <= valueSeriesCount; seriesIndex++) { + if (omit(seriesIndex)) { + continue; + } + const rawValues = data[seriesIndex] as (number | null)[]; + rawValues.forEach((rawValue, pointIndex) => { + totals[pointIndex] += rawValue == null ? 0 : Number(rawValue); + }); + } + + return totals; +} + +/** A column whose participating series sum to 0 has no share to divide, so every slice is 0. */ +function toPercent(value: number, total: number): number { + return total === 0 ? 0 : (value / total) * 100; +} + +/** What a raw value adds to the stack at a given point. */ +type Contribution = (value: number, pointIndex: number) => number; + +function contributionForMode(params: BuildStackedSeriesParams): Contribution { + if (params.mode !== StackMode.Percent) { + return (value): number => value; + } + // Resolved up front: totals span series the accumulation below has not reached yet. + const totals = columnTotals(params); + return (value, pointIndex): number => toPercent(value, totals[pointIndex]); } /** @@ -42,9 +90,17 @@ function buildStackedSeries({ valueSeriesCount, pointCount, omit, + mode, }: BuildStackedSeriesParams): (number | null)[][] { const stackedSeries: (number | null)[][] = Array(valueSeriesCount); const cumulativeSums = Array(pointCount).fill(0) as number[]; + const contributionOf = contributionForMode({ + data, + valueSeriesCount, + pointCount, + omit, + mode, + }); for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) { const rawValues = data[seriesIndex] as (number | null)[]; @@ -54,7 +110,10 @@ function buildStackedSeries({ } else { stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => { const numericValue = rawValue == null ? 0 : Number(rawValue); - return (cumulativeSums[pointIndex] += numericValue); + return (cumulativeSums[pointIndex] += contributionOf( + numericValue, + pointIndex, + )); }); } } @@ -101,16 +160,3 @@ function findNextVisibleSeriesIndex( } return -1; } - -/** - * Returns band indices for initial stacked state (no series omitted). - * Top-down: first series at top, band fills between consecutive series. - * uPlot band format: [upperSeriesIdx, lowerSeriesIdx]. - */ -export function getInitialStackedBands(seriesCount: number): uPlot.Band[] { - const bands: uPlot.Band[] = []; - for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) { - bands.push({ series: [seriesIndex, seriesIndex + 1] }); - } - return bands; -} diff --git a/frontend/src/container/DashboardContainer/visualization/charts/utils/stackUtils.ts b/frontend/src/container/DashboardContainer/visualization/charts/utils/stackUtils.ts deleted file mode 100644 index 1119d5faa9b..00000000000 --- a/frontend/src/container/DashboardContainer/visualization/charts/utils/stackUtils.ts +++ /dev/null @@ -1,116 +0,0 @@ -import uPlot, { AlignedData } from 'uplot'; - -/** - * Stack data cumulatively (top-down: first series = top, last = bottom). - * When `omit(seriesIndex)` returns true, that series is excluded from stacking. - */ -export function stack( - data: AlignedData, - omit: (seriesIndex: number) => boolean, -): { data: AlignedData; bands: uPlot.Band[] } { - const timeAxis = data[0]; - const pointCount = timeAxis.length; - const valueSeriesCount = data.length - 1; // exclude time axis - - const stackedSeries = buildStackedSeries({ - data, - valueSeriesCount, - pointCount, - omit, - }); - const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices - - return { - data: [timeAxis, ...stackedSeries] as AlignedData, - bands, - }; -} - -interface BuildStackedSeriesParams { - data: AlignedData; - valueSeriesCount: number; - pointCount: number; - omit: (seriesIndex: number) => boolean; -} - -/** - * Accumulate from last series upward: last series = raw values, first = total. - * Omitted series are copied as-is (no accumulation). - */ -function buildStackedSeries({ - data, - valueSeriesCount, - pointCount, - omit, -}: BuildStackedSeriesParams): (number | null)[][] { - const stackedSeries: (number | null)[][] = Array(valueSeriesCount); - const cumulativeSums = Array(pointCount).fill(0) as number[]; - - for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) { - const rawValues = data[seriesIndex] as (number | null)[]; - - if (omit(seriesIndex)) { - stackedSeries[seriesIndex - 1] = rawValues; - } else { - stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => { - const numericValue = rawValue == null ? 0 : Number(rawValue); - return (cumulativeSums[pointIndex] += numericValue); - }); - } - } - - return stackedSeries; -} - -/** - * Bands define fill between consecutive visible series for stacked appearance. - * uPlot format: [upperSeriesIdx, lowerSeriesIdx]. - */ -function buildFillBands( - seriesLength: number, - omit: (seriesIndex: number) => boolean, -): uPlot.Band[] { - const bands: uPlot.Band[] = []; - - for (let seriesIndex = 1; seriesIndex < seriesLength; seriesIndex++) { - if (omit(seriesIndex)) { - continue; - } - const nextVisibleSeriesIndex = findNextVisibleSeriesIndex( - seriesLength, - seriesIndex, - omit, - ); - if (nextVisibleSeriesIndex !== -1) { - bands.push({ series: [seriesIndex, nextVisibleSeriesIndex] }); - } - } - - return bands; -} - -function findNextVisibleSeriesIndex( - seriesLength: number, - afterIndex: number, - omit: (seriesIndex: number) => boolean, -): number { - for (let i = afterIndex + 1; i < seriesLength; i++) { - if (!omit(i)) { - return i; - } - } - return -1; -} - -/** - * Returns band indices for initial stacked state (no series omitted). - * Top-down: first series at top, band fills between consecutive series. - * uPlot band format: [upperSeriesIdx, lowerSeriesIdx]. - */ -export function getInitialStackedBands(seriesCount: number): uPlot.Band[] { - const bands: uPlot.Band[] = []; - for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) { - bands.push({ series: [seriesIndex, seriesIndex + 1] }); - } - return bands; -} diff --git a/frontend/src/container/DashboardContainer/visualization/hooks/__tests__/useBarChartStacking.test.ts b/frontend/src/container/DashboardContainer/visualization/hooks/__tests__/useBarChartStacking.test.ts deleted file mode 100644 index 024c468f2d0..00000000000 --- a/frontend/src/container/DashboardContainer/visualization/hooks/__tests__/useBarChartStacking.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import uPlot from 'uplot'; - -import type { UseBarChartStackingParams } from '../useBarChartStacking'; -import { useBarChartStacking } from '../useBarChartStacking'; - -type MockConfig = { addHook: jest.Mock }; - -function asConfig(c: MockConfig): UseBarChartStackingParams['config'] { - return c as unknown as UseBarChartStackingParams['config']; -} - -function createMockConfig(): { - config: MockConfig; - invokeSetData: (plot: uPlot) => void; - invokeSetSeries: ( - plot: uPlot, - seriesIndex: number | null, - opts: Partial & { focus?: boolean }, - ) => void; - removeSetData: jest.Mock; - removeSetSeries: jest.Mock; -} { - let setDataHandler: ((plot: uPlot) => void) | null = null; - let setSeriesHandler: - | ((plot: uPlot, seriesIndex: number | null, opts: uPlot.Series) => void) - | null = null; - - const removeSetData = jest.fn(); - const removeSetSeries = jest.fn(); - - const addHook = jest.fn( - ( - hookName: string, - handler: (plot: uPlot, ...args: unknown[]) => void, - ): (() => void) => { - if (hookName === 'setData') { - setDataHandler = handler as (plot: uPlot) => void; - return removeSetData; - } - if (hookName === 'setSeries') { - setSeriesHandler = handler as ( - plot: uPlot, - seriesIndex: number | null, - opts: uPlot.Series, - ) => void; - return removeSetSeries; - } - return jest.fn(); - }, - ); - - const config: MockConfig = { addHook }; - - const invokeSetData = (plot: uPlot): void => { - setDataHandler?.(plot); - }; - - const invokeSetSeries = ( - plot: uPlot, - seriesIndex: number | null, - opts: Partial & { focus?: boolean }, - ): void => { - setSeriesHandler?.(plot, seriesIndex, opts as uPlot.Series); - }; - - return { - config, - invokeSetData, - invokeSetSeries, - removeSetData, - removeSetSeries, - }; -} - -function createMockPlot(overrides: Partial = {}): uPlot { - return { - data: [ - [0, 1, 2], - [1, 2, 3], - [4, 5, 6], - ], - series: [{ show: true }, { show: true }, { show: true }], - delBand: jest.fn(), - addBand: jest.fn(), - setData: jest.fn(), - ...overrides, - } as unknown as uPlot; -} - -describe('useBarChartStacking', () => { - it('returns data as-is when isStackedBarChart is false', () => { - const data: uPlot.AlignedData = [ - [100, 200], - [1, 2], - [3, 4], - ]; - const { result } = renderHook(() => - useBarChartStacking({ - data, - isStackedBarChart: false, - config: null, - }), - ); - expect(result.current).toBe(data); - }); - - it('returns data as-is when config is null and isStackedBarChart is true', () => { - const data: uPlot.AlignedData = [ - [0, 1], - [1, 2], - [4, 5], - ]; - const { result } = renderHook(() => - useBarChartStacking({ - data, - isStackedBarChart: true, - config: null, - }), - ); - // Still returns stacked data (computed in useMemo); no hooks registered - expect(result.current[0]).toStrictEqual([0, 1]); - expect(result.current[1]).toStrictEqual([5, 7]); // stacked - expect(result.current[2]).toStrictEqual([4, 5]); - }); - - it('returns stacked data when isStackedBarChart is true and multiple value series', () => { - const data: uPlot.AlignedData = [ - [0, 1, 2], - [1, 2, 3], - [4, 5, 6], - [7, 8, 9], - ]; - const { result } = renderHook(() => - useBarChartStacking({ - data, - isStackedBarChart: true, - config: null, - }), - ); - expect(result.current[0]).toStrictEqual([0, 1, 2]); - expect(result.current[1]).toStrictEqual([12, 15, 18]); // s1+s2+s3 - expect(result.current[2]).toStrictEqual([11, 13, 15]); // s2+s3 - expect(result.current[3]).toStrictEqual([7, 8, 9]); - }); - - it('returns data as-is when only one value series (no stacking needed)', () => { - const data: uPlot.AlignedData = [ - [0, 1], - [1, 2], - ]; - const { result } = renderHook(() => - useBarChartStacking({ - data, - isStackedBarChart: true, - config: null, - }), - ); - expect(result.current).toStrictEqual(data); - }); - - it('registers setData and setSeries hooks when isStackedBarChart and config provided', () => { - const { config } = createMockConfig(); - const data: uPlot.AlignedData = [ - [0, 1], - [1, 2], - [3, 4], - ]; - - renderHook(() => - useBarChartStacking({ - data, - isStackedBarChart: true, - config: asConfig(config), - }), - ); - - expect(config.addHook).toHaveBeenCalledWith('setData', expect.any(Function)); - expect(config.addHook).toHaveBeenCalledWith( - 'setSeries', - expect.any(Function), - ); - }); - - it('does not register hooks when isStackedBarChart is false', () => { - const { config } = createMockConfig(); - const data: uPlot.AlignedData = [ - [0, 1], - [1, 2], - [3, 4], - ]; - - renderHook(() => - useBarChartStacking({ - data, - isStackedBarChart: false, - config: asConfig(config), - }), - ); - - expect(config.addHook).not.toHaveBeenCalled(); - }); - - it('calls cleanup when unmounted', () => { - const { config, removeSetData, removeSetSeries } = createMockConfig(); - const data: uPlot.AlignedData = [ - [0, 1], - [1, 2], - [3, 4], - ]; - - const { unmount } = renderHook(() => - useBarChartStacking({ - data, - isStackedBarChart: true, - config: asConfig(config), - }), - ); - - unmount(); - - expect(removeSetData).toHaveBeenCalled(); - expect(removeSetSeries).toHaveBeenCalled(); - }); - - it('re-stacks and updates plot when setData hook is invoked', () => { - const { config, invokeSetData } = createMockConfig(); - const data: uPlot.AlignedData = [ - [0, 1, 2], - [1, 2, 3], - [4, 5, 6], - ]; - const plot = createMockPlot({ - data: [ - [0, 1, 2], - [5, 7, 9], - [4, 5, 6], - ], - }); - - renderHook(() => - useBarChartStacking({ - data, - isStackedBarChart: true, - config: asConfig(config), - }), - ); - - invokeSetData(plot); - - expect(plot.delBand).toHaveBeenCalledWith(null); - expect(plot.addBand).toHaveBeenCalled(); - expect(plot.setData).toHaveBeenCalledWith( - expect.arrayContaining([ - [0, 1, 2], - expect.any(Array), // stacked row 1 - expect.any(Array), // stacked row 2 - ]), - ); - }); - - it('re-stacks when setSeries hook is invoked (e.g. legend toggle)', () => { - const { config, invokeSetSeries } = createMockConfig(); - const data: uPlot.AlignedData = [ - [0, 1], - [10, 20], - [5, 10], - ]; - // Plot data must match unstacked length so canApplyStacking passes - const plot = createMockPlot({ - data: [ - [0, 1], - [15, 30], - [5, 10], - ], - }); - - renderHook(() => - useBarChartStacking({ - data, - isStackedBarChart: true, - config: asConfig(config), - }), - ); - - invokeSetSeries(plot, 1, { show: false }); - - expect(plot.setData).toHaveBeenCalled(); - }); - - it('does not re-stack when setSeries is called with focus option', () => { - const { config, invokeSetSeries } = createMockConfig(); - const data: uPlot.AlignedData = [ - [0, 1], - [1, 2], - [3, 4], - ]; - const plot = createMockPlot(); - - renderHook(() => - useBarChartStacking({ - data, - isStackedBarChart: true, - config: asConfig(config), - }), - ); - - (plot.setData as jest.Mock).mockClear(); - invokeSetSeries(plot, 1, { focus: true } as uPlot.Series); - - expect(plot.setData).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/container/DashboardContainer/visualization/hooks/useBarChartStacking.ts b/frontend/src/container/DashboardContainer/visualization/hooks/useBarChartStacking.ts deleted file mode 100644 index 48b11714999..00000000000 --- a/frontend/src/container/DashboardContainer/visualization/hooks/useBarChartStacking.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { - MutableRefObject, - useCallback, - useLayoutEffect, - useMemo, - useRef, -} from 'react'; -import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder'; -import { has } from 'lodash-es'; -import uPlot from 'uplot'; - -import { stackSeries } from '../charts/utils/stackSeriesUtils'; - -/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */ -function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean { - return !plot.series[seriesIndex]?.show; -} - -function canApplyStacking( - unstackedData: uPlot.AlignedData | null, - plot: uPlot, - isUpdating: boolean, -): boolean { - return ( - !isUpdating && - !!unstackedData && - !!plot.data && - unstackedData[0]?.length === plot.data[0]?.length - ); -} - -function setupStackingHooks( - config: UPlotConfigBuilder, - applyStackingToChart: (plot: uPlot) => void, - isUpdatingRef: MutableRefObject, -): () => void { - const onDataChange = (plot: uPlot): void => { - if (!isUpdatingRef.current) { - applyStackingToChart(plot); - } - }; - - const onSeriesVisibilityChange = ( - plot: uPlot, - _seriesIdx: number | null, - opts: uPlot.Series, - ): void => { - if (!has(opts, 'focus')) { - applyStackingToChart(plot); - } - }; - - const removeSetDataHook = config.addHook('setData', onDataChange); - const removeSetSeriesHook = config.addHook( - 'setSeries', - onSeriesVisibilityChange, - ); - - return (): void => { - removeSetDataHook?.(); - removeSetSeriesHook?.(); - }; -} - -export interface UseBarChartStackingParams { - data: uPlot.AlignedData; - isStackedBarChart?: boolean; - config: UPlotConfigBuilder | null; -} - -/** - * Handles stacking for bar charts: computes initial stacked data and re-stacks - * when data or series visibility changes (e.g. legend toggles). - */ -export function useBarChartStacking({ - data, - isStackedBarChart = false, - config, -}: UseBarChartStackingParams): uPlot.AlignedData { - // Store unstacked source data so uPlot hooks can access it (hooks run outside React's render cycle) - const unstackedDataRef = useRef(null); - unstackedDataRef.current = isStackedBarChart ? data : null; - - // Prevents re-entrant calls when we update chart data (avoids infinite loop in setData hook) - const isUpdatingChartRef = useRef(false); - - const chartData = useMemo((): uPlot.AlignedData => { - if (!isStackedBarChart || !data || data.length < 2) { - return data; - } - const noSeriesHidden = (): boolean => false; // include all series in initial stack - const { data: stacked } = stackSeries(data, noSeriesHidden); - return stacked; - }, [data, isStackedBarChart]); - - const applyStackingToChart = useCallback((plot: uPlot): void => { - const unstacked = unstackedDataRef.current; - if ( - !unstacked || - !canApplyStacking(unstacked, plot, isUpdatingChartRef.current) - ) { - return; - } - - const shouldExcludeSeries = (idx: number): boolean => - isSeriesHidden(plot, idx); - const { data: stacked, bands } = stackSeries(unstacked, shouldExcludeSeries); - - plot.delBand(null); - bands.forEach((band: uPlot.Band) => plot.addBand(band)); - - isUpdatingChartRef.current = true; - plot.setData(stacked); - isUpdatingChartRef.current = false; - }, []); - - useLayoutEffect(() => { - if (!isStackedBarChart || !config) { - return undefined; - } - return setupStackingHooks(config, applyStackingToChart, isUpdatingChartRef); - }, [isStackedBarChart, config, applyStackingToChart]); - - return chartData; -} diff --git a/frontend/src/container/DashboardContainer/visualization/panels/BarPanel/BarPanel.tsx b/frontend/src/container/DashboardContainer/visualization/panels/BarPanel/BarPanel.tsx index 31bc6d6988c..d217e61e43c 100644 --- a/frontend/src/container/DashboardContainer/visualization/panels/BarPanel/BarPanel.tsx +++ b/frontend/src/container/DashboardContainer/visualization/panels/BarPanel/BarPanel.tsx @@ -22,6 +22,7 @@ import { prepareBarPanelConfig } from './utils'; import '../Panel.styles.scss'; import TooltipFooter from '../components/TooltipFooter'; import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils'; +import { StackMode } from 'lib/uPlotV2/config/types'; function BarPanel(props: PanelWrapperProps): JSX.Element { const { @@ -147,6 +148,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element { {containerDimensions.width > 0 && containerDimensions.height > 0 && ( ({ ), })); -jest.mock( - 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils', - () => ({ - getInitialStackedBands: jest.fn().mockReturnValue([]), - }), -); - const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults') .getLegend as jest.Mock; const getLabelNameMock = jest.requireMock('lib/getLabelName') .default as jest.Mock; -const getInitialStackedBandsMock = jest.requireMock( - 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils', -).getInitialStackedBands as jest.Mock; const createApiResponse = ( result: MetricRangePayloadProps['data']['result'] = [], @@ -247,36 +237,5 @@ describe('BarPanel utils', () => { }).getConfig(); expect(config.series?.[1]).toMatchObject({ stroke: '#ff0000' }); }); - - it('calls getInitialStackedBands when widget is stackedBarChart', () => { - const widget = createWidget({ stackedBarChart: true }); - const apiResponse = createApiResponse([ - { - metric: {}, - queryName: 'Q1', - values: [[1000, '1']], - } as MetricRangePayloadProps['data']['result'][0], - { - metric: {}, - queryName: 'Q2', - values: [[1000, '2']], - } as MetricRangePayloadProps['data']['result'][0], - ]); - prepareBarPanelConfig({ ...baseParams, widget, apiResponse }); - // seriesCount = result.length + 1 = 3 - expect(getInitialStackedBandsMock).toHaveBeenCalledWith(3); - }); - - it('does not call getInitialStackedBands for non-stacked chart', () => { - const apiResponse = createApiResponse([ - { - metric: {}, - queryName: 'Q1', - values: [[1000, '1']], - } as MetricRangePayloadProps['data']['result'][0], - ]); - prepareBarPanelConfig({ ...baseParams, apiResponse }); - expect(getInitialStackedBandsMock).not.toHaveBeenCalled(); - }); }); }); diff --git a/frontend/src/container/DashboardContainer/visualization/panels/BarPanel/utils.ts b/frontend/src/container/DashboardContainer/visualization/panels/BarPanel/utils.ts index 94c0ccfc665..388af58138c 100644 --- a/frontend/src/container/DashboardContainer/visualization/panels/BarPanel/utils.ts +++ b/frontend/src/container/DashboardContainer/visualization/panels/BarPanel/utils.ts @@ -1,7 +1,6 @@ import { ExecStats } from 'api/v5/v5'; import { Timezone } from 'components/CustomTimePicker/timezoneUtils'; import { PANEL_TYPES } from 'constants/queryBuilder'; -import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils'; import { getLegend } from 'lib/dashboard/getQueryResults'; import getLabelName from 'lib/getLabelName'; import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin'; @@ -69,11 +68,6 @@ export function prepareBarPanelConfig({ return builder; } - if (widget.stackedBarChart) { - const seriesCount = (apiResponse.data.result.length ?? 0) + 1; // +1 for 1-based uPlot series indices - builder.setBands(getInitialStackedBands(seriesCount)); - } - apiResponse.data.result.forEach((series) => { const baseLabelName = getLabelName( series.metric, diff --git a/frontend/src/container/MeterExplorer/Explorer/TimeSeries.tsx b/frontend/src/container/MeterExplorer/Explorer/TimeSeries.tsx index e25d3288c02..14a4683add4 100644 --- a/frontend/src/container/MeterExplorer/Explorer/TimeSeries.tsx +++ b/frontend/src/container/MeterExplorer/Explorer/TimeSeries.tsx @@ -9,6 +9,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode'; import { useResizeObserver } from 'hooks/useDimensions'; import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit'; import { LegendPosition } from 'lib/uPlotV2/components/types'; +import { StackMode } from 'lib/uPlotV2/config/types'; import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils'; import { useTimezone } from 'providers/Timezone'; import { AppState } from 'store/reducers'; @@ -137,6 +138,7 @@ function TimeSeries({ key={`${WIDGET_ID}-${index}`} > diff --git a/frontend/src/container/MeterExplorer/Explorer/configBuilder.ts b/frontend/src/container/MeterExplorer/Explorer/configBuilder.ts index 596480fc57b..1369f27f28e 100644 --- a/frontend/src/container/MeterExplorer/Explorer/configBuilder.ts +++ b/frontend/src/container/MeterExplorer/Explorer/configBuilder.ts @@ -1,6 +1,5 @@ import { Timezone } from 'components/CustomTimePicker/timezoneUtils'; import { PANEL_TYPES } from 'constants/queryBuilder'; -import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils'; import { getLegend } from 'lib/dashboard/getQueryResults'; import getLabelName from 'lib/getLabelName'; import { @@ -89,9 +88,6 @@ export function buildMeterChartConfig({ return builder; } - const seriesCount = (apiResponse.data.result.length ?? 0) + 1; - builder.setBands(getInitialStackedBands(seriesCount)); - apiResponse.data.result.forEach((series) => { const baseLabelName = getLabelName( series.metric, diff --git a/frontend/src/lib/uPlotV2/components/Tooltip/BarChartTooltip.tsx b/frontend/src/lib/uPlotV2/components/Tooltip/BarChartTooltip.tsx index cd4c8537897..bbe27ec4aef 100644 --- a/frontend/src/lib/uPlotV2/components/Tooltip/BarChartTooltip.tsx +++ b/frontend/src/lib/uPlotV2/components/Tooltip/BarChartTooltip.tsx @@ -9,6 +9,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element { (): TooltipContentItem[] => buildTooltipContent({ data: props.uPlotInstance.data, + unstackedData: props.unstackedData, series: props.uPlotInstance.series, dataIndexes: props.dataIndexes, activeSeriesIndex: props.seriesIndex, @@ -21,6 +22,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element { }), [ props.uPlotInstance, + props.unstackedData, props.seriesIndex, props.dataIndexes, props.yAxisUnit, diff --git a/frontend/src/lib/uPlotV2/components/Tooltip/TimeSeriesTooltip.tsx b/frontend/src/lib/uPlotV2/components/Tooltip/TimeSeriesTooltip.tsx index ff5ce289c6f..a98e4e77082 100644 --- a/frontend/src/lib/uPlotV2/components/Tooltip/TimeSeriesTooltip.tsx +++ b/frontend/src/lib/uPlotV2/components/Tooltip/TimeSeriesTooltip.tsx @@ -11,6 +11,7 @@ export default function TimeSeriesTooltip( (): TooltipContentItem[] => buildTooltipContent({ data: props.uPlotInstance.data, + unstackedData: props.unstackedData, series: props.uPlotInstance.series, dataIndexes: props.dataIndexes, activeSeriesIndex: props.seriesIndex, @@ -22,6 +23,7 @@ export default function TimeSeriesTooltip( }), [ props.uPlotInstance, + props.unstackedData, props.seriesIndex, props.dataIndexes, props.yAxisUnit, diff --git a/frontend/src/lib/uPlotV2/components/Tooltip/__tests__/utils.test.ts b/frontend/src/lib/uPlotV2/components/Tooltip/__tests__/utils.test.ts index 031691de920..48ce20c25d0 100644 --- a/frontend/src/lib/uPlotV2/components/Tooltip/__tests__/utils.test.ts +++ b/frontend/src/lib/uPlotV2/components/Tooltip/__tests__/utils.test.ts @@ -72,6 +72,35 @@ describe('Tooltip utils', () => { expect(result).toBe(20); }); + it('reports the pre-stack value, identically for normal and percent', () => { + const unstackedData: AlignedData = [[0], [30], [10]]; + const series = [{}, { show: true }, { show: true }] as Series[]; + const read = (data: AlignedData): number | null => + getTooltipBaseValue({ + data, + unstackedData, + index: 1, + dataIndex: 0, + isStackedBarChart: true, + series, + }); + + expect(read([[0], [40], [10]])).toBe(30); + expect(read([[0], [100], [25]])).toBe(30); + }); + + it('falls back to subtraction when no pre-stack data is given', () => { + const result = getTooltipBaseValue({ + data: [[0], [40], [10]], + index: 1, + dataIndex: 0, + isStackedBarChart: true, + series: [{}, { show: true }, { show: true }] as Series[], + }); + + expect(result).toBe(30); + }); + it('returns null when value is missing', () => { const data: AlignedData = [ [0, 1], diff --git a/frontend/src/lib/uPlotV2/components/Tooltip/utils.ts b/frontend/src/lib/uPlotV2/components/Tooltip/utils.ts index 4846bf5f29b..f1fa93419bc 100644 --- a/frontend/src/lib/uPlotV2/components/Tooltip/utils.ts +++ b/frontend/src/lib/uPlotV2/components/Tooltip/utils.ts @@ -23,17 +23,25 @@ export function resolveSeriesColor( export function getTooltipBaseValue({ data, + unstackedData, index, dataIndex, isStackedBarChart, series, }: { data: AlignedData; + unstackedData?: AlignedData; index: number; dataIndex: number; isStackedBarChart?: boolean; series?: Series[]; }): number | null { + // The subtraction below only recovers the raw value under `normal` stacking. + const unstackedSeries = unstackedData?.[index]; + if (unstackedSeries) { + return unstackedSeries[dataIndex] ?? null; + } + let baseValue = data[index][dataIndex] ?? null; // Top-down stacking (first series at top): raw = stacked[i] - stacked[nextVisible]. // When series are hidden, we must use the next *visible* series, not index+1, @@ -56,6 +64,7 @@ export function getTooltipBaseValue({ export function buildTooltipContent({ data, + unstackedData, series, dataIndexes, activeSeriesIndex, @@ -67,6 +76,7 @@ export function buildTooltipContent({ syncFilterMode, }: { data: AlignedData; + unstackedData?: AlignedData; series: Series[]; dataIndexes: Array; activeSeriesIndex: number | null; @@ -115,6 +125,7 @@ export function buildTooltipContent({ const baseValue = getTooltipBaseValue({ data, + unstackedData, index: seriesIndex, dataIndex, isStackedBarChart, diff --git a/frontend/src/lib/uPlotV2/components/types.ts b/frontend/src/lib/uPlotV2/components/types.ts index d8c8fa031e5..e5b9b5d6070 100644 --- a/frontend/src/lib/uPlotV2/components/types.ts +++ b/frontend/src/lib/uPlotV2/components/types.ts @@ -69,6 +69,11 @@ export interface TooltipRenderArgs { syncedSeriesIndexes?: number[] | null; /** Receiver-side filter mode for the synced tooltip. Defaults to Filtered. */ syncFilterMode?: SyncTooltipFilterMode; + /** + * Pre-stack values, injected by `ChartWrapper`. `Percent` discards the column total, + * so the raw value cannot be recovered from the plot's own cumulative data. + */ + unstackedData?: uPlot.AlignedData; } export interface IRenderTooltipFooterArgs { diff --git a/frontend/src/lib/uPlotV2/config/UPlotConfigBuilder.ts b/frontend/src/lib/uPlotV2/config/UPlotConfigBuilder.ts index f90f76d5e27..beab435393f 100644 --- a/frontend/src/lib/uPlotV2/config/UPlotConfigBuilder.ts +++ b/frontend/src/lib/uPlotV2/config/UPlotConfigBuilder.ts @@ -20,6 +20,7 @@ import { ConfigBuilderProps, LegendItem, SelectionPreferencesSource, + StackMode, } from './types'; import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder'; import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder'; @@ -28,6 +29,11 @@ import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder'; /** * Type definitions for uPlot option objects */ +/** Renders a 0–100 number as `50%`, unlike the 0–1 `percentunit`. */ +const PERCENT_AXIS_UNIT = 'percent'; + +const PERCENT_AXIS_MAX = 100; + type LegendConfig = { show?: boolean; live?: boolean; @@ -57,6 +63,8 @@ export class UPlotConfigBuilder extends ConfigBuilder< private bands: uPlot.Band[] = []; + private stackMode: StackMode = StackMode.None; + private cursor: Cursor | undefined; private hooks: Hooks.Arrays = {}; @@ -143,6 +151,15 @@ export class UPlotConfigBuilder extends ConfigBuilder< this.axes[scaleKey] = new UPlotAxisBuilder(props); } + /** Drives the fill bands, the percent axis unit and the percent range below. */ + setStackMode(stackMode: StackMode): void { + this.stackMode = stackMode; + } + + getStackMode(): StackMode { + return this.stackMode; + } + /** * Add or merge a scale configuration */ @@ -211,6 +228,41 @@ export class UPlotConfigBuilder extends ConfigBuilder< this.bands = bands; } + /** + * The panel's own limits are in the source unit, which means nothing once values are + * normalised. Soft rather than hard, so mixed-sign shares outside 0–100 stay visible. + */ + private resolveScale(scale: UPlotScaleBuilder): UPlotScaleBuilder { + if (this.stackMode !== StackMode.Percent || scale.props.scaleKey !== 'y') { + return scale; + } + return new UPlotScaleBuilder({ + ...scale.props, + min: undefined, + max: undefined, + softMin: 0, + softMax: PERCENT_AXIS_MAX, + // Thresholds still draw, but a 500ms one must not stretch the axis to 0–500. + thresholds: undefined, + }); + } + + /** Explicit bands win; otherwise a stack fills between consecutive series. */ + private resolveBands(): uPlot.Band[] | undefined { + if (this.bands.length > 0) { + return this.bands; + } + if (this.stackMode === StackMode.None || this.series.length < 2) { + return undefined; + } + return ( + this.series + .slice(0, -1) + // uPlot series are 1-based (index 0 is the timestamp axis). + .map((_, index) => ({ series: [index + 1, index + 2] as [number, number] })) + ); + } + /** * Set cursor configuration */ @@ -444,9 +496,19 @@ export class UPlotConfigBuilder extends ConfigBuilder< }; }), ]; - config.axes = Object.values(this.axes).map((a) => a.getConfig()); + config.axes = Object.entries(this.axes).map(([scaleKey, axis]) => { + if (scaleKey !== 'y' || this.stackMode !== StackMode.Percent) { + return axis.getConfig(); + } + // Ticks read as percentages; the panel unit still applies to tooltips and + // thresholds, so build from a copy rather than touching the axis props. + return new UPlotAxisBuilder({ + ...axis.props, + yAxisUnit: PERCENT_AXIS_UNIT, + }).getConfig(); + }); config.scales = this.scales.reduce( - (acc, s) => ({ ...acc, ...s.getConfig() }), + (acc, s) => ({ ...acc, ...this.resolveScale(s).getConfig() }), {} as Record, ); @@ -456,7 +518,7 @@ export class UPlotConfigBuilder extends ConfigBuilder< config.cursor = this.getCursorConfig(); config.tzDate = this.tzDate; config.plugins = this.plugins.length > 0 ? this.plugins : undefined; - config.bands = this.bands.length > 0 ? this.bands : undefined; + config.bands = this.resolveBands(); if (Array.isArray(this.padding)) { config.padding = this.padding; diff --git a/frontend/src/lib/uPlotV2/config/__tests__/UPlotConfigBuilder.test.ts b/frontend/src/lib/uPlotV2/config/__tests__/UPlotConfigBuilder.test.ts index 99171e47d40..610c1dc65f1 100644 --- a/frontend/src/lib/uPlotV2/config/__tests__/UPlotConfigBuilder.test.ts +++ b/frontend/src/lib/uPlotV2/config/__tests__/UPlotConfigBuilder.test.ts @@ -5,7 +5,7 @@ import { STEP_INTERVAL_MULTIPLIER, } from '../../constants'; import type { SeriesProps } from '../types'; -import { DrawStyle, SelectionPreferencesSource } from '../types'; +import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types'; import { UPlotConfigBuilder } from '../UPlotConfigBuilder'; // Mock only the real boundary that hits localStorage @@ -496,3 +496,161 @@ describe('UPlotConfigBuilder', () => { expect(config.bands).toBeUndefined(); }); }); + +describe('UPlotConfigBuilder stacking', () => { + beforeEach(() => { + jest.clearAllMocks(); + getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue([]); + }); + + /** + * Soft limits end up captured in the scale's range closure, so the only way to read + * them back is to run it and inspect the range config it hands uPlot. + */ + function scaleSoftLimits( + builder: UPlotConfigBuilder, + scaleKey: string, + ): { min: number; max: number } { + const rangeNum = jest.fn().mockReturnValue([0, 0]); + (uPlot as unknown as { rangeNum: unknown }).rangeNum = rangeNum; + + const range = builder.getConfig().scales?.[scaleKey]?.range as ( + u: unknown, + min: number, + max: number, + key: string, + ) => void; + range({ scales: { [scaleKey]: { distr: 1 } } }, 40, 60, scaleKey); + + const [, , rangeConfig] = rangeNum.mock.calls[0] as [ + number, + number, + { min: { soft: number }; max: { soft: number } }, + ]; + return { min: rangeConfig.min.soft, max: rangeConfig.max.soft }; + } + + /** Renders y-axis ticks the way uPlot would, so unit formatting is observable. */ + function yAxisTicks(builder: UPlotConfigBuilder, ticks: number[]): string[] { + const yAxis = builder.getConfig().axes?.find((a) => a.scale === 'y'); + const values = yAxis?.values as ( + u: unknown, + splits: number[], + ) => (string | null)[]; + return values(null, ticks).map((v) => String(v)); + } + + function builderFor(stack?: StackMode, seriesCount = 3): UPlotConfigBuilder { + const builder = new UPlotConfigBuilder({ id: 'stack-test' }); + if (stack) { + builder.setStackMode(stack); + } + builder.addAxis({ scaleKey: 'y', show: true, side: 3, yAxisUnit: 'ms' }); + for (let i = 0; i < seriesCount; i++) { + builder.addSeries({ + scaleKey: 'y', + label: `S${i}`, + drawStyle: DrawStyle.Bar, + colorMapping: {}, + isDarkMode: false, + } as SeriesProps); + } + return builder; + } + + it('defaults to no stacking, so no bands and the panel unit on the axis', () => { + const builder = builderFor(); + + expect(builder.getStackMode()).toBe('none'); + expect(builder.getConfig().bands).toBeUndefined(); + expect(yAxisTicks(builder, [1000])).toStrictEqual(['1 s']); + }); + + it('derives one band per adjacent series pair once a stack is declared', () => { + expect(builderFor(StackMode.Normal).getConfig().bands).toStrictEqual([ + { series: [1, 2] }, + { series: [2, 3] }, + ]); + }); + + it('emits no bands for a single series', () => { + expect(builderFor(StackMode.Normal, 1).getConfig().bands).toBeUndefined(); + }); + + it('keeps the panel unit on the axis for a normal stack', () => { + expect(yAxisTicks(builderFor(StackMode.Normal), [1000])).toStrictEqual([ + '1 s', + ]); + }); + + it('formats the axis as percentages for a percent stack', () => { + expect(yAxisTicks(builderFor(StackMode.Percent), [0, 50, 100])).toStrictEqual( + ['0%', '50%', '100%'], + ); + }); + + it('leaves other axes on their own unit under a percent stack', () => { + const builder = builderFor(StackMode.Percent); + builder.addAxis({ scaleKey: 'x', show: true, side: 2 }); + + expect(builder.getConfig().axes?.map((a) => a.scale)).toStrictEqual([ + 'y', + 'x', + ]); + }); + + it('pins the y scale to the 0–100 band under a percent stack, dropping panel limits', () => { + const builder = new UPlotConfigBuilder({ id: 'stack-scale' }); + builder.setStackMode(StackMode.Percent); + builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 }); + + // Soft, not hard: mixed-sign shares fall outside 0–100 and must stay visible. + expect(builder.getConfig().scales?.y).toMatchObject({ auto: true }); + expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 0, max: 100 }); + }); + + it('leaves the panel limits alone when the stack is not percent', () => { + const builder = new UPlotConfigBuilder({ id: 'stack-scale' }); + builder.setStackMode(StackMode.Normal); + builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 }); + + expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 5, max: 500 }); + }); + + it.each([StackMode.Normal, StackMode.Percent])( + 'draws thresholds under a %s stack', + (stack) => { + const builder = new UPlotConfigBuilder({ id: 'stack-thr' }); + builder.setStackMode(stack); + builder.addThresholds({ + scaleKey: 'y', + thresholds: [{ thresholdValue: 500, thresholdColor: 'red' }], + yAxisUnit: 'ms', + }); + + expect(builder.getConfig().hooks?.draw).toHaveLength(1); + }, + ); + + it('keeps a source-unit threshold from stretching the percent band', () => { + const builder = new UPlotConfigBuilder({ id: 'stack-thr' }); + builder.setStackMode(StackMode.Percent); + const thresholds = { + scaleKey: 'y', + thresholds: [{ thresholdValue: 500, thresholdColor: 'red' }], + yAxisUnit: 'ms', + }; + builder.addThresholds(thresholds); + builder.addScale({ scaleKey: 'y', thresholds }); + + // Without this the 500ms threshold would widen a percentage axis to 0–500. + expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 0, max: 100 }); + }); + + it('lets explicit bands win over the derived ones', () => { + const builder = builderFor(StackMode.Normal); + builder.setBands([{ series: [1, 3] }]); + + expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]); + }); +}); diff --git a/frontend/src/lib/uPlotV2/config/types.ts b/frontend/src/lib/uPlotV2/config/types.ts index 1d765bbb914..a699d0b5c51 100644 --- a/frontend/src/lib/uPlotV2/config/types.ts +++ b/frontend/src/lib/uPlotV2/config/types.ts @@ -33,6 +33,13 @@ export enum SelectionPreferencesSource { /** * Props for configuring the uPlot config builder */ +/** `Percent` rescales each x-slice to its column total, so every column fills to 100. */ +export enum StackMode { + None = 'none', + Normal = 'normal', + Percent = 'percent', +} + export interface ConfigBuilderProps { id: string; onDragSelect?: (startTime: number, endTime: number) => void; diff --git a/frontend/src/lib/uPlotV2/utils/__tests__/dataUtils.test.ts b/frontend/src/lib/uPlotV2/utils/__tests__/dataUtils.test.ts index 49e4fd5cabd..40ed3b37813 100644 --- a/frontend/src/lib/uPlotV2/utils/__tests__/dataUtils.test.ts +++ b/frontend/src/lib/uPlotV2/utils/__tests__/dataUtils.test.ts @@ -281,3 +281,20 @@ describe('dataUtils', () => { }); }); }); + +describe('insertLargeGapNullsIntoAlignedData index alignment', () => { + // ChartWrapper gap-processes the pre-stack series to keep tooltip indices aligned; + // that only holds because insertions are decided from the x axis, never from y. + it('inserts at the same positions regardless of the y values', () => { + const x = [0, 100, 200]; + const options = [{ spanGaps: 50 }]; + const raw = [x, [1, 2, 3]] as uPlot.AlignedData; + const stacked = [x, [10, 20, 30]] as uPlot.AlignedData; + + const fromRaw = insertLargeGapNullsIntoAlignedData(raw, options); + const fromStacked = insertLargeGapNullsIntoAlignedData(stacked, options); + + expect(fromRaw[0]).toStrictEqual(fromStacked[0]); + expect(fromRaw[1]).toHaveLength((fromStacked[1] as unknown[]).length); + }); +}); diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/Panels/kinds/BarChartPanel/Renderer.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/Panels/kinds/BarChartPanel/Renderer.tsx index 5f7ce16165a..27077c6b671 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/Panels/kinds/BarChartPanel/Renderer.tsx +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/Panels/kinds/BarChartPanel/Renderer.tsx @@ -7,6 +7,7 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ import { useIsDarkMode } from 'hooks/useDarkMode'; import { useResizeObserver } from 'hooks/useDimensions'; import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types'; +import { StackMode } from 'lib/uPlotV2/config/types'; import { flattenTimeSeries, getExecStats, @@ -219,7 +220,9 @@ function BarPanelRenderer({ height={containerDimensions.height} syncMode={dashboardPreference?.syncMode} syncFilterMode={dashboardPreference?.syncFilterMode} - isStackedBarChart={spec.visualization?.stackedBarChart ?? false} + stack={ + spec.visualization?.stackedBarChart ? StackMode.Normal : StackMode.None + } renderTooltipFooter={renderTooltipFooter} onClick={enableDrillDown ? handleChartClick : undefined} /> diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/Panels/kinds/BarChartPanel/utils/buildConfig.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/Panels/kinds/BarChartPanel/utils/buildConfig.ts index cc3a68ccabe..3bf78a50905 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardContainer/Panels/kinds/BarChartPanel/utils/buildConfig.ts +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/Panels/kinds/BarChartPanel/utils/buildConfig.ts @@ -1,7 +1,6 @@ import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas'; import { Timezone } from 'components/CustomTimePicker/timezoneUtils'; import { PANEL_TYPES } from 'constants/queryBuilder'; -import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils'; import { PanelMode } from 'container/DashboardContainer/visualization/panels/types'; import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder'; import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel'; @@ -101,12 +100,6 @@ function addSeries({ }: AddSeriesArgs): void { const colorMapping = spec.legend?.customColors ?? {}; - if (spec.visualization?.stackedBarChart) { - // uPlot uses 1-based series indices (index 0 is the timestamp axis); - // `+1` keeps the band targets aligned with the series we're about to add. - builder.setBands(getInitialStackedBands(series.length + 1)); - } - series.forEach((s) => { const baseLabel = getLabelName(s.labels, s.queryName, s.legend); const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);