Skip to content

Commit cd68580

Browse files
[charts] Improve zoomed highlight behaviour (#13868)
Signed-off-by: Jose C Quintas Jr <[email protected]> Co-authored-by: Alexandre Fauquette <[email protected]>
1 parent 56a6900 commit cd68580

File tree

14 files changed

+89
-89
lines changed

14 files changed

+89
-89
lines changed

packages/x-charts-pro/src/BarChartPro/BarChartPro.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,10 @@ const BarChartPro = React.forwardRef(function BarChartPro(props: BarChartProProp
5151
<g {...clipPathGroupProps}>
5252
<BarChartPlotZoom {...barPlotProps} />
5353
<ChartsOverlay {...overlayProps} />
54+
<ChartsAxisHighlight {...axisHighlightProps} />
5455
</g>
5556
<ChartsAxis {...chartsAxisProps} />
5657
<ChartsLegend {...legendProps} />
57-
<ChartsAxisHighlight {...axisHighlightProps} />
5858
{!props.loading && <ChartsTooltip {...tooltipProps} />}
5959
<ChartsClipPath {...clipPathProps} />
6060
<ZoomSetup />

packages/x-charts-pro/src/LineChartPro/LineChartPro.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ const LineChartPro = React.forwardRef(function LineChartPro(props: LineChartProP
6262
<AreaPlotZoom {...areaPlotProps} />
6363
<LinePlotZoom {...linePlotProps} />
6464
<ChartsOverlay {...overlayProps} />
65+
<ChartsAxisHighlight {...axisHighlightProps} />
6566
</g>
6667
<ChartsAxis {...chartsAxisProps} />
67-
<ChartsAxisHighlight {...axisHighlightProps} />
6868
<MarkPlotZoom {...markPlotProps} />
6969
<LineHighlightPlot {...lineHighlightPlotProps} />
7070
<ChartsLegend {...legendProps} />

packages/x-charts-pro/src/context/ZoomProvider/useSetupPan.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,9 @@ import { getSVGPoint } from '@mui/x-charts/internals';
44
import { useZoom } from './useZoom';
55
import { ZoomData } from './Zoom.types';
66

7-
const isPointOutside = (
8-
point: { x: number; y: number },
9-
area: { left: number; top: number; width: number; height: number },
10-
) => {
11-
const outsideX = point.x < area.left || point.x > area.left + area.width;
12-
const outsideY = point.y < area.top || point.y > area.top + area.height;
13-
return outsideX || outsideY;
14-
};
15-
167
export const useSetupPan = () => {
178
const { zoomData, setZoomData, setIsInteracting, isPanEnabled, options } = useZoom();
18-
const area = useDrawingArea();
9+
const drawingArea = useDrawingArea();
1910

2011
const svgRef = useSvgRef();
2112

@@ -55,7 +46,7 @@ export const useSetupPan = () => {
5546
const MAX_PERCENT = option.maxEnd;
5647

5748
const movement = option.axisDirection === 'x' ? movementX : movementY;
58-
const dimension = option.axisDirection === 'x' ? area.width : area.height;
49+
const dimension = option.axisDirection === 'x' ? drawingArea.width : drawingArea.height;
5950

6051
let newMinPercent = min - (movement / dimension) * span;
6152
let newMaxPercent = max - (movement / dimension) * span;
@@ -93,7 +84,7 @@ export const useSetupPan = () => {
9384
eventCacheRef.current.push(event);
9485
const point = getSVGPoint(element, event);
9586

96-
if (isPointOutside(point, area)) {
87+
if (!drawingArea.isPointInside(point)) {
9788
return;
9889
}
9990

@@ -135,5 +126,14 @@ export const useSetupPan = () => {
135126
document.removeEventListener('pointercancel', handleUp);
136127
document.removeEventListener('pointerleave', handleUp);
137128
};
138-
}, [area, svgRef, isDraggingRef, setIsInteracting, zoomData, setZoomData, isPanEnabled, options]);
129+
}, [
130+
drawingArea,
131+
svgRef,
132+
isDraggingRef,
133+
setIsInteracting,
134+
zoomData,
135+
setZoomData,
136+
isPanEnabled,
137+
options,
138+
]);
139139
};

packages/x-charts-pro/src/context/ZoomProvider/useSetupZoom.ts

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,9 @@ const zoomAtPoint = (
5454
return [newMinRange, newMaxRange];
5555
};
5656

57-
const isPointOutside = (
58-
point: { x: number; y: number },
59-
area: { left: number; top: number; width: number; height: number },
60-
) => {
61-
const outsideX = point.x < area.left || point.x > area.left + area.width;
62-
const outsideY = point.y < area.top || point.y > area.top + area.height;
63-
return outsideX || outsideY;
64-
};
65-
6657
export const useSetupZoom = () => {
6758
const { zoomData, setZoomData, isZoomEnabled, options, setIsInteracting } = useZoom();
68-
const area = useDrawingArea();
59+
const drawingArea = useDrawingArea();
6960

7061
const svgRef = useSvgRef();
7162
const eventCacheRef = React.useRef<PointerEvent[]>([]);
@@ -85,7 +76,7 @@ export const useSetupZoom = () => {
8576

8677
const point = getSVGPoint(element, event);
8778

88-
if (isPointOutside(point, area)) {
79+
if (!drawingArea.isPointInside(point)) {
8980
return;
9081
}
9182

@@ -104,8 +95,8 @@ export const useSetupZoom = () => {
10495
const option = options[zoom.axisId];
10596
const centerRatio =
10697
option.axisDirection === 'x'
107-
? getHorizontalCenterRatio(point, area)
108-
: getVerticalCenterRatio(point, area);
98+
? getHorizontalCenterRatio(point, drawingArea)
99+
: getVerticalCenterRatio(point, drawingArea);
109100

110101
const { scaleRatio, isZoomIn } = getWheelScaleRatio(event, option.step);
111102
const [newMinRange, newMaxRange] = zoomAtPoint(centerRatio, scaleRatio, zoom, option);
@@ -162,8 +153,8 @@ export const useSetupZoom = () => {
162153

163154
const centerRatio =
164155
option.axisDirection === 'x'
165-
? getHorizontalCenterRatio(point, area)
166-
: getVerticalCenterRatio(point, area);
156+
? getHorizontalCenterRatio(point, drawingArea)
157+
: getVerticalCenterRatio(point, drawingArea);
167158

168159
const [newMinRange, newMaxRange] = zoomAtPoint(centerRatio, scaleRatio, zoom, option);
169160

@@ -219,7 +210,7 @@ export const useSetupZoom = () => {
219210
clearTimeout(interactionTimeoutRef.current);
220211
}
221212
};
222-
}, [svgRef, setZoomData, zoomData, area, isZoomEnabled, options, setIsInteracting]);
213+
}, [svgRef, setZoomData, zoomData, drawingArea, isZoomEnabled, options, setIsInteracting]);
223214
};
224215

225216
/**

packages/x-charts/src/BarChart/BarChart.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,10 @@ const BarChart = React.forwardRef(function BarChart(props: BarChartProps, ref) {
132132
<g {...clipPathGroupProps}>
133133
<BarPlot {...barPlotProps} />
134134
<ChartsOverlay {...overlayProps} />
135+
<ChartsAxisHighlight {...axisHighlightProps} />
135136
</g>
136137
<ChartsAxis {...chartsAxisProps} />
137138
<ChartsLegend {...legendProps} />
138-
<ChartsAxisHighlight {...axisHighlightProps} />
139139
{!props.loading && <ChartsTooltip {...tooltipProps} />}
140140
<ChartsClipPath {...clipPathProps} />
141141
{children}

packages/x-charts/src/ChartsLegend/DefaultChartsLegend.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export interface LegendRendererProps extends Omit<LegendPerItemProps, 'itemsToDi
1010
/**
1111
* @deprecated Use the `useDrawingArea` hook instead.
1212
*/
13-
drawingArea: DrawingArea;
13+
drawingArea: Omit<DrawingArea, 'isPointInside'>;
1414
}
1515

1616
function DefaultChartsLegend(props: LegendRendererProps) {

packages/x-charts/src/ChartsVoronoiHandler/ChartsVoronoiHandler.tsx

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,14 @@ type VoronoiSeries = { seriesId: SeriesId; startIndex: number; endIndex: number
3131
function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) {
3232
const { voronoiMaxRadius, onItemClick } = props;
3333
const svgRef = useSvgRef();
34-
const { left, top, width, height } = useDrawingArea();
34+
const drawingArea = useDrawingArea();
3535
const { xAxis, yAxis, xAxisIds, yAxisIds } = useCartesianContext();
3636
const { dispatch } = React.useContext(InteractionContext);
3737

3838
const { series, seriesOrder } = useScatterSeries() ?? {};
3939
const voronoiRef = React.useRef<Record<string, VoronoiSeries>>({});
4040
const delauneyRef = React.useRef<Delaunay<any> | undefined>(undefined);
41+
const lastFind = React.useRef<number | undefined>(undefined);
4142

4243
const { setHighlighted, clearHighlighted } = useHighlighted();
4344

@@ -70,7 +71,20 @@ function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) {
7071
const getXPosition = getValueToPositionMapper(xScale);
7172
const getYPosition = getValueToPositionMapper(yScale);
7273

73-
const seriesPoints = data.flatMap(({ x, y }) => [getXPosition(x), getYPosition(y)]);
74+
const seriesPoints = data.flatMap(({ x, y }) => {
75+
const pointX = getXPosition(x);
76+
const pointY = getYPosition(y);
77+
78+
if (!drawingArea.isPointInside({ x: pointX, y: pointY })) {
79+
// If the point is not displayed we move them to a trash coordinate.
80+
// This avoids managing index mapping before/after filtering.
81+
// The trash point is far enough such that any point in the drawing area will be closer to the mouse than the trash coordinate.
82+
return [-drawingArea.width, -drawingArea.height];
83+
}
84+
85+
return [pointX, pointY];
86+
});
87+
7488
voronoiRef.current[seriesId] = {
7589
seriesId,
7690
startIndex: points.length,
@@ -80,15 +94,15 @@ function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) {
8094
});
8195

8296
delauneyRef.current = new Delaunay(points);
83-
}, [defaultXAxisId, defaultYAxisId, series, seriesOrder, xAxis, yAxis]);
97+
lastFind.current = undefined;
98+
}, [defaultXAxisId, defaultYAxisId, series, seriesOrder, xAxis, yAxis, drawingArea]);
8499

