-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
[charts] Add groups
to band
and point
axis config
#18766
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Deploy preview: https://deploy-preview-18766--material-ui-x.netlify.app/ Updated pages: Bundle size report
|
CodSpeed Performance ReportMerging #18766 will not alter performanceComparing Summary
Footnotes |
key={index} | ||
transform={`translate(${tickOffset}, 0)`} | ||
className={classes.tickContainer} | ||
data-group-index={groupIndex} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I intend to use this in a styling demo, will be in another PR to not make this too big
import { isInfinity } from '../internals/isInfinity'; | ||
import { isBandScale } from '../internals/isBandScale'; | ||
import { useChartContext } from '../context/ChartProvider/useChartContext'; | ||
import { ChartsXAxisProps } from '../models/axis'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This file became just a "selector" to decide which type of axis component to render.
function ChartsXAxis(inProps: ChartsXAxisProps) {
const { xAxis, xAxisIds } = useXAxes();
if (xAxis[inProps.axisId ?? xAxisIds[0]].getGrouping) {
return <ChartsGroupedXAxis {...inProps} />;
}
return <ChartsSingleXAxis {...inProps} />;
}
/** | ||
* @ignore - internal component. | ||
*/ | ||
function ChartsGroupedXAxis(inProps: ChartsXAxisProps) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've removed a lot of checks/functions that are present in the "Single" axis, like shortenLabels and visibility checks.
packages/x-charts/src/models/axis.ts
Outdated
/** | ||
* The function used to create axis groups. | ||
* | ||
* For each value in the returned array, a group will be created. | ||
* Each group will have a label that is the stringified value of the group. | ||
* For example, if the function returns `[31, "Jan", 2021]`, `[1, "Feb", 2021]`, `[2, "Feb", 2021]`, | ||
* the axis will be rendered as: | ||
* | ||
* ```bash | ||
* | 31 | 1 | 2 | | ||
* | Jan | Feb | | ||
* | 2021 | | ||
* ``` | ||
* | ||
* Each row in the example above is assigned a group index. | ||
* | ||
* @param {any} value The value of the axis item. | ||
* @param {number} dataIndex The index of the axis item in the `data` array. | ||
* @returns {Array<string | number | Date>} The array of values that will be used to group the axis items. | ||
*/ | ||
getGrouping?: (value: V, dataIndex: number) => (string | number | Date)[]; | ||
/** | ||
* The configuration for the axis grouping. | ||
* Accepts a single object or an array of objects. | ||
* If an array is provided, each group will use the corresponding object in the array. | ||
* A group is defined by the `getGrouping` function. | ||
* For example, if the `getGrouping` function returns `[31, "Jan", 2021]`, `[1, "Feb", 2021]`, `[2, "Feb", 2021]`, | ||
* the group with index 1 will be `["Jan", "Feb", "Feb"]` | ||
*/ | ||
groupingConfig?: AxisGroupingConfig | AxisGroupingConfig[]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I went with a "built-in" props/component as that was easier to start.
But I suppose we could also make it a stand-alone component to be used with slots/composition.
This would allow the props to be passed directly to it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@alexfauquette thoughts on this?
The band scale look nice. I've more doubt about the other ones. Just a small edge case due to the fact you link group with their left tick, you get an issue with the last one About point scale, it feels weird on the extremities. Not sure it's worth supporting it. But I don't have that much opinion on it About continuouse scale, it looks overcomplicated compared to the result D3 default time formatter has a nice trick for getting similar result. A similar approach for contiuouse scale could be slightly more complicating but easier to document. Because with the current solution we fully rely on the tick generation. which works well for months/quatter/years, because D3 tends to place ticks on those values. Last point of attention, the link between text position and the tik size create issues for small ticks. Either we define a |
Yeah, it is a bit annoying. We could add a prop or something to fix that tick's size manually. The issue is that we can't know in which "tick" we should end. I suppose I can always end in the biggest one by default though? 🤔
We could make it use the same styling as "continuous"
I don't get what is your proposal here 🤔
We can't reliably know how big the text is. The user can set an option for each of the groups by using the |
packages/x-charts/src/models/axis.ts
Outdated
@@ -443,6 +473,14 @@ export type AxisConfig< | |||
|
|||
export interface AxisConfigExtension {} | |||
|
|||
export type AxisGroupingConfig = { | |||
/** | |||
* The number of pixels between two groups. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If tickSize is 6px, does this mean that this group will have a 6px margin top, or will it have 3px margin top + 3px margin bottom? Or is it like flex gap where is only applies to the space in between groups?
From the demo, it seems it's the second one, but my point is that it should probably be explained in these docs as I don't think it will be clear to the user.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a bit confused by your question. tickSize
doesn't add any margin. It just ensures the tick has a specified size, then the label is rendered after the tick ends, just like the regular axis 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changing the tick size changes the labels position in the demo, that's why I thought it was related.
So the label changing position is just a consequence of the tick size being larger?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
label' y-position is defined by ticksize + a fix gap
y: positionSign * axisHeight, | ||
}; | ||
|
||
const tickLabels = new Map(Array.from(xTicks).map((item) => [item, item.formattedValue])); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
xTicks
already seems to be an array:
const tickLabels = new Map(Array.from(xTicks).map((item) => [item, item.formattedValue])); | |
const tickLabels = new Map(xTicks.map((item) => [item, item.formattedValue])); |
export type GroupedTickItemType = { | ||
/** | ||
* This property is undefined only if it's the tick closing the last band | ||
*/ | ||
value?: any; | ||
formattedValue?: string; | ||
offset: number; | ||
labelOffset: number; | ||
/** | ||
* In band scales, we remove some redundant ticks. | ||
*/ | ||
ignoreTick?: boolean; | ||
dataIndex?: number; | ||
/** | ||
* The index of the group this tick belongs to. If `getGrouping` returns `[[0, 1], [2, 3]]` | ||
* both ticks with value `0` and `2` will have `groupIndex: 0`, and both ticks with value `1` and `3` will have `groupIndex: 1`. | ||
*/ | ||
groupIndex?: number; | ||
/** | ||
* The index of the tick group inside the group. This is useful if you have multiple ticks with the same value, | ||
* but they are divided by other values. Eg: `'Jan', ..., 'Dec', 'Jan'` | ||
* We would have the first `'Jan'` with `syncIndex: 0` and `syncIndex: 12` for the second `'Jan'`. | ||
*/ | ||
syncIndex?: number; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if we should return separate TickItem[]
and TickLabel[]
, especially with grouping since tickItems.length !== tickLabels.length
.
That way, we wouldn't need ignoreTick
nor undefined value
s.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think for now it would not be necessary
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like, tickItems is very often not the same tickLabels in band scale, as we always have to add a final tick anyways.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like, tickItems is very often not the same tickLabels in band scale, as we always have to add a final tick anyways.
Yeah, that's why I was wondering if the should separate them. One array of ticks, one array of tick labels.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could technically do it since useTicks
is an internal hook.
Maybe better to do that in a dedicated PR to avoid mixing too many modification
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, separate PR sounds good
@alexfauquette Yeah I'm trying to go for something like option 1 there. |
You're current approach seems limited to do such thing. Because you start from ticks, which in the case of dates aligne correctly because D3 generates human-readable tick values. I would not be surprised if we end up with distinct solutions for continuouse/ordinal axes. Ordianl ones with a solution like you deed because it feels more natural, and for continuous an array of thresholds |
8212327
to
7f350a3
Compare
I've moved the config to only be present for |
This pull request has conflicts, please resolve those before we can evaluate the pull request. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left some comments
@@ -240,12 +240,33 @@ If two or more axes share the same `position`, they are displayed in the order t | |||
|
|||
{{"demo": "MultipleAxes.js"}} | |||
|
|||
### Grouped Axes | |||
## Grouped Axes |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we want to label this as preview/unstable for now?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The question is "Are we sure the API will not be modified"
The current one seems robust, so I don't see reasons to mark the feature as preview
docs/data/charts/axis/axis.md
Outdated
|
||
You can group axes together by rendering more than one axis on the same side. | ||
In order to group axes together, a user can provide a `grouping` property in the axis definition. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we mention that this only works for band and point scales? If a user tries to use it with a different scale it'll throw an error
In order to target a specific group, the `data-group-index` attribute can be used as a selector. | ||
The example below has a yellow tick color for the last group and blue text for the first group. | ||
|
||
{{"demo": "GroupedAxesStyling.js"}} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be nice to have different demos, as they current all use the same dataset, but that can be done as a follow-up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That would be nice for testing the rendering with Argos.
- One that does not start with Q1 and end with Q4
- One that does start with Q1 and end with Q4
- One that uses point scale instead of band scale
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch, there issues with the first/last ticks 🤔
- One that does start with Q1 and end with Q4
- One that does not start with Q1 and end with Q4
We can always use the largest possible tick size at the start&end
AG Grid
We can leave as is
We can allow changing the firstTick/lastTick: "largest"|"smallest"|groupIndex
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can always use the largest possible tick size at the start&end AG Grid
Looks like a better first step than the current one which has different behavior between first and last tick 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The other all look nice
docs/data/charts/axis/axis.md
Outdated
The `getGrouping` function receives the axis data value and should return an array of values. | ||
Each value is a group name. Groups are displayed in the order they are defined in the `getGrouping` function. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we precise that this function overrides the value formatter?
docs/data/charts/axis/axis.md
Outdated
### Tick size | ||
|
||
The tick size can be customized for each group. | ||
To do so, you can provide a `tickSize` property in the `grouping.config` array. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When looking at the config, it feels like the config
is the part that will grow, and I don't see what else you will put in grouping
<Chart
xAxis={[{
...,
grouping: {
getGrouping,
config: [{ tickSize: 0 }, { tickSize: 32 }],
},
}]}
/>
Maybe another approach could be
<Chart
xAxis={[{
...,
groups: [
{ getValue: getMonth, tickSize: 0 },
{ getValue: getYear, tickSize: 32 },
]
}]}
/>
The internal processing is similar, but you ensure consistency between the number of groups and their config
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤔 I like the groups
approach.
I was going to initially allow other props in grouping
, but they did only make sense in the other axis types, but now they are out of scope, it might not be necessary.
export type GroupedTickItemType = { | ||
/** | ||
* This property is undefined only if it's the tick closing the last band | ||
*/ | ||
value?: any; | ||
formattedValue?: string; | ||
offset: number; | ||
labelOffset: number; | ||
/** | ||
* In band scales, we remove some redundant ticks. | ||
*/ | ||
ignoreTick?: boolean; | ||
dataIndex?: number; | ||
/** | ||
* The index of the group this tick belongs to. If `getGrouping` returns `[[0, 1], [2, 3]]` | ||
* both ticks with value `0` and `2` will have `groupIndex: 0`, and both ticks with value `1` and `3` will have `groupIndex: 1`. | ||
*/ | ||
groupIndex?: number; | ||
/** | ||
* The index of the tick group inside the group. This is useful if you have multiple ticks with the same value, | ||
* but they are divided by other values. Eg: `'Jan', ..., 'Dec', 'Jan'` | ||
* We would have the first `'Jan'` with `syncIndex: 0` and `syncIndex: 12` for the second `'Jan'`. | ||
*/ | ||
syncIndex?: number; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could technically do it since useTicks
is an internal hook.
Maybe better to do that in a dedicated PR to avoid mixing too many modification
packages/x-charts/src/models/axis.ts
Outdated
* | ||
* For each value in the returned array, a group will be created. | ||
* Each group will have a label that is the stringified value of the group. | ||
* For example, if the function returns `[31, "Jan", 2021]`, `[1, "Feb", 2021]`, `[2, "Feb", 2021]`, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this will play nicely with the grid-charts integration
with row grouping, we keep path
for each row that can easily be mapped to this (mapping and in a reverse order - since we keep the top parent on index 0)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You liked @alexfauquette comment above #18766 (comment) which regards to grouping
vs groups
, but this confused me on what you would prefer 🤔
Can you clarify?
grouping: {
getGrouping,
config: [{ tickSize: 0 }, { tickSize: 32 }],
}
// VS
groups: [
{ getValue: getMonth, tickSize: 0 },
{ getValue: getYear, tickSize: 32 },
]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, because it will break the array that I said we could compile pretty easy.
Still, I would prefer
groups: [
{ getValue: getMonth, tickSize: 0 },
{ getValue: getYear, tickSize: 32 },
]
It avoids requirement to track the indexes manually.
The values can still be provided pretty easy by picking the index from our internal array.
Also, in case you want to override defaults for the last index, the first options would have to be something like this?
grouping: {
getGrouping,
config: [null, { tickSize: 32 }],
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@arminmeh yeah that would be the case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll convert it to groups: []
then
groups
to band
and point
axis config
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems the demo is crashing when the groups array is empty.
Screen.Recording.2025-08-05.at.11.36.32.mov
It's a weird edge case, but we should avoid crashing.
Not sure of the implications, but should we only render the grouped axes component when groups.length > 1?
I'm suggesting groups.length > 1 because groups is missing some functionality from normal tick labels, such as overlap handling. When groups.length === 1, the user could have used the normal tick labels which provides more functionality, so it doesn't really make a lot of sense to use the grouping functionality, does it?
position === 'none' | ||
) { | ||
return null; | ||
if ('groups' in xAxis[inProps.axisId ?? xAxisIds[0]]) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we check if groups
is an array?
Otherwise there might be edge cases like this:
const a = { 'groups': undefined };
'groups' in a; // true
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added some more reasonable checks
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Regarding groups.length > 1
I think it could be more confusing, as in I could just be trying to style the first group in isolation, but then if I add another item in the array everything changes.
I think we can leave it as is for now and check how ppl react
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it could be more confusing, as in I could just be trying to style the first group in isolation, but then if I add another item in the array everything changes.
True. I'm ok with it as is
) { | ||
return null; | ||
const axis = xAxis[inProps.axisId ?? xAxisIds[0]]; | ||
if ('groups' in axis && Array.isArray(axis.groups)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see an issue with it
export type GroupedTickItemType = { | ||
/** | ||
* This property is undefined only if it's the tick closing the last band | ||
*/ | ||
value?: any; | ||
formattedValue?: string; | ||
offset: number; | ||
labelOffset: number; | ||
/** | ||
* In band scales, we remove some redundant ticks. | ||
*/ | ||
ignoreTick?: boolean; | ||
dataIndex?: number; | ||
/** | ||
* The index of the group this tick belongs to. If `getGrouping` returns `[[0, 1], [2, 3]]` | ||
* both ticks with value `0` and `2` will have `groupIndex: 0`, and both ticks with value `1` and `3` will have `groupIndex: 1`. | ||
*/ | ||
groupIndex?: number; | ||
/** | ||
* The index of the tick group inside the group. This is useful if you have multiple ticks with the same value, | ||
* but they are divided by other values. Eg: `'Jan', ..., 'Dec', 'Jan'` | ||
* We would have the first `'Jan'` with `syncIndex: 0` and `syncIndex: 12` for the second `'Jan'`. | ||
*/ | ||
syncIndex?: number; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, separate PR sounds good
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm good with the API and the global implementation. Left minor comments about the implementation details
// Step 2: Sort by group index to ensure proper ordering | ||
allTickItems.sort((a, b) => a.groupIndex - b.groupIndex); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You mapped the new groups
structure to generate getGrouping
and keep this internal logic. BU tit might be simpler to directly use groups
const maxGroupIndex = groups.length -1
const allTickItems = []
for (let groupIndex = 0; groupIndex < groups.length; groupIndex+=1){
// No need to sort anymore
tickValues.forEach((tickValue, dataIndex) => {
const groupValue = groups[groupIndex].getValue(tickValue, dataIndex);
// ....
if(isNew){
// Add a new item
allTickItems.push( ... )
} else {
// Merge it with the last item
allTickItems[allTickItems.length -1].size += itemSize
}
})
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
moved it to a new pr, it will change the logic, so it will be easier in a new pr where I can be sure the styles wont be affected
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The functionality seems to be working well, so I'm approving to unblock integration with the data grid. I think we can still do refactors in a follow-up to reduce code duplication and streamline some parts as mentioned in previous comments.
The comments aren't blockers for me, so they can be addressed in a follow-up.
Great job!
In order to group `band` or `point` axes together, a user can provide a `groups` property in the x-axis definition. | ||
This property expects an array of objects with a `getValue` function. | ||
|
||
The `getValue` function receives the axis data value and should return a group name. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we also explain how grouping is done?
For example, it might not be clear that you can have groups with the same name (e.g., two "Jan") and they'll be separate groups if the next group is different (e.g., 2025 and 2026).
Although, that's shown in the demo, so maybe it'll become clear by example.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think it is necessary, it should be clear-ish by example.
But also generally, considering the amount of issues we had with dates in the past, users expect the same values to be different if not one after the other
const tickLabel = item.formattedValue; | ||
const ignoreTick = item.ignoreTick ?? false; | ||
const groupIndex = item.groupIndex ?? 0; | ||
const groupConfig = getGroupingConfig(groupingConfig, groupIndex, tickSize); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getGroupingConfig
is only being called here and we're only using groupConfig.tickSize
.
We can rename getGroupingConfig
to calculateTickSize
, pass the necessary arguments and return a number instead of an object. Should allow us to remove some lines of code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
for y axis it will need to parse more options, so I'll leave it here for now
extremities: 0, | ||
end: 1, | ||
middle: 0.5, | ||
tick: 0, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is tick
a valid option? I can't find any reference to it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/**
* The placement of ticks label. Can be the middle of the band, or the tick position.
* Only used if scale is 'band'.
* @default 'middle'
*/
tickLabelPlacement?: 'middle' | 'tick';
Interactive demo https://deploy-preview-18766--material-ui-x.netlify.app/x/react-charts/axis/#grouped-axes
Resolves #17931
Initial idea is to always rely on a formatting function rather than the data. Which should work for dataset, continuous and discrete axes.
I've moved it as part of the
band
andpoint
scale only