Skip to content

Conversation

alexfauquette
Copy link
Member

The idea is to add an onXAxisInteraction that provides meaning full information about where the pointer is compared to x-axes.

Implementation

For now it's only trigger by pointerMove event. Which has a known limitation: It the developer has some logic that modifies the data the onXAxisInteraction will not be trigger by the modification.

For example, if the pointer moves over the last value of the x-axis. we call onXAxisInteraction with this data index.
When the last item get removed by some async call, the item does not exist anymore, and we end up in a weird situation.

A potential solution is to apply the same logic when updating axes: compute interaction values before/after the axis modification and trigger the onXAxisInteraction if needed (to explore)

DX

The onXAxisInteraction support multiple axes. That's not super useful for highlight that only accept one. But it will be usefull to control that axis tooltip.

The axis interaction contains both dataIndex and value. The usecases are the following:

  • If axis has data, the dataIndex is the source of truth.
  • If axis has no data (for example the y-axis on line chart, of any axis on scatter chart) the value is needed.

I'm not sure it's a good idea to have both. I'm thinking about only supporting the dataIndex, because axes without data are useless for the axis-tooltip. And the axis highlight is overcomplicated and wem behavior can be obtained by implementing the last demo of Custom components - Scales (Coordinate to value
)

@alexfauquette alexfauquette added type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. scope: charts Changes related to the charts. labels May 19, 2025
@mui-bot
Copy link

mui-bot commented May 19, 2025

Deploy preview: https://deploy-preview-17900--material-ui-x.netlify.app/

Updated pages:

Bundle size report

Total Size Change: 🔺+49KB(+0.37%) - Total Gzip Change: 🔺+15.7KB(+0.39%)
Files: 122 total (0 added, 0 removed, 37 changed)

Show details for 100 more bundles (22 more not shown)

