Skip to content

[charts-pro] Zoom pointer improvements #17000

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

Open
wants to merge 58 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 43 commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
b03ccf2
add plugin start
JCQuintas Mar 6, 2025
6d80993
add chart interaction using @use-gesture/react
JCQuintas Mar 7, 2025
ba8b61a
replace tooltip logic
JCQuintas Mar 7, 2025
db75765
rename vars
JCQuintas Mar 7, 2025
04f4a3a
update axis event click handler
JCQuintas Mar 7, 2025
7f6b43e
improve
JCQuintas Mar 9, 2025
baccbf1
rename
JCQuintas Mar 9, 2025
b4f0d24
prevent useInteractionItemProps re-renders
JCQuintas Mar 10, 2025
48b461e
Fix perf related to react spring
JCQuintas Mar 10, 2025
e38fcee
Merge commit 'd3cde4cfcbc42b685da49928caefa531ea3c3711' into pointer-…
JCQuintas Mar 12, 2025
7988ff0
fix missing
JCQuintas Mar 12, 2025
29923fa
add mouse position tracker back
JCQuintas Mar 12, 2025
6abb966
migrate polar axis
JCQuintas Mar 12, 2025
7ebb091
add wheel listeners
JCQuintas Mar 12, 2025
637e193
Merge commit '06d8025ed7b5eb14b4d125924b4eeefea9af12a2' into pointer-…
JCQuintas Mar 12, 2025
6dad5f2
log
JCQuintas Mar 12, 2025
6a8ad69
fix listeners bug
JCQuintas Mar 12, 2025
989c284
clear animation on drag
JCQuintas Mar 12, 2025
d9b51cd
move zoom wheel setup to its own hook
JCQuintas Mar 13, 2025
1cb800a
zoom on pinch
JCQuintas Mar 13, 2025
a019178
move pan on drag
JCQuintas Mar 14, 2025
3c18086
cleanup cache logic
JCQuintas Mar 14, 2025
61bf657
fix mobile pinch
JCQuintas Mar 14, 2025
9595fac
reorder
JCQuintas Mar 14, 2025
223c9b1
fix mobile interaction
JCQuintas Mar 14, 2025
568f7da
allow config multiple listeners at once
JCQuintas Mar 14, 2025
c89dabd
tree shake
JCQuintas Mar 16, 2025
01899c7
remove el check
JCQuintas Mar 16, 2025
f9a56a7
use memo for storing data
JCQuintas Mar 16, 2025
b93cbd1
remove comment
JCQuintas Mar 16, 2025
73f39f3
cleanup drag
JCQuintas Mar 17, 2025
8dbaf63
fix pinch mobile
JCQuintas Mar 17, 2025
9b4933d
prevent scroll on mobile
JCQuintas Mar 17, 2025
3996fa9
Revert "tree shake"
JCQuintas Mar 17, 2025
8b1b724
Merge commit 'e5a59cd69b84265f2957563270a341bd035fa401' into pointer-…
JCQuintas Mar 17, 2025
c6be367
remove preventscroll option as it messes tapping
JCQuintas Mar 17, 2025
8873928
Merge commit 'bb6ce455efdd7abaee82eed64a9dbef8584abb23' into pointer-…
JCQuintas Mar 18, 2025
0dece29
add bar tests
JCQuintas Mar 19, 2025
f595e0c
add further tests
JCQuintas Mar 19, 2025
290e7f0
remove only
JCQuintas Mar 19, 2025
75872f4
Merge branch 'master' into pointer-events-improvement
JCQuintas Mar 19, 2025
ecafae6
fix wheel update depth issue
JCQuintas Mar 19, 2025
c5f6c8e
remove timeout on wheel need
JCQuintas Mar 19, 2025
cc9e738
add docs
JCQuintas Mar 20, 2025
0edea79
rework tap
JCQuintas Mar 20, 2025
fd88fbe
allow pointermove
JCQuintas Mar 21, 2025
02c1a29
move utils
JCQuintas Mar 24, 2025
1cccafa
use native events
JCQuintas Mar 25, 2025
c55a5e3
pan
JCQuintas Mar 25, 2025
f1a0e33
Merge commit '83b5b33ec34e1cb6b91f134c0c442b3ac9da3cf1' into pointer-…
JCQuintas Mar 25, 2025
220efd1
ensure zoom is called on RAF
JCQuintas Mar 25, 2025
7fb1449
Add debouce and automatic interacting
JCQuintas Mar 25, 2025
4c3bb03
Merge commit '4306063b7e5402a9e1b58262900a2732de464eed' into pointer-…
JCQuintas Apr 3, 2025
aedde9a
Merge commit '329bb76fde9d31e1c9a733f6a3dc14c1beff39ed' into pointer-…
JCQuintas Apr 3, 2025
4eb9075
Merge commit 'd1ed9b1a8bd879812fbc7d16a753f9b6f7714db9' into pointer-…
JCQuintas Apr 7, 2025
d3e4d6b
Merge commit '9427044825eae14d4ef6d0c2e5de1af5ce1367d6' into pointer-…
JCQuintas Apr 7, 2025
fe32424
fix type
JCQuintas Apr 7, 2025
64ae3a4
fix mobile pan too fast issue
JCQuintas Apr 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 212 additions & 0 deletions packages/x-charts-pro/src/BarChartPro/BarChartPro.zoom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import * as React from 'react';
import { expect } from 'chai';
import { createRenderer, screen, fireEvent } from '@mui/internal-test-utils';
import { describeSkipIf, isJSDOM, testSkipIf } from 'test/utils/skipIf';
import { BarChartPro } from './BarChartPro';

