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);