-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
[charts-pro] Add range selection to zoom slider #17949
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
Changes from all commits
b2d1dc7
95e303c
bdb4b70
0762ad9
7f51c61
6605b1c
d76e80d
98c74a6
802b153
cc46cc0
f560599
67057e9
a436d9a
e63476a
737f5a8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,8 @@ import { | |
| useStore, | ||
| ZoomData, | ||
| ZOOM_SLIDER_MARGIN, | ||
| selectorChartRawAxis, | ||
| ChartState, | ||
| } from '@mui/x-charts/internals'; | ||
| import { styled } from '@mui/material/styles'; | ||
| import { useXAxes, useYAxes } from '@mui/x-charts/hooks'; | ||
|
|
@@ -29,6 +31,7 @@ const ZoomSliderTrack = styled('rect')(({ theme }) => ({ | |
| theme.palette.mode === 'dark' | ||
| ? (theme.vars || theme).palette.grey[800] | ||
| : (theme.vars || theme).palette.grey[300], | ||
| cursor: 'crosshair', | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A bit weird but ok 🤷
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There you go 😛 |
||
| }, | ||
| })); | ||
|
|
||
|
|
@@ -122,13 +125,16 @@ export function ChartAxisZoomSlider({ axisDirection, axisId }: ChartZoomSliderPr | |
|
|
||
| return ( | ||
| <g transform={`translate(${x} ${y})`}> | ||
| <ZoomSliderTrack | ||
| <ChartAxisZoomSliderTrack | ||
| x={axisDirection === 'x' ? 0 : backgroundRectOffset} | ||
| y={axisDirection === 'x' ? backgroundRectOffset : 0} | ||
| height={axisDirection === 'x' ? ZOOM_SLIDER_TRACK_SIZE : drawingArea.height} | ||
| width={axisDirection === 'x' ? drawingArea.width : ZOOM_SLIDER_TRACK_SIZE} | ||
| rx={ZOOM_SLIDER_TRACK_SIZE / 2} | ||
| ry={ZOOM_SLIDER_TRACK_SIZE / 2} | ||
| axisId={axisId} | ||
| axisDirection={axisDirection} | ||
| reverse={reverse} | ||
| /> | ||
| <ChartAxisZoomSliderActiveTrack | ||
| zoomData={zoomData} | ||
|
|
@@ -141,6 +147,140 @@ export function ChartAxisZoomSlider({ axisDirection, axisId }: ChartZoomSliderPr | |
| ); | ||
| } | ||
|
|
||
| interface ChartAxisZoomSliderTrackProps extends React.ComponentProps<'rect'> { | ||
| axisId: AxisId; | ||
| axisDirection: 'x' | 'y'; | ||
| reverse: boolean; | ||
| } | ||
|
|
||
| function ChartAxisZoomSliderTrack({ | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| axisId, | ||
| axisDirection, | ||
| reverse, | ||
| ...other | ||
| }: ChartAxisZoomSliderTrackProps) { | ||
| const ref = React.useRef<SVGRectElement>(null); | ||
| const { instance, svgRef } = useChartContext<[UseChartProZoomSignature]>(); | ||
| const store = useStore<[UseChartProZoomSignature]>(); | ||
|
|
||
| const onPointerDown = function onPointerDown(event: React.PointerEvent<SVGRectElement>) { | ||
| const rect = ref.current; | ||
| const element = svgRef.current; | ||
|
|
||
| if (!rect || !element) { | ||
| return; | ||
| } | ||
|
|
||
| const pointerDownPoint = getSVGPoint(element, event); | ||
| let zoomFromPointerDown = calculateZoomFromPoint(store.getSnapshot(), axisId, pointerDownPoint); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe something like
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. or
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure if
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| if (zoomFromPointerDown === null) { | ||
| return; | ||
| } | ||
|
|
||
| const { minStart, maxEnd } = selectorChartAxisZoomOptionsLookup(store.getSnapshot(), axisId); | ||
|
|
||
| // Ensure the zoomFromPointerDown is within the min and max range | ||
| zoomFromPointerDown = Math.max(Math.min(zoomFromPointerDown, maxEnd), minStart); | ||
|
|
||
| let pointerMoved = false; | ||
|
|
||
| const onPointerMove = rafThrottle(function onPointerMove(pointerMoveEvent: PointerEvent) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a bit weird to define the Does removing the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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.
It's probably negligible, I didn't test it. |
||
| const pointerMovePoint = getSVGPoint(element, pointerMoveEvent); | ||
| const zoomFromPointerMove = calculateZoomFromPoint( | ||
| store.getSnapshot(), | ||
| axisId, | ||
| pointerMovePoint, | ||
| ); | ||
|
|
||
| if (zoomFromPointerMove === null) { | ||
| return; | ||
| } | ||
|
|
||
| pointerMoved = true; | ||
| const zoomOptions = selectorChartAxisZoomOptionsLookup(store.getSnapshot(), axisId); | ||
|
|
||
| instance.setAxisZoomData(axisId, (prevZoomData) => { | ||
| if (zoomFromPointerMove > zoomFromPointerDown) { | ||
| const end = calculateZoomEnd( | ||
| zoomFromPointerMove, | ||
| { ...prevZoomData, start: zoomFromPointerDown }, | ||
| zoomOptions, | ||
| ); | ||
|
|
||
| /* If the starting point is too close to the end that minSpan wouldn't be respected, we need to update the | ||
| * start point. */ | ||
| const start = calculateZoomStart( | ||
| zoomFromPointerDown, | ||
| { ...prevZoomData, start: zoomFromPointerDown, end }, | ||
| zoomOptions, | ||
| ); | ||
|
|
||
| return { ...prevZoomData, start, end }; | ||
| } | ||
|
|
||
| const start = calculateZoomStart( | ||
| zoomFromPointerMove, | ||
| { ...prevZoomData, end: zoomFromPointerDown }, | ||
| zoomOptions, | ||
| ); | ||
|
|
||
| /* If the starting point is too close to the start that minSpan wouldn't be respected, we need to update the | ||
| * start point. */ | ||
| const end = calculateZoomEnd( | ||
| zoomFromPointerDown, | ||
| { ...prevZoomData, start, end: zoomFromPointerDown }, | ||
| zoomOptions, | ||
| ); | ||
|
|
||
| return { ...prevZoomData, start, end }; | ||
| }); | ||
| }); | ||
|
|
||
| const onPointerUp = function onPointerUp(pointerUpEvent: PointerEvent) { | ||
| rect.releasePointerCapture(pointerUpEvent.pointerId); | ||
| rect.removeEventListener('pointermove', onPointerMove); | ||
| document.removeEventListener('pointerup', onPointerUp); | ||
|
|
||
| if (pointerMoved) { | ||
| return; | ||
| } | ||
|
|
||
| // If the pointer didn't move, we still need to respect the zoom constraints (minSpan, etc.) | ||
| // In that case, we assume the start to be the pointerZoom and calculate the end. | ||
| const pointerUpPoint = getSVGPoint(element, pointerUpEvent); | ||
| const zoomFromPointerUp = calculateZoomFromPoint(store.getSnapshot(), axisId, pointerUpPoint); | ||
|
|
||
| if (zoomFromPointerUp === null) { | ||
| return; | ||
| } | ||
|
|
||
| const zoomOptions = selectorChartAxisZoomOptionsLookup(store.getSnapshot(), axisId); | ||
|
|
||
| instance.setAxisZoomData(axisId, (prev) => ({ | ||
| ...prev, | ||
| start: zoomFromPointerUp, | ||
| end: calculateZoomEnd(zoomFromPointerUp, prev, zoomOptions), | ||
| })); | ||
| }; | ||
|
|
||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
|
|
||
| rect.setPointerCapture(event.pointerId); | ||
| document.addEventListener('pointerup', onPointerUp); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Couldn't make it work when
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is normal |
||
| rect.addEventListener('pointermove', onPointerMove); | ||
|
|
||
| instance.setAxisZoomData(axisId, (prev) => ({ | ||
| ...prev, | ||
| start: zoomFromPointerDown, | ||
| end: zoomFromPointerDown, | ||
| })); | ||
|
Comment on lines
+274
to
+278
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the purpose of this The only effect is to clear the chart between pointer-down and pointer-move 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
It's a higher level function that IMO, our
We could do something like that as well. @kenanyusuf @noraleonte what's your take? Should I try to do something like this?
Is that a good idea? What's the point of min/max span if you can disrepect it?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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.
It seems the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I don't think it is better though 😆
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I'm ok with not respecting min/max span while dragging, but after pointer release we ensure those constraints are met.
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.
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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
UX-wise I think it's debatable, but for performance I think updating the chart only after pointer up would be much better.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. UX-wise, it's fairly similar to the brush zoom
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. 😄 |
||
| }; | ||
|
|
||
| return <ZoomSliderTrack ref={ref} onPointerDown={onPointerDown} {...other} />; | ||
| } | ||
|
|
||
| const formatter = Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }); | ||
| const zoomValueFormatter = (value: number) => formatter.format(value); | ||
|
|
||
|
|
@@ -185,25 +325,17 @@ function ChartAxisZoomSliderActiveTrack({ | |
| let prevPointerZoom = 0; | ||
|
|
||
| const onPointerMove = rafThrottle((event: PointerEvent) => { | ||
| const { left, top, height, width } = selectorChartDrawingArea(store.getSnapshot()); | ||
| const axisZoomData = selectorChartAxisZoomData(store.getSnapshot(), axisId); | ||
| const element = svgRef.current; | ||
|
|
||
| if (!axisZoomData || !element) { | ||
| if (!element) { | ||
| return; | ||
| } | ||
|
|
||
| const point = getSVGPoint(element, event); | ||
| let pointerZoom = calculateZoomFromPoint(store.getSnapshot(), axisId, point); | ||
|
|
||
| let pointerZoom: number; | ||
| if (axisDirection === 'x') { | ||
| pointerZoom = ((point.x - left) / width) * 100; | ||
| } else { | ||
| pointerZoom = ((top + height - point.y) / height) * 100; | ||
| } | ||
|
|
||
| if (reverse) { | ||
| pointerZoom = 100 - pointerZoom; | ||
| if (pointerZoom === null) { | ||
| return; | ||
| } | ||
|
|
||
| pointerZoom = Math.max(pointerZoomMin, Math.min(pointerZoomMax, pointerZoom)); | ||
|
|
@@ -216,7 +348,7 @@ function ChartAxisZoomSliderActiveTrack({ | |
|
|
||
| const onPointerUp = () => { | ||
| activePreviewRect.removeEventListener('pointermove', onPointerMove); | ||
| activePreviewRect.removeEventListener('pointerup', onPointerUp); | ||
| document.removeEventListener('pointerup', onPointerUp); | ||
| setShowTooltip(null); | ||
| }; | ||
|
|
||
|
|
@@ -225,7 +357,6 @@ function ChartAxisZoomSliderActiveTrack({ | |
| event.preventDefault(); | ||
| activePreviewRect.setPointerCapture(event.pointerId); | ||
|
|
||
| const { left, top, height, width } = selectorChartDrawingArea(store.getSnapshot()); | ||
| const axisZoomData = selectorChartAxisZoomData(store.getSnapshot(), axisId); | ||
| const element = svgRef.current; | ||
|
|
||
|
|
@@ -234,25 +365,18 @@ function ChartAxisZoomSliderActiveTrack({ | |
| } | ||
|
|
||
| const point = getSVGPoint(element, event); | ||
| const pointerDownZoom = calculateZoomFromPoint(store.getSnapshot(), axisId, point); | ||
|
|
||
| // The corresponding value of zoom where the pointer was pressed | ||
| let pointerDownZoom: number; | ||
| if (axisDirection === 'x') { | ||
| pointerDownZoom = ((point.x - left) / width) * 100; | ||
| } else { | ||
| pointerDownZoom = ((top + height - point.y) / height) * 100; | ||
| } | ||
|
|
||
| if (reverse) { | ||
| pointerDownZoom = 100 - pointerDownZoom; | ||
| if (pointerDownZoom === null) { | ||
| return; | ||
| } | ||
|
|
||
| prevPointerZoom = pointerDownZoom; | ||
| pointerZoomMin = pointerDownZoom - axisZoomData.start; | ||
| pointerZoomMax = 100 - (axisZoomData.end - pointerDownZoom); | ||
|
|
||
| setShowTooltip('both'); | ||
| activePreviewRect.addEventListener('pointerup', onPointerUp); | ||
| document.addEventListener('pointerup', onPointerUp); | ||
| activePreviewRect.addEventListener('pointermove', onPointerMove); | ||
| }; | ||
|
|
||
|
|
@@ -275,22 +399,14 @@ function ChartAxisZoomSliderActiveTrack({ | |
| const point = getSVGPoint(element, event); | ||
|
|
||
| instance.setZoomData((prevZoomData) => { | ||
| const { left, top, width, height } = selectorChartDrawingArea(store.value); | ||
|
|
||
| const zoomOptions = selectorChartAxisZoomOptionsLookup(store.value, axisId); | ||
| const zoomOptions = selectorChartAxisZoomOptionsLookup(store.getSnapshot(), axisId); | ||
|
|
||
| return prevZoomData.map((zoom) => { | ||
| if (zoom.axisId === axisId) { | ||
| let newStart: number; | ||
|
|
||
| if (axisDirection === 'x') { | ||
| newStart = ((point.x - left) / width) * 100; | ||
| } else { | ||
| newStart = ((top + height - point.y) / height) * 100; | ||
| } | ||
| const newStart = calculateZoomFromPoint(store.getSnapshot(), axisId, point); | ||
|
|
||
| if (reverse) { | ||
| newStart = 100 - newStart; | ||
| if (newStart === null) { | ||
| return zoom; | ||
| } | ||
|
|
||
| return { | ||
|
|
@@ -314,9 +430,8 @@ function ChartAxisZoomSliderActiveTrack({ | |
| const point = getSVGPoint(element, event); | ||
|
|
||
| instance.setZoomData((prevZoomData) => { | ||
| const { left, top, width, height } = selectorChartDrawingArea(store.value); | ||
|
|
||
| const zoomOptions = selectorChartAxisZoomOptionsLookup(store.value, axisId); | ||
| const { left, top, width, height } = selectorChartDrawingArea(store.getSnapshot()); | ||
| const zoomOptions = selectorChartAxisZoomOptionsLookup(store.getSnapshot(), axisId); | ||
|
|
||
| return prevZoomData.map((zoom) => { | ||
| if (zoom.axisId === axisId) { | ||
|
|
@@ -449,6 +564,30 @@ function ChartAxisZoomSliderActiveTrack({ | |
| ); | ||
| } | ||
|
|
||
| export function calculateZoomFromPoint(state: ChartState<any>, axisId: AxisId, point: DOMPoint) { | ||
| const { left, top, height, width } = selectorChartDrawingArea(state); | ||
| const axis = selectorChartRawAxis(state, axisId); | ||
|
|
||
| if (!axis) { | ||
| return null; | ||
| } | ||
|
|
||
| const axisDirection = axis.position === 'right' || axis.position === 'left' ? 'y' : 'x'; | ||
|
|
||
| let pointerZoom: number; | ||
| if (axisDirection === 'x') { | ||
| pointerZoom = ((point.x - left) / width) * 100; | ||
| } else { | ||
| pointerZoom = ((top + height - point.y) / height) * 100; | ||
| } | ||
|
|
||
| if (axis.reverse) { | ||
| pointerZoom = 100 - pointerZoom; | ||
| } | ||
|
|
||
| return pointerZoom; | ||
| } | ||
|
|
||
| export function calculateZoomStart( | ||
| newStart: number, | ||
| currentZoom: ZoomData, | ||
|
|
||

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.
Updated demo so it's easier to test the range selection