@mui/x-charts-pro/ChartContainerProparsed: 🔺+3.88KB(+2.20%) gzip: 🔺+1.2KB(+2.16%)
@mui/x-charts/ChartContainerparsed: 🔺+3.87KB(+2.64%) gzip: 🔺+1.08KB(+2.39%)
@mui/x-charts/Gaugeparsed: 🔺+3.76KB(+2.68%) gzip: 🔺+1.04KB(+2.37%)
@mui/x-charts-pro/ChartDataProviderProparsed: 🔺+3.75KB(+2.22%) gzip: 🔺+1.33KB(+2.50%)
@mui/x-charts/ChartDataProviderparsed: 🔺+3.74KB(+2.68%) gzip: 🔺+1.11KB(+2.58%)
@mui/x-charts-proparsed: 🔺+2.4KB(+0.63%) gzip: 🔺+750B(+0.65%)
@mui/x-chartsparsed: 🔺+2.4KB(+0.79%) gzip: 🔺+755B(+0.83%)
@mui/x-charts-pro/LineChartProparsed: 🔺+2.34KB(+0.84%) gzip: 🔺+648B(+0.74%)
@mui/x-charts/LineChartparsed: 🔺+2.34KB(+1.04%) gzip: 🔺+842B(+1.22%)
@mui/x-charts/ScatterChartparsed: 🔺+2.15KB(+1.11%) gzip: 🔺+712B(+1.19%)
@mui/x-charts-pro/BarChartProparsed: 🔺+2.15KB(+0.78%) gzip: 🔺+617B(+0.72%)
@mui/x-charts-pro/FunnelChartparsed: 🔺+2.15KB(+0.88%) gzip: 🔺+772B(+1.03%)
@mui/x-charts-pro/ScatterChartProparsed: 🔺+2.15KB(+0.80%) gzip: 🔺+736B(+0.87%)
@mui/x-charts/BarChartparsed: 🔺+2.15KB(+1.03%) gzip: 🔺+622B(+0.96%)
@mui/x-charts/SparkLineChartparsed: 🔺+2.07KB(+0.99%) gzip: 🔺+635B(+0.99%)
@mui/x-charts-pro/PieChartProparsed: 🔺+1.42KB(+0.61%) gzip: 🔺+490B(+0.68%)
@mui/x-charts/PieChartparsed: 🔺+1.41KB(+0.72%) gzip: 🔺+436B(+0.72%)
@mui/x-charts-pro/Heatmapparsed: 🔺+1.29KB(+0.56%) gzip: 🔺+386B(+0.54%)
@mui/x-charts/RadarChartparsed: 🔺+1.28KB(+0.67%) gzip: 🔺+524B(+0.89%)
@mui/x-charts-pro/RadarChartProparsed: 🔺+1.28KB(+0.63%) gzip: 🔺+719B(+1.14%)
@mui/x-charts/ChartsAxisHighlightparsed: 🔺+778B(+1.27%) gzip: 🔺+205B(+0.95%)
@mui/x-charts/ChartsTooltipparsed: 🔺+226B(+0.29%) gzip: 🔺+71B(+0.27%)
@mui/x-charts-pro/ChartsToolbarProparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts-pro/ChartZoomSliderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsAxisparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsClipPathparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsGridparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsLabelparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsLegendparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsLocalizationProviderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsOverlayparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsReferenceLineparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsSurfaceparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsTextparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsXAxisparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsYAxisparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/Toolbarparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-data-gridparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-data-grid-premiumparsed: 0B(0.00%) gzip: ▼-1B(0.00%)
@mui/x-data-grid-premium/DataGridPremiumparsed: 0B(0.00%) gzip: ▼-2B(0.00%)
@mui/x-data-grid-proparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-data-grid-pro/DataGridProparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-data-grid/DataGridparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickersparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-proparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/AdapterDateFnsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/AdapterDateFnsJalaliparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/AdapterDayjsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/AdapterLuxonparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/AdapterMomentparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/AdapterMomentHijriparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/AdapterMomentJalaaliparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/DateRangeCalendarparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/DateRangePickerparsed: 0B(0.00%) gzip: ▼-3B(-0.01%)
@mui/x-date-pickers-pro/DateRangePickerDayparsed: 0B(0.00%) gzip: 🔺+3B(+0.04%)
@mui/x-date-pickers-pro/DateRangePickerDay2parsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/DateTimeRangePickerparsed: 0B(0.00%) gzip: ▼-1B(0.00%)
@mui/x-date-pickers-pro/DesktopDateRangePickerparsed: 0B(0.00%) gzip: ▼-1B(0.00%)
@mui/x-date-pickers-pro/DesktopDateTimeRangePickerparsed: 0B(0.00%) gzip: ▼-1B(0.00%)
@mui/x-date-pickers-pro/DesktopTimeRangePickerparsed: 0B(0.00%) gzip: ▼-1B(0.00%)
@mui/x-date-pickers-pro/LocalizationProviderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/MobileDateRangePickerparsed: 0B(0.00%) gzip: ▼-3B(-0.01%)
@mui/x-date-pickers-pro/MobileDateTimeRangePickerparsed: 0B(0.00%) gzip: ▼-1B(0.00%)
@mui/x-date-pickers-pro/MobileTimeRangePickerparsed: 0B(0.00%) gzip: ▼-1B(0.00%)
@mui/x-date-pickers-pro/MultiInputDateRangeFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/MultiInputDateTimeRangeFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/MultiInputTimeRangeFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/PickersRangeCalendarHeaderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/SingleInputDateRangeFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/SingleInputDateTimeRangeFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/SingleInputTimeRangeFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/StaticDateRangePickerparsed: 0B(0.00%) gzip: ▼-1B(0.00%)
@mui/x-date-pickers-pro/TimeRangePickerparsed: 0B(0.00%) gzip: ▼-1B(0.00%)
@mui/x-date-pickers/AdapterDateFnsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterDateFnsBaseparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterDateFnsJalaliparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterDayjsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterLuxonparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterMomentparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterMomentHijriparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterMomentJalaaliparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DateCalendarparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DateFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DatePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DateTimeFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DateTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DayCalendarSkeletonparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DesktopDatePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DesktopDateTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DesktopTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DigitalClockparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/LocalizationProviderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/MobileDatePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/MobileDateTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/MobileTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/MonthCalendarparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/MultiSectionDigitalClockparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/PickerDay2parsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/PickersActionBarparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/PickersCalendarHeaderparsed: 0B(0.00%) gzip: 0B(0.00%)

