Skip to content

Conversation

@bernardobelchior
Copy link
Member

Part of #15383.

Adds a range selection to zoom slider. It respects all zoom options (e.g., minSpan, minStart).

Screen.Recording.2025-05-21.at.16.48.10.mov

Some edge cases

Selection starts too close to the end and the cursor goes to the right, so the minSpan of 10 wouldn't be respected. In this case, we need to move the start to the left.

Screen.Recording.2025-05-21.at.16.49.29.mov

Selection starts outside minStart. In this case, we move the start to minStart and adjust the end accordingly.

Screen.Recording.2025-05-21.at.17.17.04.mov

@github-actions
Copy link

github-actions bot commented May 21, 2025

Thanks for adding a type label to the PR! 👍

Comment on lines +53 to +55
{ axisId: 'x', start: 45, end: 55 },
{ axisId: 'x2', start: 30, end: 70 },
{ axisId: 'y', start: 10, end: 90 },
{ axisId: 'y', start: 40, end: 60 },
Copy link
Member Author

Choose a reason for hiding this comment

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

Updated demo so it's easier to test the range selection

event.stopPropagation();

rect.setPointerCapture(event.pointerId);
document.addEventListener('pointerup', onPointerUp);
Copy link
Member Author

Choose a reason for hiding this comment

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

Couldn't make it work when pointerup listener was set on rect. The event wasn't being triggered in some cases.

Copy link
Member

Choose a reason for hiding this comment

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

this is normal

@mui-bot
Copy link

mui-bot commented May 21, 2025

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

Bundle size report

Total Size Change:${\tiny{\color{red}▲}}$+10.9KB(+0.09%) - Total Gzip Change:${\tiny{\color{red}▲}}$+3.33KB(+0.09%)
Files: 118 total (0 added, 0 removed, 37 changed)

@mui/x-charts-proparsed:${\tiny{\color{red}▲}}$+1.59KB(+0.49%) gzip:${\tiny{\color{red}▲}}$+491B(+0.49%)
@mui/x-charts-pro/LineChartProparsed:${\tiny{\color{red}▲}}$+1.58KB(+0.71%) gzip:${\tiny{\color{red}▲}}$+548B(+0.76%)
@mui/x-charts-pro/BarChartProparsed:${\tiny{\color{red}▲}}$+1.58KB(+0.76%) gzip:${\tiny{\color{red}▲}}$+467B(+0.69%)
@mui/x-charts-pro/ScatterChartProparsed:${\tiny{\color{red}▲}}$+1.58KB(+0.82%) gzip:${\tiny{\color{red}▲}}$+412B(+0.65%)
@mui/x-charts-pro/ChartZoomSliderparsed:${\tiny{\color{red}▲}}$+1.45KB(+2.25%) gzip:${\tiny{\color{red}▲}}$+436B(+1.89%)

Show 32 more bundle changes