85100
React.useEffect(() => {
86101
const element = svgRef.current;
87102
if (element === null) {
88103
return undefined;
89104
}
90105

91-
// TODO: A perf optimisation of voronoi could be to use the last point as the initial point for the next search.
92106
function getClosestPoint(
93107
event: MouseEvent,
94108
):
@@ -99,21 +113,21 @@ function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) {
99113
// Get mouse coordinate in global SVG space
100114
const svgPoint = getSVGPoint(element, event);
101115

102-
const outsideX = svgPoint.x < left || svgPoint.x > left + width;
103-
const outsideY = svgPoint.y < top || svgPoint.y > top + height;
104-
if (outsideX || outsideY) {
116+
if (!drawingArea.isPointInside(svgPoint)) {
117+
lastFind.current = undefined;
105118
return 'outside-chart';
106119
}
107120

108121
if (!delauneyRef.current) {
109122
return 'no-point-found';
110123
}
111124

112-
const closestPointIndex = delauneyRef.current.find(svgPoint.x, svgPoint.y);
125+
const closestPointIndex = delauneyRef.current.find(svgPoint.x, svgPoint.y, lastFind.current);
113126
if (closestPointIndex === undefined) {
114127
return 'no-point-found';
115128
}
116129

130+
lastFind.current = closestPointIndex;
117131
const closestSeries = Object.values(voronoiRef.current).find((value) => {
118132
return 2 * closestPointIndex >= value.startIndex && 2 * closestPointIndex < value.endIndex;
119133
});
@@ -137,7 +151,7 @@ function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) {
137151
return { seriesId: closestSeries.seriesId, dataIndex };
138152
}
139153

140-
const handleMouseOut = () => {
154+
const handleMouseLeave = () => {
141155
dispatch({ type: 'exitChart' });
142156
clearHighlighted();
143157
};
@@ -180,27 +194,24 @@ function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) {
180194
onItemClick(event, { type: 'scatter', seriesId, dataIndex });
181195
};
182196

183-
element.addEventListener('pointerout', handleMouseOut);
197+
element.addEventListener('pointerleave', handleMouseLeave);
184198
element.addEventListener('pointermove', handleMouseMove);
185199
element.addEventListener('click', handleMouseClick);
186200
return () => {
187-
element.removeEventListener('pointerout', handleMouseOut);
201+
element.removeEventListener('pointerleave', handleMouseLeave);
188202
element.removeEventListener('pointermove', handleMouseMove);
189203
element.removeEventListener('click', handleMouseClick);
190204
};
191205
}, [
192206
svgRef,
193207
dispatch,
194-
left,
195-
width,
196-
top,
197-
height,
198208
yAxis,
199209
xAxis,
200210
voronoiMaxRadius,
201211
onItemClick,
202212
setHighlighted,
203213
clearHighlighted,
214+
drawingArea,
204215
]);
205216