Details of bundle changes

Generated by 🚫 dangerJS against b149f2a

Copy link

codspeed-hq bot commented May 19, 2025

CodSpeed Performance Report

Merging #17900 will degrade performances by 11.34%

Comparing alexfauquette:test-axis-highlight (b149f2a) with master (ed7604e)

Summary

❌ 1 regressions
✅ 9 untouched benchmarks

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Benchmarks breakdown

Benchmark BASE HEAD Change
ScatterChartPro with big data amount 393.6 ms 444 ms -11.34%

Copy link
Member

@JCQuintas JCQuintas left a comment

Choose a reason for hiding this comment

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

It seems to be a nice addition and works as expected.

I have some concerns however, regarding the API.

I think I would rather have a more generic api, like onAxisHighlightChange and highlightedAxes, where the identifier actually includes the axis dimension as a part of it.

{
  axisId: 123,
  dataIndex: 0,
  value: 'Pizza',
  axis: 'z'
}

Since the X/Y dimension forces us to play by the cartesian charts rules 😆

The highlight can be controlled by using `xAxisHighlight`/`yAxisHighlight` and the `onXAxisInteraction`/`onYAxisInteraction`.

The `xAxisHighlight`/`yAxisHighlight` are objects `{ axisId, dataIndex, value }`.
If the `dataIndex` is provided, the axis `value` is ignored an computed from the `dataIndex` and `axis.data`.
Copy link
Member

Choose a reason for hiding this comment

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

What do you mean with

If the dataIndex is provided

provided by whom?

The `xAxisHighlight`/`yAxisHighlight` are objects `{ axisId, dataIndex, value }`.
If the `dataIndex` is provided, the axis `value` is ignored an computed from the `dataIndex` and `axis.data`.

The `onXAxisInteraction`/`onYAxisInteraction` handler are trigger each time pointer moves from one axis value to another.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
The `onXAxisInteraction`/`onYAxisInteraction` handler are trigger each time pointer moves from one axis value to another.
The `onXAxisInteraction`/`onYAxisInteraction` handler are triggered each time the pointer moves from one axis value to another.

The `xAxisHighlight`/`yAxisHighlight` are objects `{ axisId, dataIndex, value }`.
If the `dataIndex` is provided, the axis `value` is ignored an computed from the `dataIndex` and `axis.data`.

The `onXAxisInteraction`/`onYAxisInteraction` handler are trigger each time pointer moves from one axis value to another.
Copy link
Member

Choose a reason for hiding this comment

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

Maybe we want to rewrite most text instead

Suggested change
The `onXAxisInteraction`/`onYAxisInteraction` handler are trigger each time pointer moves from one axis value to another.
The `onXAxisInteraction`/`onYAxisInteraction` handler are triggered each time the pointer crosses the boundaries between two axes.

Comment on lines 68 to 69
The handler get an array of axis value identifier.
Whereas the controlled value only accept on objects.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
The handler get an array of axis value identifier.
Whereas the controlled value only accept on objects.
The handler accepts an array of axis value identifiers.
Whereas the controlled value only accepts one object.

Whereas the controlled value only accept on objects.

For now highlight components assume you use the first axis.
Being able to highlight any axis will arrive in further development.
Copy link
Member

Choose a reason for hiding this comment

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

doesnt seem to add much from the phrase above it

Suggested change
Being able to highlight any axis will arrive in further development.