@mui/x-charts-pro/ChartDataProviderProparsed:${\tiny{\color{red}▲}}$+240B(+0.18%) gzip:${\tiny{\color{red}▲}}$+67B(+0.15%)
@mui/x-charts-pro/Heatmapparsed:${\tiny{\color{red}▲}}$+240B(+0.13%) gzip:${\tiny{\color{red}▲}}$+64B(+0.11%)
@mui/x-charts-pro/ChartContainerProparsed:${\tiny{\color{red}▲}}$+237B(+0.17%) gzip:${\tiny{\color{red}▲}}$+71B(+0.15%)
@mui/x-charts-pro/FunnelChartparsed:${\tiny{\color{red}▲}}$+237B(+0.12%) gzip:${\tiny{\color{red}▲}}$+65B(+0.10%)
@mui/x-charts/ChartsAxisparsed:${\tiny{\color{red}▲}}$+109B(+0.15%) gzip:${\tiny{\color{red}▲}}$+37B(+0.15%)
@mui/x-charts/ChartsXAxisparsed:${\tiny{\color{red}▲}}$+109B(+0.16%) gzip:${\tiny{\color{red}▲}}$+34B(+0.14%)
@mui/x-charts/ChartsYAxisparsed:${\tiny{\color{red}▲}}$+109B(+0.17%) gzip:${\tiny{\color{red}▲}}$+36B(+0.15%)
@mui/x-charts/LineChartparsed:${\tiny{\color{red}▲}}$+109B(+0.06%) gzip:${\tiny{\color{red}▲}}$+35B(+0.06%)
@mui/x-charts/BarChartparsed:${\tiny{\color{red}▲}}$+108B(+0.06%) gzip:${\tiny{\color{red}▲}}$+40B(+0.07%)
@mui/x-charts/ChartsLegendparsed:${\tiny{\color{red}▲}}$+108B(+0.15%) gzip:${\tiny{\color{red}▲}}$+34B(+0.14%)
@mui/x-charts/RadarChartparsed:${\tiny{\color{red}▲}}$+108B(+0.07%) gzip:${\tiny{\color{red}▲}}$+36B(+0.07%)
@mui/x-chartsparsed:${\tiny{\color{red}▲}}$+106B(+0.04%) gzip:${\tiny{\color{red}▲}}$+42B(+0.05%)
@mui/x-charts-pro/ChartsToolbarProparsed:${\tiny{\color{red}▲}}$+106B(+0.19%) gzip:${\tiny{\color{red}▲}}$+32B(+0.16%)
@mui/x-charts/ChartContainerparsed:${\tiny{\color{red}▲}}$+106B(+0.09%) gzip:${\tiny{\color{red}▲}}$+33B(+0.09%)
@mui/x-charts/ChartDataProviderparsed:${\tiny{\color{red}▲}}$+106B(+0.10%) gzip:${\tiny{\color{red}▲}}$+33B(+0.09%)
@mui/x-charts/ChartsAxisHighlightparsed:${\tiny{\color{red}▲}}$+106B(+0.18%) gzip:${\tiny{\color{red}▲}}$+34B(+0.16%)
@mui/x-charts/ChartsGridparsed:${\tiny{\color{red}▲}}$+106B(+0.18%) gzip:${\tiny{\color{red}▲}}$+35B(+0.17%)
@mui/x-charts/ChartsReferenceLineparsed:${\tiny{\color{red}▲}}$+106B(+0.18%) gzip:${\tiny{\color{red}▲}}$+39B(+0.18%)
@mui/x-charts/ChartsSurfaceparsed:${\tiny{\color{red}▲}}$+106B(+0.18%) gzip:${\tiny{\color{red}▲}}$+37B(+0.18%)
@mui/x-charts/ChartsTooltipparsed:${\tiny{\color{red}▲}}$+106B(+0.14%) gzip:${\tiny{\color{red}▲}}$+32B(+0.12%)
@mui/x-charts/Gaugeparsed:${\tiny{\color{red}▲}}$+106B(+0.10%) gzip:${\tiny{\color{red}▲}}$+31B(+0.08%)
@mui/x-charts/PieChartparsed:${\tiny{\color{red}▲}}$+106B(+0.07%) gzip:${\tiny{\color{red}▲}}$+31B(+0.06%)
@mui/x-charts/ScatterChartparsed:${\tiny{\color{red}▲}}$+106B(+0.07%) gzip:${\tiny{\color{red}▲}}$+39B(+0.08%)
@mui/x-charts/SparkLineChartparsed:${\tiny{\color{red}▲}}$+106B(+0.06%) gzip:${\tiny{\color{red}▲}}$+34B(+0.06%)
@mui/x-data-grid-pro/DataGridProparsed: 0B(0.00%) gzip:${\tiny{\color{red}▲}}$+1B(0.00%)
@mui/x-date-pickers-pro/DateRangeCalendarparsed: 0B(0.00%) gzip:${\tiny{\color{green}▼}}$-2B(-0.01%)
@mui/x-date-pickers-pro/DateRangePickerparsed: 0B(0.00%) gzip:${\tiny{\color{red}▲}}$+1B(0.00%)
@mui/x-date-pickers-pro/DateRangePickerDayparsed: 0B(0.00%) gzip:${\tiny{\color{red}▲}}$+1B(+0.01%)
@mui/x-date-pickers-pro/DesktopDateTimeRangePickerparsed: 0B(0.00%) gzip:${\tiny{\color{green}▼}}$-1B(0.00%)
@mui/x-date-pickers-pro/MobileDateRangePickerparsed: 0B(0.00%) gzip:${\tiny{\color{red}▲}}$+1B(0.00%)
@mui/x-tree-view-proparsed: 0B(0.00%) gzip:${\tiny{\color{green}▼}}$-1B(0.00%)
@mui/x-tree-view-pro/RichTreeViewProparsed: 0B(0.00%) gzip:${\tiny{\color{red}▲}}$+1B(0.00%)

Details of bundle changes

Generated by 🚫 dangerJS against 737f5a8

reverse: boolean;
}

