Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 19 additions & 13 deletions packages/x-charts/src/BarChart/extremums.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ const buildData = (
data: number[],
layout: 'vertical' | 'horizontal' = 'vertical',
): Parameters<CartesianExtremumGetter<'bar'>>[0] => {
const stackData: [number, number][] = data.length
? [
[data[0], data[1]],
[data[2], data[3]],
]
: [];

return {
series: {
id1: {
Expand All @@ -13,13 +20,10 @@ const buildData = (
color: 'red',
data,
minBarSize: 0,
stackedData: data.length
? [
[data[0], data[1]],
[data[2], data[3]],
]
: [],
stackedData: stackData,
visibleStackedData: stackData,
layout,
hidden: false,
valueFormatter: () => '',
},
},
Expand All @@ -41,7 +45,12 @@ const buildDataWithAxisId = (
layout === 'horizontal'
? { yAxisId: 'axis-id', xAxisId: 'other-id' }
: { xAxisId: 'axis-id', yAxisId: 'other-id' };

const stackData: [number, number][] = data.length
? [
[data[0], data[1]],
[data[2], data[3]],
]
: [];
return {
series: {
id1: {
Expand All @@ -50,13 +59,10 @@ const buildDataWithAxisId = (
color: 'red',
data,
minBarSize: 0,
stackedData: data.length
? [
[data[0], data[1]],
[data[2], data[3]],
]
: [],
stackedData: stackData,
visibleStackedData: stackData,
layout,
hidden: false,
valueFormatter: () => '',
...axesIds,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type BarDataset = DatasetType<number | null>;
const barValueFormatter = ((v) =>
v == null ? '' : v.toLocaleString()) as DefaultizedBarSeriesType['valueFormatter'];

const seriesProcessor: SeriesProcessor<'bar'> = (params, dataset) => {
const seriesProcessor: SeriesProcessor<'bar'> = (params, dataset, isIdentifierVisible) => {
const { seriesOrder, series } = params;
const stackingGroups = getStackingGroups(params);

Expand Down Expand Up @@ -66,41 +66,101 @@ const seriesProcessor: SeriesProcessor<'bar'> = (params, dataset) => {

const completedSeries: {
[id: string]: DefaultizedBarSeriesType & {
visibleStackedData: [number, number][];
stackedData: [number, number][];
};
} = {};

stackingGroups.forEach((stackingGroup) => {
const { ids, stackingOffset, stackingOrder } = stackingGroup;
// Get stacked values, and derive the domain
const stackedSeries = d3Stack<any, DatasetElementType<number | null>, SeriesId>()
.keys(
ids.map((id) => {
// Use dataKey if needed and available
const dataKey = series[id].dataKey;
return series[id].data === undefined && dataKey !== undefined ? dataKey : id;
}),
)
const keys = ids.map((id) => {
// Use dataKey if needed and available
const dataKey = series[id].dataKey;
return series[id].data === undefined && dataKey !== undefined ? dataKey : id;
});

const stackedData = d3Stack<any, DatasetElementType<number | null>, SeriesId>()
.keys(keys)
.value((d, key) => d[key] ?? 0) // defaultize null value to 0
.order(stackingOrder)
.offset(stackingOffset)(d3Dataset);

// We sort the keys based on the original stacking order to ensure consistency
const idOrder = stackedData.sort((a, b) => a.index - b.index).map((s) => s.key);
Comment on lines +88 to +89
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about why this is needed. For me D3 takes care of the ordering with stackingOrder

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have to use the exact same order as stackedData.

IIRC the issue was that some ordering functions change the order based on value, so if we have value 0 for a hidden series it would be ordered first/last in the stack.


// Compute visible stacked data
const visibleStackedData = d3Stack<any, DatasetElementType<number | null>, SeriesId>()
.keys(idOrder)
.value((d, key) => {
const keyIndex = keys.indexOf(key);
const seriesId = ids[keyIndex];

if (!isIdentifierVisible?.(`${seriesId}`)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assunme the `${seriesId}` is here to be sure seriesId are string

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, I could move it into the isIdentifierVisible though

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't SeriesId be a number as well?

// For hidden series, return 0 so they don't contribute to the stack
return 0;
}
return d[key] ?? 0;
})
.offset(stackingOffset)(d3Dataset);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would assume the stackingOrder is needed for the visible stack and not for the one with all series.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mentioned above

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be happy if modifying "stack order" could transform France in a bicycle country :)

But I need to face the reality, you've a mismatch between the series and their value ;)

image

https://deploy-preview-20404--material-ui-x.netlify.app/x/react-charts/stacking/#stack-order

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've create #20596 to test this case


// Post-process visibleStackedData to fix positions for hidden series
// Hidden series should collapse to the cumulative position of visible series before them
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure about this part.

On one side, the animations it creates look nice. On the other one, it looks like something that should be the responsability of the animation, not the data processing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hum, I think I can achieve the same by tweaking the animation logic. Let me try

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the necessary changes: 114188a
seems reasonable @bernardobelchior wdyt?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its missing line chart adjustments that needs to be made, but it works nicely for barchart, animations are the same

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My only remaining concern would be performances, because we render all the items even the one that ends up being hidden.

But I assume this could be simply solved by something like "if animation is disabled, useBarPlotData filter out hidden series"

visibleStackedData.forEach((layer, layerIndex) => {
const key = idOrder[layerIndex];
const keyIndex = keys.indexOf(key);
const seriesId = ids[keyIndex];

if (!isIdentifierVisible?.(`${seriesId}`)) {
layer.forEach((point, pointIndex) => {
// Get the original value to determine if it's negative or positive
const originalValue = d3Dataset[pointIndex]?.[key] ?? 0;
const isNegative = originalValue < 0;

// Calculate the cumulative sum of all visible series before this one
// Only accumulate values with the same sign
let cumulativeSum = 0;
for (let i = 0; i < layerIndex; i += 1) {
const prevKey = idOrder[i];
const prevKeyIndex = keys.indexOf(prevKey);
const prevSeriesId = ids[prevKeyIndex];

if (isIdentifierVisible?.(`${prevSeriesId}`)) {
const value = d3Dataset[pointIndex]?.[prevKey] ?? 0;
const isPrevNegative = value < 0;

// Only accumulate if both have the same sign
if (isNegative === isPrevNegative) {
cumulativeSum += value;
}
}
}

// Set both start and end to the cumulative position (zero height/width)
point[0] = cumulativeSum;
point[1] = cumulativeSum;
});
}
});

ids.forEach((id, index) => {
const dataKey = series[id].dataKey;
const data = dataKey
? dataset!.map((d) => {
const value = d[dataKey];
return typeof value === 'number' ? value : null;
})
: series[id].data!;
const hidden = !isIdentifierVisible?.(`${id}`);
completedSeries[id] = {
layout: 'vertical',
labelMarkType: 'square',
minBarSize: 0,
valueFormatter: series[id].valueFormatter ?? barValueFormatter,
...series[id],
data: dataKey
? dataset!.map((data) => {
const value = data[dataKey];

return typeof value === 'number' ? value : null;
})
: series[id].data!,
stackedData: stackedSeries[index].map(([a, b]) => [a, b]),
data,
hidden,
stackedData: stackedData[index] as [number, number][],
visibleStackedData: visibleStackedData[index] as [number, number][],
};
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const tooltipItemPositionGetter: TooltipItemPositionGetter<'bar'> = (params) =>
dataIndex: identifier.dataIndex,
numberOfGroups: series.bar.stackingGroups.length,
groupIndex: series.bar.stackingGroups.findIndex((group) => group.ids.includes(itemSeries.id)),
isSeriesVisible: true,
});

if (dimensions == null) {
Expand Down
1 change: 1 addition & 0 deletions packages/x-charts/src/BarChart/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface ProcessedBarData extends AnimationData {
color: string;
value: number | null;
maskId: string;
hidden: boolean;
}

export interface MaskData extends AnimationData {
Expand Down
3 changes: 3 additions & 0 deletions packages/x-charts/src/BarChart/useBarPlotData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export function useBarPlotData(
dataIndex,
numberOfGroups: stackingGroups.length,
groupIndex,
isSeriesVisible: !series[seriesId].hidden,
});

if (barDimensions == null) {
Expand All @@ -86,6 +87,8 @@ export function useBarPlotData(
const result = {
seriesId,
dataIndex,
layout: series[seriesId].layout,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need the layout here? It doesn't seem to be in the types.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a few of your comments can be answered by the description 😆
This PR is a split from #20404 to simplify review, so it is expected some parts are not used yet.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is a split from #20404 to simplify review, so it is expected some parts are not used yet.

Yeah, makes sense, but I don't know which parts 😄

hidden: series[seriesId].hidden,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this being used? Couldn't find it. It seems it's enough to provide it to getBarDimensions.

...barDimensions,
color: colorGetter(dataIndex),
value: series[seriesId].data[dataIndex],
Expand Down
108 changes: 85 additions & 23 deletions packages/x-charts/src/LineChart/seriesConfig/seriesProcessor.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { stack as d3Stack } from '@mui/x-charts-vendor/d3-shape';
import { warnOnce } from '@mui/x-internals/warning';
import { getStackingGroups } from '../../internals/stackSeries';
import { getStackingGroups, StackOrder } from '../../internals/stackSeries';
import {
type ChartSeriesDefaultized,
type DatasetElementType,
type DatasetType,
} from '../../models/seriesType/config';
import { type SeriesId } from '../../models/seriesType/common';
import { type SeriesProcessor } from '../../internals/plugins/models';
import type { DefaultizedLineSeriesType } from '../../models';

const seriesProcessor: SeriesProcessor<'line'> = (params, dataset) => {
const lineValueFormatter = ((v) =>
v == null ? '' : v.toLocaleString()) as DefaultizedLineSeriesType['valueFormatter'];

const seriesProcessor: SeriesProcessor<'line'> = (params, dataset, isIdentifierVisible) => {
const { seriesOrder, series } = params;
const stackingGroups = getStackingGroups({ ...params, defaultStrategy: { stackOffset: 'none' } });

Expand Down Expand Up @@ -65,36 +69,94 @@ const seriesProcessor: SeriesProcessor<'line'> = (params, dataset) => {
const completedSeries: Record<SeriesId, ChartSeriesDefaultized<'line'>> = {};

stackingGroups.forEach((stackingGroup) => {
// Get stacked values, and derive the domain
const { ids, stackingOrder, stackingOffset } = stackingGroup;
const stackedSeries = d3Stack<any, DatasetElementType<number | null>, SeriesId>()
.keys(
ids.map((id) => {
// Use dataKey if needed and available
const dataKey = series[id].dataKey;
return series[id].data === undefined && dataKey !== undefined ? dataKey : id;
}),
)
const { ids, stackingOffset, stackingOrder } = stackingGroup;
const keys = ids.map((id) => {
// Use dataKey if needed and available
const dataKey = series[id].dataKey;
return series[id].data === undefined && dataKey !== undefined ? dataKey : id;
});

const stackedData = d3Stack<any, DatasetElementType<number | null>, SeriesId>()
.keys(keys)
.value((d, key) => d[key] ?? 0) // defaultize null value to 0
.order(stackingOrder)
.offset(stackingOffset)(d3Dataset);

// We sort the keys based on the original stacking order to ensure consistency
const idOrder = stackedData.sort((a, b) => a.index - b.index).map((s) => s.key);

// Compute visible stacked data
const visibleStackedData = d3Stack<any, DatasetElementType<number | null>, SeriesId>()
.keys(idOrder)
.value((d, key) => {
const keyIndex = keys.indexOf(key);
const seriesId = ids[keyIndex];

if (!isIdentifierVisible?.(`${seriesId}`)) {
// For hidden series, return 0 so they don't contribute to the stack
return 0;
}
return d[key] ?? 0;
})
.order(StackOrder.none)
.offset(stackingOffset)(d3Dataset);

// Post-process visibleStackedData to fix positions for hidden series
// Hidden series should collapse to the cumulative position of visible series before them
visibleStackedData.forEach((layer, layerIndex) => {
const key = idOrder[layerIndex];
const keyIndex = keys.indexOf(key);
const seriesId = ids[keyIndex];

if (!isIdentifierVisible?.(`${seriesId}`)) {
layer.forEach((point, pointIndex) => {
// Get the original value to determine if it's negative or positive
const originalValue = d3Dataset[pointIndex]?.[key] ?? 0;
const isNegative = originalValue < 0;

// Calculate the cumulative sum of all visible series before this one
// Only accumulate values with the same sign
let cumulativeSum = 0;
for (let i = 0; i < layerIndex; i += 1) {
const prevKey = idOrder[i];
const prevKeyIndex = keys.indexOf(prevKey);
const prevSeriesId = ids[prevKeyIndex];

if (isIdentifierVisible?.(`${prevSeriesId}`)) {
const value = d3Dataset[pointIndex]?.[prevKey] ?? 0;
const isPrevNegative = value < 0;

// Only accumulate if both have the same sign
if (isNegative === isPrevNegative) {
cumulativeSum += value;
}
}
}

// Set both start and end to the cumulative position (zero height/width)
point[0] = cumulativeSum;
point[1] = cumulativeSum;
});
}
});

ids.forEach((id, index) => {
const dataKey = series[id].dataKey;
const data = dataKey
? dataset!.map((d) => {
const value = d[dataKey];
return typeof value === 'number' ? value : null;
})
: series[id].data!;
const hidden = !isIdentifierVisible?.(`${id}`);
completedSeries[id] = {
labelMarkType: 'line',
...series[id],
data: dataKey
? dataset!.map((data) => {
const value = data[dataKey];

return typeof value === 'number' ? value : null;
})
: series[id].data!,
stackedData: stackedSeries[index].map(([a, b]) => [a, b]),
valueFormatter:
series[id]?.valueFormatter ??
((v: number | null) => (v == null ? '' : v.toLocaleString())),
data,
valueFormatter: series[id].valueFormatter ?? lineValueFormatter,
hidden,
stackedData: stackedData[index] as [number, number][],
visibleStackedData: visibleStackedData[index] as [number, number][],
};
});
});
Expand Down
9 changes: 5 additions & 4 deletions packages/x-charts/src/LineChart/useAreaPlotData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function useAreaPlotData(
const {
xAxisId = defaultXAxisId,
yAxisId = defaultYAxisId,
visibleStackedData,
stackedData,
data,
connectNulls,
Expand Down Expand Up @@ -94,26 +95,26 @@ export function useAreaPlotData(
xData?.flatMap((x, index) => {
const nullData = data[index] == null;
if (shouldExpand) {
const rep = [{ x, y: stackedData[index], nullData, isExtension: false }];
const rep = [{ x, y: visibleStackedData[index], nullData, isExtension: false }];
if (!nullData && (index === 0 || data[index - 1] == null)) {
rep.unshift({
x: (xScale(x) ?? 0) - (xScale.step() - xScale.bandwidth()) / 2,
y: stackedData[index],
y: visibleStackedData[index],
nullData,
isExtension: true,
});
}
if (!nullData && (index === data.length - 1 || data[index + 1] == null)) {
rep.push({
x: (xScale(x) ?? 0) + (xScale.step() + xScale.bandwidth()) / 2,
y: stackedData[index],
y: visibleStackedData[index],
nullData,
isExtension: true,
});
}
return rep;
}
return { x, y: stackedData[index], nullData };
return { x, y: visibleStackedData[index], nullData };
}) ?? [];

const d3Data = connectNulls ? formattedData.filter((d) => !d.nullData) : formattedData;
Expand Down
Loading
Loading