206217
// eslint-disable-next-line react/jsx-no-useless-fragment

packages/x-charts/src/LineChart/LineChart.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,9 @@ const LineChart = React.forwardRef(function LineChart(props: LineChartProps, ref
162162
<AreaPlot {...areaPlotProps} />
163163
<LinePlot {...linePlotProps} />
164164
<ChartsOverlay {...overlayProps} />
165+
<ChartsAxisHighlight {...axisHighlightProps} />
165166
</g>
166167
<ChartsAxis {...chartsAxisProps} />
167-
<ChartsAxisHighlight {...axisHighlightProps} />
168168
<MarkPlot {...markPlotProps} />
169169
<LineHighlightPlot {...lineHighlightPlotProps} />
170170
<ChartsLegend {...legendProps} />

packages/x-charts/src/LineChart/LineHighlightPlot.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { InteractionContext } from '../context/InteractionProvider';
77
import { DEFAULT_X_AXIS_KEY } from '../constants';
88
import getColor from './getColor';
99
import { useLineSeries } from '../hooks/useSeries';
10+
import { useDrawingArea } from '../hooks/useDrawingArea';
1011

1112
export interface LineHighlightPlotSlots {
1213
lineHighlight?: React.JSXElementConstructor<LineHighlightElementProps>;
@@ -44,6 +45,7 @@ function LineHighlightPlot(props: LineHighlightPlotProps) {
4445

4546
const seriesData = useLineSeries();
4647
const axisData = useCartesianContext();
48+
const drawingArea = useDrawingArea();
4749
const { axis } = React.useContext(InteractionContext);
4850

4951
const highlightedIndex = axis.x?.index;
@@ -93,6 +95,10 @@ function LineHighlightPlot(props: LineHighlightPlotProps) {
9395
const x = xScale(xData[highlightedIndex]);
9496
const y = yScale(stackedData[highlightedIndex][1])!; // This should not be undefined since y should not be a band scale
9597

98+
if (!drawingArea.isPointInside({ x, y })) {
99+
return null;
100+
}
101+
96102
const colorGetter = getColor(series[seriesId], xAxis[xAxisKey], yAxis[yAxisKey]);
97103
return (
98104
<Element

packages/x-charts/src/LineChart/MarkPlot.tsx

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ function MarkPlot(props: MarkPlotProps) {
5959
const seriesData = useLineSeries();
6060
const axisData = useCartesianContext();
6161
const chartId = useChartId();
62-
const { left, width, top, height } = useDrawingArea();
62+
const drawingArea = useDrawingArea();
6363

6464
const Mark = slots?.mark ?? MarkElement;
6565

@@ -91,16 +91,6 @@ function MarkPlot(props: MarkPlotProps) {
9191
const yScale = yAxis[yAxisKey].scale;
9292
const xData = xAxis[xAxisKey].data;
9393

94-
const isInRange = ({ x, y }: { x: number; y: number }) => {
95-
if (x < left || x > left + width) {
96-
return false;
97-
}
98-
if (y < top || y > top + height) {
99-
return false;
100-
}
101-
return true;
102-
};
103-
10494
if (xData === undefined) {
10595
throw new Error(
10696
`MUI X Charts: ${
@@ -133,7 +123,7 @@ function MarkPlot(props: MarkPlotProps) {
133123
// Remove missing data point
134124
return false;
135125
}
136-
if (!isInRange({ x, y })) {
126+
if (!drawingArea.isPointInside({ x, y })) {
137127
// Remove out of range
138128
return false;
139129
}

0 commit comments

Comments
 (0)