@alexfauquette
Copy link
Member Author

I think I would rather have a more generic api, like onAxisHighlightChange and highlightedAxes, where the identifier actually includes the axis dimension as a part of it.

We could do it, if we restrict ourself to axes with a data attribute. Otherwise the devs will receive a bunch of update to ignore. Each time the pointe rmoves along the y-axis on a line chart, there is an update 🙈

/**
* The data index if the axis have a `data` property.
*/
dataIndex: number | null;
Copy link
Member

Choose a reason for hiding this comment

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

In LineItemIdentifier we use dataIndex?: number. Should we decide on whether to use dataIndex?: number or dataIndex: number | null? It might be confusing otherwise.

Comment on lines 48 to 51
if (controlledItem.dataIndex !== null) {
return axis.axis[controlledItem.axisId].data?.[controlledItem.dataIndex];
}
return controlledItem.value;
Copy link
Member

Choose a reason for hiding this comment

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

I guess this is what "get priority" means, right?

Why do we need to prioritize data index over value? Can't we just use one or the other?

* The function called when pointer moves from one axis item to another.
* @param {AxisPointerIdentifier[]} newAxes The array of item per axis.
*/
onXAxisInteraction?: (newAxes: AxisPointerIdentifier[] | null) => void;
Copy link
Member

Choose a reason for hiding this comment

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

Should we call the first argument pointerIds instead of newAxes? They aren't axes, they are pointer identifiers.

Comment on lines 157 to 158
...store.value,
interaction: { ...store.value.interaction!, pointer: svgPoint },
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 if spreading the whole store will impact performance negatively, especially since it's being called on pointer move.

And potentially twice if onXAxisInteraction and onYAxisInteraction are both set.

What if we move the second argument of selectorChartsInteractionXAxisIndex and selectorChartsInteractionXAxisValue into standalone functions and then we provide them to the selector while also calling them here?

export function selectChartsInteractionXAxisValue(x: number | null, xAxes: ComputeResult<ChartsXAxisProps>, xIndex: number | null, id: AxisId | undefined) {
    if (x === null || xIndex === null || xAxes.axisIds.length === 0) {
      return null;
    }
    return valueGetter(x, xAxes, xIndex, id);
  },
}

export const selectorChartsInteractionXAxisValue = createSelector(
  [
    selectorChartsInteractionPointerX,
    selectorChartXAxis,
    selectorChartsInteractionXAxisIndex,
    optionalGetAxisId,
  ],
  selectChartsInteractionXAxisValue
);

And then in this file, we would do:

          const nextXAxisValue = selectChartsInteractionXAxisValue(svgPoint.x, xAxes, nextXAxisIndex, axisId);

The same would apply for the Y axis and for the X axis index.

What do you think?


const newStore = {
...store.value,
interaction: { ...store.value.interaction!, pointer: svgPoint },
Copy link
Member

Choose a reason for hiding this comment

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

Do we need the exclamation mark? Spreading undefined or null into an object is fine:

const a = { ...undefined }
// a = { } 

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, but in that case the value of interaction: { ...store.value.interaction, pointer: svgPoint }, could be interaction: { pointer: {x, y} } (if the interaction is undefined) which is not a valid interaction value. So TS complains in the selectors

@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label May 23, 2025
Copy link

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label May 28, 2025
@alexfauquette
Copy link
Member Author

Based on feedback, I went with the following DX

  • The highlightedAxis={{ direction, axisId, dataIndex}} which is less flexible but already cover 99% of the usecases. It make sure only axes the a data property can be hihlighted.
  • The callback is now onAxisInteraction({ direction, axisId, dataIndex}[]). It's also only taking care of axes with data attribute.

The callback is get an array, because I plan to use the same one for the tooltip control. And the tooltip is able to support multiple axes so we will need to get an access to all of them

Comment on lines 525 to 532
/**
* The axis direction.
*/
direction: CartesianDirection;
/**
* The axis id.
*/
axisId: AxisId;
Copy link
Member

@bernardobelchior bernardobelchior May 29, 2025

Choose a reason for hiding this comment

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

Do we need the direction? The direction can be derived from the list of axes and the axis ID, right? So isn't the direction a bit redundant here?

It would allow us to have just one AxisItemIdentifier instead of having to split by cartesian/polar.

Copy link
Member Author

Choose a reason for hiding this comment

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

With the current implementation, it would be hard, because

const axis = useXAxis(id) ?? useYAxis(id)

On the principle, I agree. The current axis management could be improved to remove this x/y distinction in the storage. But that would be a much larger PR.

Copy link
Member

Choose a reason for hiding this comment

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

But that would be a much larger PR.

Wouldn't it be exporting a useCartesianAxes and a useCartesianAxis hooks whose implementation is basically return useXAxis(id) ?? useYAxis(id)? Or am I missing something?

It would save us from exposing the direction here.


Is there ever a case for a chart with cartesian and polar axes simultaneously? If so, we might have to expose a useAxes because a user doesn't know the axis type (cartesian or polar) to use the proper hook.

Copy link
Member Author

Choose a reason for hiding this comment

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

Is there ever a case for a chart with cartesian and polar axes simultaneously?

No there is no case like that.

We could export useCartesianAxis(id) but we still need useXAxis() and useYAxis() because they default to first axis in each direction

Copy link
Member

Choose a reason for hiding this comment

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

we still need useXAxis() and useYAxis() because they default to first axis in each direction

Sure, we can keep it, although the difference in behavior should only be theoretical. I believe we issue a warning when an x-axis has the same ID as a y-axis, so in practice the behavior should be the same as users aren't using the same IDs for different axes.

@@ -123,6 +196,7 @@ export const useChartCartesianAxis: ChartPlugin<UseChartCartesianAxisSignature<a
instance,
params.disableAxisListener,
isInteractionEnabled,
onAxisInteraction,
Copy link
Member

Choose a reason for hiding this comment

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

Should we wrap this in an useEventCallback?

If onAxisInteraction is a function that's created on every render, we might get into infinite loops because we run this useEffect whenever onAxisInteraction changes, which might call onAxisInteraction with newAxisPointerInteraction which is a new array every time. If onAxisInteraction causes a re-render (e.g., by calling setState), then we'll get an infinite loop.

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 could, but I'm not a big fan of memorizing user functions.

About the inifit loop, I don't think it's an issue since onAxisInteraction is only called inside pointer events callback

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 not a big fan of memorizing user functions

Why not?

I don't think it's an issue since onAxisInteraction is only called inside pointer events callback

You're right. It might just be inefficient to be removing and adding event listeners on every re-render. Not sure if we want to address that now.

@@ -47,6 +48,16 @@ export interface UseChartCartesianAxisParameters<S extends ScaleName = ScaleName
* @param {null | ChartsAxisData} data The data about the clicked axis and items associated with it.
*/
onAxisClick?: (event: MouseEvent, data: null | ChartsAxisData) => void;
/**
* The function called when pointer moves from one axis value to another.
Copy link
Member

Choose a reason for hiding this comment

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

when pointer moves from one axis value to another

Is this the only case? Can't it be called if the data changes as well?

I think something like "when the axis value changes" would work better.


Also, we use "axis value" here, but "axis item" below. Are they the same thing? Should we use the same name in both places?

Copy link
Member Author

Choose a reason for hiding this comment

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

Is this the only case? Can't it be called if the data changes as well?

It's the case for performance reasons. Otherwise each pointer move will trigger it.

If user what to have value updates, they should get full control on their events with https://mui.com/x/react-charts/components/#scales

Copy link
Member

Choose a reason for hiding this comment

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

Otherwise each pointer move will trigger it

Sorry, didn't understand why every pointer move would trigger the callback. Why do you say that's the case?

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 misunderstood your initial comment.

I was focusing on the fact I remove the value to only keep the dataIndex attribute.

Can't it be called if the data changes as well?

I would be great but not implemented in this PR. I will have a look if the new selector implementation makes it easier to do

Copy link
Member

Choose a reason for hiding this comment

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

I misunderstood your initial comment.

Sure, no problem.

I would be great but not implemented in this PR. I will have a look if the new selector implementation makes it easier to do

I see. Should we still update the docs so that it's clear that it isn't only pointer move that will cause this event handler to be called?


Also, we use "axis value" here, but "axis item" below. Are they the same thing? Should we use the same name in both places?

I think we also missed the question above

@@ -47,6 +48,16 @@ export interface UseChartCartesianAxisParameters<S extends ScaleName = ScaleName
* @param {null | ChartsAxisData} data The data about the clicked axis and items associated with it.
*/
onAxisClick?: (event: MouseEvent, data: null | ChartsAxisData) => void;
/**
* The function called when pointer moves from one axis value to another.
* @param {AxisItemIdentifier[]} newAxisInteraction The array of item per axis.
Copy link
Member

Choose a reason for hiding this comment

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

Does this contain all axis items or only the ones that changed? We should probably specify that

Copy link
Member Author

Choose a reason for hiding this comment

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

Only those for axis with data property.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, but my point is: does newAxisInteraction contain only values that changed or the new interaction state?

For example, if there's an x-axis and an y-axis and I move the pointer horizontally such that only the x-axis value changes, do I get an array with one or two values?

Copy link
Member Author

Choose a reason for hiding this comment

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

It contains all the axes with a data property.

If one axis have a new dataIndex we return all the axes data index with their current highlight index. Such that the array always have the same length/order

Copy link
Member

Choose a reason for hiding this comment

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

Such that the array always have the same length/order

Thanks, that answers my question! Should it be part of the docs? Otherwise I suspect users might have the same doubts I had

@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jun 24, 2025
## Controlled axis highlight

The highlight can be controlled by using `highlightedAxis` prop.
Its value can be `null` to remove axis highlight, or an object `{ direction: 'x' | 'y', axisId: string, dataIndex: number }`.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Its value can be `null` to remove axis highlight, or an object `{ direction: 'x' | 'y', axisId: string, dataIndex: number }`.
Its value can be `null` to remove axis highlight, or an object `{ axisId: string, dataIndex: number }`.

The highlight can be controlled by using `highlightedAxis` prop.
Its value can be `null` to remove axis highlight, or an object `{ direction: 'x' | 'y', axisId: string, dataIndex: number }`.

The `onAxisInteraction` handler is trigger each time the pointer crosses the boundaries between two axis values.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
The `onAxisInteraction` handler is trigger each time the pointer crosses the boundaries between two axis values.
The `onAxisInteraction` handler is triggered each time the pointer crosses the boundaries between two axis values.

Comment on lines 60 to 61
Its parameter is an array of objects `{ direction, axisId, dataIndex }`.
One per axis.
Copy link
Member

Choose a reason for hiding this comment

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

So, if no axis highlight exists, the argument is an empty array, right?

One per axis.

How does this work? I'm trying the example below and there's an x and a y axes, but onAxisInteraction only returns:

[
    {
        "axisId": "x-axis",
        "dataIndex": 4
    }
]

Which axes are supposed to be shown?

One per axis.
Axes without data are ignored by the handler.

The handler gets an array of objects.
Copy link
Member

Choose a reason for hiding this comment

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

Isn't this the same as the content on line 60?

* The controlled axis highlighted.
* Indicates the direction, axis id, and data index to highlight.
*/
highlightedAxis?: AxisItemIdentifier | null;
Copy link
Member

Choose a reason for hiding this comment

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

Isn't it a bit weird that the prop that sets the highlighted is called highlightedAxis, but when it changes, the event handler is called onAxisInteraction?

Should they be axisInteraction/onAxisInteraction or highlightedAxis/onAxisHighlight?

Copy link
Member Author

Choose a reason for hiding this comment

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

My concern is the upcoming tooltip control that will need the same callback to know which axis needs to be displayed.

But we could effectively expose an onAxisHighlight and trigger another function for the tooltip.

This just open a new question. Should onAxisHighlight get an array of axis item or just the first one.

Copy link
Member

Choose a reason for hiding this comment

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

My concern is the upcoming tooltip control that will need the same callback to know which axis needs to be displayed.

Sorry, what do you mean?

But we could effectively expose an onAxisHighlight and trigger another function for the tooltip.

Ah, do you mean onAxisHighlight for axis highlights and onTooltipChange when the tooltip should display different items?

Should onAxisHighlight get an array of axis item or just the first one.

I think that's what I'm trying to get at in my comment below. I think there might be more than one axis highlighted at a time, so we probably need an array.

/**
* The controlled axis item highlighted.
*/
controlledCartesianAxisHighlight?: AxisItemIdentifier | null;
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 we have more than one highlighted axis item?

Copy link
Member Author

Choose a reason for hiding this comment

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

In theory it's not that hard. In practice I don't see usecases.

I can think about the crosshair but when following the point, I feel doing store update would be a bad trade off in terms of performances. Bette to do all the computation in the crosshair component itself.

image

I can also think about someone who would like to highlight multiple values at once. But in that case, it would probably be better to create his own highlight component

Copy link
Member

Choose a reason for hiding this comment

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

In theory it's not that hard. In practice I don't see usecases.

What about charts with multiple X axes? Can't there be more than one axis highlight at a time?

I can also think about someone who would like to highlight multiple values at once. But in that case, it would probably be better to create his own highlight component

Would that be something that's easy to do? Should we have a demo for it?

I suspect it's somewhat useful. For example, you might want to click a button and highlight some points.

Copy link
Member Author

Choose a reason for hiding this comment

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

What about charts with multiple X axes? Can't there be more than one axis highlight at a time?

For me it looks like the worst case scenario with two axis highligh component rendered either on top of each other or fairly close

Would that be something that's easy to do? Should we have a demo for it?

That's a line or a rectangle to render. The logic should not be that different from what the demos in https://mui.com/x/react-charts/components/#scales

Copy link
Member

Choose a reason for hiding this comment

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

For me it looks like the worst case scenario with two axis highligh component rendered either on top of each other or fairly close

Sure, but shouldn't we support it? I think it's a bit weird that in controlled state the handler provides an array of values and the state itself only accepts a single value. IMO, both should accept an array or a single value.

In this case, since there can be multiple axes, I think we should always use an array. What downsides do you see in this?

Copy link

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jun 27, 2025
@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jul 4, 2025
Copy link
Member

@bernardobelchior bernardobelchior left a comment

Choose a reason for hiding this comment

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

Left a couple comments

@@ -116,7 +116,7 @@ describe('ScatterChart - click event', () => {
});
});