describeSkipIf(isJSDOM)('<BarChartPro /> - Zoom', () => {
const { render } = createRenderer();

const barChartProps = {
series: [
{
data: [10, 20, 30, 40],
},
],
xAxis: [
{
scaleType: 'band',
data: ['A', 'B', 'C', 'D'],
zoom: true,
height: 30,
id: 'x',
},
],
yAxis: [{ position: 'none' }],
width: 100,
height: 130,
margin: 0,
slotProps: { tooltip: { trigger: 'none' } },
} as const;

const options = {
wrapper: ({ children }: { children?: React.ReactNode }) => (
<div style={{ width: 100, height: 130 }}>{children}</div>
),
};

// eslint-disable-next-line mocha/no-top-level-hooks
beforeEach(() => {
// TODO: Remove beforeEach/afterEach after vitest becomes our main runner
if (window?.document?.body?.style) {
window.document.body.style.margin = '0';
}
});

// eslint-disable-next-line mocha/no-top-level-hooks
afterEach(() => {
if (window?.document?.body?.style) {
window.document.body.style.margin = '8px';
}
});

it('should zoom on wheel', async () => {
const { user } = render(<BarChartPro {...barChartProps} />, options);

expect(screen.queryByText('A')).not.to.equal(null);
expect(screen.queryByText('B')).not.to.equal(null);
expect(screen.queryByText('C')).not.to.equal(null);
expect(screen.queryByText('D')).not.to.equal(null);

const svg = document.querySelector('svg')!;

await user.pointer([
{
target: svg,
coords: { x: 50, y: 50 },
},
]);

// scroll, we scroll exactly in the center of the svg
// And we do it 200 times which is the lowest number to trigger a zoom where both A and D are not visible
for (let i = 0; i < 200; i += 1) {
fireEvent.wheel(svg, { deltaY: -1, clientX: 50, clientY: 50 });
}
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 surprising that it can not be done with delatY: -200


expect(screen.queryByText('A')).to.equal(null);
expect(screen.queryByText('B')).not.to.equal(null);
expect(screen.queryByText('C')).not.to.equal(null);
expect(screen.queryByText('D')).to.equal(null);

// scroll back
for (let i = 0; i < 200; i += 1) {
fireEvent.wheel(svg, { deltaY: 1, clientX: 50, clientY: 50 });
}
Copy link
Member

Choose a reason for hiding this comment

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

If as you said 200 is the limit to only see B and C, then just a few scroll back should be enough


expect(screen.queryByText('A')).not.to.equal(null);
expect(screen.queryByText('B')).not.to.equal(null);
expect(screen.queryByText('C')).not.to.equal(null);
expect(screen.queryByText('D')).not.to.equal(null);
});

['MouseLeft', 'TouchA'].forEach((pointerName) => {
it(`should pan on ${pointerName} drag`, async () => {
const { user } = render(
<BarChartPro {...barChartProps} initialZoom={[{ axisId: 'x', start: 75, end: 100 }]} />,
options,
);

expect(screen.queryByText('A')).to.equal(null);
expect(screen.queryByText('B')).to.equal(null);
expect(screen.queryByText('C')).to.equal(null);
expect(screen.queryByText('D')).not.to.equal(null);

const svg = document.querySelector('svg')!;

// we drag one position so C should be visible
await user.pointer([
{
keys: `[${pointerName}>]`,
target: svg,
coords: { x: 5, y: 20 },
},
{
pointerName: pointerName === 'MouseLeft' ? undefined : pointerName,
target: svg,
coords: { x: 100, y: 20 },
},
{
keys: `[/${pointerName}]`,
target: svg,
coords: { x: 100, y: 20 },
},
]);

expect(screen.queryByText('A')).to.equal(null);
expect(screen.queryByText('B')).to.equal(null);
expect(screen.queryByText('C')).not.to.equal(null);
expect(screen.queryByText('D')).to.equal(null);

// we drag all the way to the left so A should be visible
await user.pointer([
{
keys: `[${pointerName}>]`,
target: svg,
coords: { x: 5, y: 20 },
},
{
pointerName: pointerName === 'MouseLeft' ? undefined : pointerName,
target: svg,
coords: { x: 300, y: 20 },
},
{
keys: `[/${pointerName}]`,
target: svg,
coords: { x: 300, y: 20 },
},
]);

expect(screen.queryByText('A')).not.to.equal(null);
expect(screen.queryByText('B')).to.equal(null);
expect(screen.queryByText('C')).to.equal(null);
expect(screen.queryByText('D')).to.equal(null);
});
});

// Technically it should work, but it's not working in the test environment
// https://github.com/pmndrs/use-gesture/discussions/430
testSkipIf(true)('should zoom on pinch', async () => {
const { user } = render(<BarChartPro {...barChartProps} />, options);

expect(screen.queryByText('A')).not.to.equal(null);
expect(screen.queryByText('B')).not.to.equal(null);
expect(screen.queryByText('C')).not.to.equal(null);
expect(screen.queryByText('D')).not.to.equal(null);

const svg = document.querySelector('svg')!;

await user.pointer({
keys: '[TouchA]',
target: svg,
coords: { x: 50, y: 50 },
});

await user.pointer([
{
keys: '[TouchA>]',
target: svg,
coords: { x: 55, y: 45 },
},
{
keys: '[TouchB>]',
target: svg,
coords: { x: 45, y: 55 },
},
{
pointerName: 'TouchA',
target: svg,
coords: { x: 75, y: 25 },
},
{
pointerName: 'TouchB',
target: svg,
coords: { x: 25, y: 75 },
},
{
keys: '[/TouchA]',
target: svg,
coords: { x: 75, y: 25 },
},
{
keys: '[/TouchB]',
target: svg,
coords: { x: 25, y: 75 },
},
]);

expect(screen.queryByText('A')?.textContent).to.equal(null);
expect(screen.queryByText('B')).not.to.equal(null);
expect(screen.queryByText('C')).not.to.equal(null);
expect(screen.queryByText('D')).to.equal(null);
});
});
155 changes: 155 additions & 0 deletions packages/x-charts-pro/src/LineChartPro/LineChartPro.zoom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import * as React from 'react';
import { expect } from 'chai';
import { createRenderer, screen, fireEvent } from '@mui/internal-test-utils';
import { describeSkipIf, isJSDOM } from 'test/utils/skipIf';
import { LineChartPro } from './LineChartPro';