function ChartAxisZoomSliderTrack({
Copy link
Member Author

Choose a reason for hiding this comment

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

This file is getting huge. I'll move the components to other files in a follow-up.

Copy link
Member Author

Choose a reason for hiding this comment

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

@bernardobelchior bernardobelchior added type: new feature Expand the scope of the product to solve a new problem. scope: charts Changes related to the charts. labels May 21, 2025
@bernardobelchior bernardobelchior mentioned this pull request May 21, 2025
6 tasks
@codspeed-hq
Copy link

codspeed-hq bot commented May 21, 2025

CodSpeed Performance Report

Merging #17949 will not alter performance

Comparing bernardobelchior:zoom-slider-range-selection (737f5a8) with master (48666ad)

Summary

✅ 9 untouched benchmarks

@bernardobelchior bernardobelchior force-pushed the zoom-slider-range-selection branch from bb1be02 to 4d3f704 Compare May 22, 2025 07:14
@bernardobelchior bernardobelchior force-pushed the zoom-slider-range-selection branch from 4d3f704 to e63476a Compare May 22, 2025 15:00
@bernardobelchior bernardobelchior marked this pull request as ready for review May 22, 2025 15:40
@bernardobelchior bernardobelchior requested review from JCQuintas and alexfauquette and removed request for alexfauquette May 22, 2025 15:40
theme.palette.mode === 'dark'
? (theme.vars || theme).palette.grey[800]
: (theme.vars || theme).palette.grey[300],
cursor: 'crosshair',
Copy link
Member

Choose a reason for hiding this comment

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

A bit weird but ok 🤷

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 don't love it either. I'm in contact with @noraleonte and @kenanyusuf to discuss what a better cursor would be.

We're having a hard time finding a better solution.

Copy link
Member Author

Choose a reason for hiding this comment

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

There you go 😛
#17977

Comment on lines 201 to 205
const end = calculateZoomEnd(
pointerZoom,
{ ...prevZoomData, start: pointerDownZoom },
zoomOptions,
);
Copy link
Member

Choose a reason for hiding this comment

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

Names are a bit confusing here, but I don't have suggestions 🤔

Copy link
Member Author

@bernardobelchior bernardobelchior May 23, 2025

Choose a reason for hiding this comment

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

I've updated them. Do you think they're better now? If not, I can revert them

event.stopPropagation();

rect.setPointerCapture(event.pointerId);
document.addEventListener('pointerup', onPointerUp);
Copy link
Member

Choose a reason for hiding this comment

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

this is normal

}

const pointerDownPoint = getSVGPoint(element, event);
let zoomFromPointerDown = calculateZoomFromPoint(store.getSnapshot(), axisId, pointerDownPoint);
Copy link
Member

Choose a reason for hiding this comment

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

maybe something like firstZoomPoint and currentZoomPoint for the move one?

Copy link
Member

Choose a reason for hiding this comment

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

or initialZoomPoint

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'm not sure if firstZoomPoint is better because this isn't a point, it's the zoom value (ranging from 0 to 100) of the pointer down event. I could call it initialZoom, but not sure if that's less confusing 🤔

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'll merge this PR as it's been approved, but we can keep discussing and I'll update the code in a follow-up PR.

Copy link
Member

Choose a reason for hiding this comment

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

My line of thought was that it is a point in the zoom "scale"

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, we can look at it that way. I'm afraid it might be confused with the SVGPoint that we're using in the same scope, that's why I initially tried suffixing it with Zoom

@bernardobelchior bernardobelchior merged commit 0df3580 into mui:master May 23, 2025
24 checks passed
@bernardobelchior bernardobelchior deleted the zoom-slider-range-selection branch May 23, 2025 07:38
Copy link
Member

@alexfauquette alexfauquette left a comment

Choose a reason for hiding this comment

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

Seems to be already merged 🙈

}

const pointerDownPoint = getSVGPoint(element, event);
let zoomFromPointerDown = calculateZoomFromPoint(store.getSnapshot(), axisId, pointerDownPoint);
Copy link
Member

Choose a reason for hiding this comment

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

or initialZoomPoint


let pointerMoved = false;