describe('onItemClick - disabling voronoi', () => {
describe.skipIf(isJSDOM)('onItemClick - disabling voronoi', () => {
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 now need to skip this test?

Copy link
Member Author

Choose a reason for hiding this comment

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

Initially because I added a pointer down event that triggers the axis highlight. So I needed the SVG coordinates.

But now with JC library it's not needed anymore

@@ -170,7 +186,7 @@ function MarkPlot(props: MarkPlotProps) {
((event) =>
onItemClick(event, { type: 'line', seriesId, dataIndex: index }))
}
isHighlighted={xAxisInteractionIndex === index || isSeriesHighlighted}
isHighlighted={highlightedItems[xAxisId]?.has(index) || isSeriesHighlighted}
isFaded={isSeriesFaded}
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't we also check highlightedItems[xAxisId]?.has(index) to see if something is faded?

Copy link
Member Author

Choose a reason for hiding this comment

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

It's a bit tricky.

We have two king of highlight

  • The item interactions. That are handled by highlight scope. They highlight/fade series.
  • The axis interactions. They only highlight value son a given axis value.

To fix this we should have a single highlight model. But that would require a breaking change

Copy link
Member

Choose a reason for hiding this comment

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

I see. Maybe this is something we should rethink for v9? It's indeed confusing how the different systems interact.

Copy link
Member Author

Choose a reason for hiding this comment

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

👍 Added to #17955

* The argument contains the identifier for all axes with a `data` property.
* @param {AxisItemIdentifier[]} axisItems The array of axes item identifiers.
*/
onHighlightedAxisChange: PropTypes.func,
Copy link
Member

Choose a reason for hiding this comment

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

Why can't we set the highlighted axis for heatmaps?

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 pick the decision to do not have axis highlight on heatmap, because it felt like a weird behavior
and never got any request for it.

Woudl you be ok if I remove the onHighlightedAxisChange from the heatmap?

Copy link
Member

Choose a reason for hiding this comment

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

Would you be ok if I remove the onHighlightedAxisChange from the heatmap?

Yeah 👍 but the heatmap isn't the only chart where this happens. It also happens on ScatterChart, but not ScatterChartPro.

@@ -170,7 +186,7 @@ function MarkPlot(props: MarkPlotProps) {
((event) =>
onItemClick(event, { type: 'line', seriesId, dataIndex: index }))
}
isHighlighted={xAxisInteractionIndex === index || isSeriesHighlighted}
isHighlighted={highlightedItems[xAxisId]?.has(index) || isSeriesHighlighted}
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 this special case for mark plot, but not for other plots?

Copy link
Member Author

Choose a reason for hiding this comment

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

Because a line or an area are not related the the x-axis value.

The only charts with similar behavior would be the bar chart if we allow a highlight par band ;)

Copy link
Member

Choose a reason for hiding this comment

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

Ah, since the line and area span the whole series, there isn't a way to highlight just one specific x-axis value, is that right?

Does that mean that the scatter should have this feature also since it's a kind of a mark plot?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, since the line and area span the whole series, there isn't a way to highlight just one specific x-axis value, is that right?

Yes

Does that mean that the scatter should have this feature also since it's a kind of a mark plot?

No because the scatter point all have different x/y values. The line/bar are the only chart that use axies values and so are impacted by axis highlight

Copy link
Member Author

Choose a reason for hiding this comment

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

To go a bit further, the scatter chart could in theory benefit from it. Because the exact need for onAxisHighlightedChange to be triggered is the presence of data property in the axis.

But it's the kind of thing we should have a more precise example in mind to do an usfull implementation

Copy link
Member

Choose a reason for hiding this comment

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

To go a bit further, the scatter chart could in theory benefit from it. Because the exact need for onAxisHighlightedChange to be triggered is the presence of data property in the axis.

Yeah, that was my suspicion. It might be useful for the crosshair functionality, though.

Copy link
Member Author

Choose a reason for hiding this comment

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

It's easier to create a dedicated componnent that based on the highlighted item get the x/y values and render the crosshair

Copy link
Member

@bernardobelchior bernardobelchior left a comment

Choose a reason for hiding this comment

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

Looking good! Minor comments

Comment on lines +66 to +67
{ axisId: 'x-axis', dataIndex: 0 },
{ axisId: 'y-axis', dataIndex: 0 },
Copy link
Member

Choose a reason for hiding this comment

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

Why is this test calling onHighlightedAxisChange with both the x and y axes, but the others are only calling that callback with the x-axis?

Copy link
Member Author

Choose a reason for hiding this comment

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

Because the y-axis has a data attribute in this test

Copy link
Member

@bernardobelchior bernardobelchior left a comment

Choose a reason for hiding this comment

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

Great job 💪

@alexfauquette alexfauquette merged commit 64a51dd into mui:master Jul 9, 2025
22 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
scope: charts Changes related to the charts. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[charts] Support for controlled highlighting on axis value
4 participants