describeSkipIf(isJSDOM)('<LineChartPro /> - Zoom', () => {
const { render } = createRenderer();

const lineChartProps = {
series: [
{
data: [10, 20, 30, 40],
},
],
xAxis: [
{
scaleType: 'point',
data: ['A', 'B', 'C', 'D'],
zoom: true,
height: 30,
id: 'x',
},
],
yAxis: [{ position: 'none' }],
width: 100,
height: 130,
margin: 5,
slotProps: { tooltip: { trigger: 'none' } },
} as const;

const options = {
wrapper: ({ children }: { children?: React.ReactNode }) => (
<div style={{ width: 100, height: 130 }}>{children}</div>
),
};
Copy link
Member

Choose a reason for hiding this comment

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

What is the advantage of the wrapper compared to just providing the width/height to the chart props?


// eslint-disable-next-line mocha/no-top-level-hooks
beforeEach(() => {
// TODO: Remove beforeEach/afterEach after vitest becomes our main runner
if (window?.document?.body?.style) {
window.document.body.style.margin = '0';
}
});

// eslint-disable-next-line mocha/no-top-level-hooks
afterEach(() => {
if (window?.document?.body?.style) {
window.document.body.style.margin = '8px';
}
});

it('should zoom on wheel', async () => {
const { user } = render(<LineChartPro {...lineChartProps} />, options);

expect(screen.queryByText('A')).not.to.equal(null);
expect(screen.queryByText('B')).not.to.equal(null);
expect(screen.queryByText('C')).not.to.equal(null);
expect(screen.queryByText('D')).not.to.equal(null);

const svg = document.querySelector('svg')!;

await user.pointer([
{
target: svg,
coords: { x: 50, y: 50 },
},
]);

// scroll, we scroll exactly in the center of the svg
// And we do it 200 times which is the lowest number to trigger a zoom where both A and D are not visible
for (let i = 0; i < 200; i += 1) {
fireEvent.wheel(svg, { deltaY: -1, clientX: 50, clientY: 50 });
}

expect(screen.queryByText('A')).to.equal(null);
expect(screen.queryByText('B')).not.to.equal(null);
expect(screen.queryByText('C')).not.to.equal(null);
expect(screen.queryByText('D')).to.equal(null);

// scroll back
for (let i = 0; i < 200; i += 1) {
fireEvent.wheel(svg, { deltaY: 1, clientX: 50, clientY: 50 });
}

expect(screen.queryByText('A')).not.to.equal(null);
expect(screen.queryByText('B')).not.to.equal(null);
expect(screen.queryByText('C')).not.to.equal(null);
expect(screen.queryByText('D')).not.to.equal(null);
});

['MouseLeft', 'TouchA'].forEach((pointerName) => {
it(`should pan on ${pointerName} drag`, async () => {
const { user } = render(
<LineChartPro {...lineChartProps} initialZoom={[{ axisId: 'x', start: 75, end: 100 }]} />,
options,
);

expect(screen.queryByText('A')).to.equal(null);
expect(screen.queryByText('B')).to.equal(null);
expect(screen.queryByText('C')).to.equal(null);
expect(screen.queryByText('D')).not.to.equal(null);

const svg = document.querySelector('svg')!;

// we drag one position so C should be visible
await user.pointer([
{
keys: `[${pointerName}>]`,
target: svg,
coords: { x: 5, y: 20 },
},
{
pointerName: pointerName === 'MouseLeft' ? undefined : pointerName,
target: svg,
coords: { x: 100, y: 20 },
},
{
keys: `[/${pointerName}]`,
target: svg,
coords: { x: 100, y: 20 },
},
]);

expect(screen.queryByText('A')).to.equal(null);
expect(screen.queryByText('B')).to.equal(null);
expect(screen.queryByText('C')).not.to.equal(null);
expect(screen.queryByText('D')).to.equal(null);

// we drag all the way to the left so A should be visible
await user.pointer([
{
keys: `[${pointerName}>]`,
target: svg,
coords: { x: 5, y: 20 },
},
{
pointerName: pointerName === 'MouseLeft' ? undefined : pointerName,
target: svg,
coords: { x: 300, y: 20 },
},
{
keys: `[/${pointerName}]`,
target: svg,
coords: { x: 300, y: 20 },
},
]);

expect(screen.queryByText('A')).not.to.equal(null);
expect(screen.queryByText('B')).to.equal(null);
expect(screen.queryByText('C')).to.equal(null);
expect(screen.queryByText('D')).to.equal(null);
});
});
});
Loading