const onPointerMove = rafThrottle(function onPointerMove(pointerMoveEvent: PointerEvent) {
Copy link
Member

Choose a reason for hiding this comment

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

It's a bit weird to define the onPointerMove inside the onPointerDown

Does removing the pointerMove/pointerDown from document makes a difference in terms of performances?

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 weird to define the onPointerMove inside the onPointerDown

Why? I think it makes sense. We only need to listen to the pointer move events after a pointer down happens, so it makes sense that we only add the listener after a pointer down event.

We could add the event listener regardless, but then we're calling a function on every move just to do nothing. Seems a bit useless, IMO.

Does removing the pointerMove/pointerDown from document makes a difference in terms of performances?

It's probably negligible, I didn't test it.

Comment on lines +274 to +278
instance.setAxisZoomData(axisId, (prev) => ({
...prev,
start: zoomFromPointerDown,
end: zoomFromPointerDown,
}));
Copy link
Member

Choose a reason for hiding this comment

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

What's the purpose of this setAxisZoomData?

The only effect is to clear the chart between pointer-down and pointer-move

image

On this topic, Echarts has a much better interaction. The pointer down and move only show the future range. And it's on pointer up that the zoom get applied. For this feature, they do not respect the min/max span.

Which seems better than having a jumping selection

Capture.video.du.2025-05-23.09-46-27.mp4

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's the purpose of this setAxisZoomData?

It's a higher level function that setZoomData that allows setting the zoom data for a specific axis, instead of having to iterate over all axes to find the one you want and update it.

IMO, our setZoomData allows too much control. For example, you can disrepect a minStart or minSpan with it. I'm not sure we should allow users to do that.

On this topic, Echarts has a much better interaction. The pointer down and move only show the future range. And it's on pointer up that the zoom get applied.

We could do something like that as well. @kenanyusuf @noraleonte what's your take? Should I try to do something like this?

For this feature, they do not respect the min/max span.

Is that a good idea? What's the point of min/max span if you can disrepect it?

Copy link
Member

Choose a reason for hiding this comment

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

Is that a good idea? What's the point of min/max span if you can disrepect it?

It's feasible to respect if with an interaction like: if the preview does not respect min/max span we ignore it, or have a different visualisation

Even if we dont not respect it with this interaction, it's usefull for the other interactions.

For me the minSpan is useful to avoid user zooming infinitely.

IMO, our setZoomData allows too much control. For example, you can disrepect a minStart or minSpan with it. I'm not sure we should allow users to do that.

It seems the setAxisZoomData does not respect minSpan since you can call it with start = end (which means span=0) 🙈

Copy link
Member

Choose a reason for hiding this comment

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

On this topic, Echarts has a much better interaction. The pointer down and move only show the future range. And it's on pointer up that the zoom get applied.

I don't think it is better though 😆
It feels a bit odd that the selection is only applied after pointer up

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 feasible to respect if with an interaction like: if the preview does not respect min/max span we ignore it, or have a different visualisation

I'm ok with not respecting min/max span while dragging, but after pointer release we ensure those constraints are met.

Even if we dont not respect it with this interaction, it's usefull for the other interactions.

For me the minSpan is useful to avoid user zooming infinitely.

Yeah, but if the range selection doesn't respect the min/max span, then a user can zoom in too much.

Plus, I think it would be pretty unexpected to for the range selection to bypass min/max span. I think users wouldn't expect it.

It seems the setAxisZoomData does not respect minSpan since you can call it with start = end (which means span=0) 🙈

Yeah, at the moment we aren't respecting it. What I meant is that I think we should provide higher-level functions that respect it. E.g., instead of setAxisZoomData, you could have moveAxisZoomStart, moveAxisZoomEnd, moveAxisZoomRange, zoomIn, zoomOut, etc.

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 don't think it is better though 😆
It feels a bit odd that the selection is only applied after pointer up

UX-wise I think it's debatable, but for performance I think updating the chart only after pointer up would be much better.

Copy link
Member

Choose a reason for hiding this comment

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

UX-wise, it's fairly similar to the brush zoom
like in rechart Applied on the overview instead of the drawing area

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 argue it is similar, but fairly different.

The brush zoom you are selecting part of your content. It makes sense for it to only apply the selection after the fact, since you would change the value being currently selected as you move the mouse.

The slider is a control, an abstraction over the zoom, which the user already implies has a meaning, and which in turn allows it to directly affect the content.

See that in the first case, the control is static, you are selecting the area you want to see. While in the second case, the control is dynamic, you are adapting the view to what you want to see.

Just like the gap example in funnel chart changes the gap instantly, so should the zoom slider change the zoom.

Copy link
Member

Choose a reason for hiding this comment

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

I say that from a UX point of view. Due to performance reasons we can offer both if the case arises eventually. 😄

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: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants