From 2a300ff5f56e78db974b4746b4c5076e3caa38aa Mon Sep 17 00:00:00 2001 From: Jose C Quintas Jr Date: Wed, 3 Jul 2024 17:34:38 +0200 Subject: [PATCH] [charts] Add initial `Zoom&Pan` to the Pro charts (#13405) Signed-off-by: Jose C Quintas Jr Co-authored-by: alexandre Co-authored-by: Alexandre Fauquette <45398769+alexfauquette@users.noreply.github.com> Co-authored-by: Flavien DELANGLE --- docs/data/charts/zoom-and-pan/ZoomBarChart.js | 117 ++++++++ .../data/charts/zoom-and-pan/ZoomBarChart.tsx | 117 ++++++++ .../zoom-and-pan/ZoomBarChart.tsx.preview | 15 + .../data/charts/zoom-and-pan/ZoomLineChart.js | 117 ++++++++ .../charts/zoom-and-pan/ZoomLineChart.tsx | 117 ++++++++ .../zoom-and-pan/ZoomLineChart.tsx.preview | 15 + .../charts/zoom-and-pan/ZoomScatterChart.js | 186 ++++++++++++ .../charts/zoom-and-pan/ZoomScatterChart.tsx | 186 ++++++++++++ .../zoom-and-pan/ZoomScatterChart.tsx.preview | 15 + docs/data/charts/zoom-and-pan/zoom-and-pan.md | 30 ++ docs/data/pages.ts | 1 + docs/pages/x/react-charts/zoom-and-pan.js | 7 + .../src/BarChartPro/BarChartPro.tsx | 21 +- .../ChartContainerPro/ChartContainerPro.tsx | 61 ++-- .../src/LineChartPro/LineChartPro.tsx | 20 +- .../src/ScatterChartPro/ScatterChartPro.tsx | 10 +- .../CartesianProviderPro.tsx | 59 ++++ .../src/context/CartesianProviderPro/index.ts | 1 + .../src/context/ZoomProvider/ZoomContext.ts | 23 ++ .../src/context/ZoomProvider/ZoomProvider.tsx | 26 ++ .../src/context/ZoomProvider/ZoomSetup.ts | 10 + .../src/context/ZoomProvider/index.ts | 3 + .../src/context/ZoomProvider/useSetupPan.ts | 113 +++++++ .../src/context/ZoomProvider/useSetupZoom.ts | 280 ++++++++++++++++++ .../src/context/ZoomProvider/useZoom.ts | 7 + packages/x-charts-pro/src/index.ts | 2 +- .../x-charts/src/ChartsXAxis/ChartsXAxis.tsx | 59 ++-- packages/x-charts/src/LineChart/MarkPlot.tsx | 5 +- .../x-charts/src/ScatterChart/Scatter.tsx | 17 +- .../context/CartesianProvider/computeValue.ts | 28 +- 30 files changed, 1586 insertions(+), 82 deletions(-) create mode 100644 docs/data/charts/zoom-and-pan/ZoomBarChart.js create mode 100644 docs/data/charts/zoom-and-pan/ZoomBarChart.tsx create mode 100644 docs/data/charts/zoom-and-pan/ZoomBarChart.tsx.preview create mode 100644 docs/data/charts/zoom-and-pan/ZoomLineChart.js create mode 100644 docs/data/charts/zoom-and-pan/ZoomLineChart.tsx create mode 100644 docs/data/charts/zoom-and-pan/ZoomLineChart.tsx.preview create mode 100644 docs/data/charts/zoom-and-pan/ZoomScatterChart.js create mode 100644 docs/data/charts/zoom-and-pan/ZoomScatterChart.tsx create mode 100644 docs/data/charts/zoom-and-pan/ZoomScatterChart.tsx.preview create mode 100644 docs/data/charts/zoom-and-pan/zoom-and-pan.md create mode 100644 docs/pages/x/react-charts/zoom-and-pan.js create mode 100644 packages/x-charts-pro/src/context/CartesianProviderPro/CartesianProviderPro.tsx create mode 100644 packages/x-charts-pro/src/context/CartesianProviderPro/index.ts create mode 100644 packages/x-charts-pro/src/context/ZoomProvider/ZoomContext.ts create mode 100644 packages/x-charts-pro/src/context/ZoomProvider/ZoomProvider.tsx create mode 100644 packages/x-charts-pro/src/context/ZoomProvider/ZoomSetup.ts create mode 100644 packages/x-charts-pro/src/context/ZoomProvider/index.ts create mode 100644 packages/x-charts-pro/src/context/ZoomProvider/useSetupPan.ts create mode 100644 packages/x-charts-pro/src/context/ZoomProvider/useSetupZoom.ts create mode 100644 packages/x-charts-pro/src/context/ZoomProvider/useZoom.ts diff --git a/docs/data/charts/zoom-and-pan/ZoomBarChart.js b/docs/data/charts/zoom-and-pan/ZoomBarChart.js new file mode 100644 index 0000000000000..afefa3368145a --- /dev/null +++ b/docs/data/charts/zoom-and-pan/ZoomBarChart.js @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { BarChartPro } from '@mui/x-charts-pro/BarChartPro'; + +export default function ZoomBarChart() { + return ( + v.y1), + }, + { + label: 'Series B', + data: data.map((v) => v.y2), + }, + ]} + /> + ); +} + +const data = [ + { + y1: 443.28, + y2: 153.9, + }, + { + y1: 110.5, + y2: 217.8, + }, + { + y1: 175.23, + y2: 286.32, + }, + { + y1: 195.97, + y2: 325.12, + }, + { + y1: 351.77, + y2: 144.58, + }, + { + y1: 43.253, + y2: 146.51, + }, + { + y1: 376.34, + y2: 309.69, + }, + { + y1: 31.514, + y2: 236.38, + }, + { + y1: 231.31, + y2: 440.72, + }, + { + y1: 108.04, + y2: 20.29, + }, + { + y1: 321.77, + y2: 484.17, + }, + { + y1: 120.18, + y2: 54.962, + }, + { + y1: 366.2, + y2: 418.5, + }, + { + y1: 451.45, + y2: 181.32, + }, + { + y1: 294.8, + y2: 440.9, + }, + { + y1: 121.83, + y2: 273.52, + }, + { + y1: 287.7, + y2: 346.7, + }, + { + y1: 134.06, + y2: 74.528, + }, + { + y1: 104.5, + y2: 150.9, + }, + { + y1: 413.07, + y2: 26.483, + }, + { + y1: 74.68, + y2: 333.2, + }, + { + y1: 360.6, + y2: 422.0, + }, + { + y1: 330.72, + y2: 488.06, + }, +]; diff --git a/docs/data/charts/zoom-and-pan/ZoomBarChart.tsx b/docs/data/charts/zoom-and-pan/ZoomBarChart.tsx new file mode 100644 index 0000000000000..afefa3368145a --- /dev/null +++ b/docs/data/charts/zoom-and-pan/ZoomBarChart.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { BarChartPro } from '@mui/x-charts-pro/BarChartPro'; + +export default function ZoomBarChart() { + return ( + v.y1), + }, + { + label: 'Series B', + data: data.map((v) => v.y2), + }, + ]} + /> + ); +} + +const data = [ + { + y1: 443.28, + y2: 153.9, + }, + { + y1: 110.5, + y2: 217.8, + }, + { + y1: 175.23, + y2: 286.32, + }, + { + y1: 195.97, + y2: 325.12, + }, + { + y1: 351.77, + y2: 144.58, + }, + { + y1: 43.253, + y2: 146.51, + }, + { + y1: 376.34, + y2: 309.69, + }, + { + y1: 31.514, + y2: 236.38, + }, + { + y1: 231.31, + y2: 440.72, + }, + { + y1: 108.04, + y2: 20.29, + }, + { + y1: 321.77, + y2: 484.17, + }, + { + y1: 120.18, + y2: 54.962, + }, + { + y1: 366.2, + y2: 418.5, + }, + { + y1: 451.45, + y2: 181.32, + }, + { + y1: 294.8, + y2: 440.9, + }, + { + y1: 121.83, + y2: 273.52, + }, + { + y1: 287.7, + y2: 346.7, + }, + { + y1: 134.06, + y2: 74.528, + }, + { + y1: 104.5, + y2: 150.9, + }, + { + y1: 413.07, + y2: 26.483, + }, + { + y1: 74.68, + y2: 333.2, + }, + { + y1: 360.6, + y2: 422.0, + }, + { + y1: 330.72, + y2: 488.06, + }, +]; diff --git a/docs/data/charts/zoom-and-pan/ZoomBarChart.tsx.preview b/docs/data/charts/zoom-and-pan/ZoomBarChart.tsx.preview new file mode 100644 index 0000000000000..b54614a7af00e --- /dev/null +++ b/docs/data/charts/zoom-and-pan/ZoomBarChart.tsx.preview @@ -0,0 +1,15 @@ + v.y1), + }, + { + label: 'Series B', + data: data.map((v) => v.y2), + }, + ]} +/> \ No newline at end of file diff --git a/docs/data/charts/zoom-and-pan/ZoomLineChart.js b/docs/data/charts/zoom-and-pan/ZoomLineChart.js new file mode 100644 index 0000000000000..e792dfc457979 --- /dev/null +++ b/docs/data/charts/zoom-and-pan/ZoomLineChart.js @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { LineChartPro } from '@mui/x-charts-pro/LineChartPro'; + +export default function ZoomLineChart() { + return ( + v.y1), + }, + { + label: 'Series B', + data: data.map((v) => v.y2), + }, + ]} + /> + ); +} + +const data = [ + { + y1: 443.28, + y2: 153.9, + }, + { + y1: 110.5, + y2: 217.8, + }, + { + y1: 175.23, + y2: 286.32, + }, + { + y1: 195.97, + y2: 325.12, + }, + { + y1: 351.77, + y2: 144.58, + }, + { + y1: 43.253, + y2: 146.51, + }, + { + y1: 376.34, + y2: 309.69, + }, + { + y1: 31.514, + y2: 236.38, + }, + { + y1: 231.31, + y2: 440.72, + }, + { + y1: 108.04, + y2: 20.29, + }, + { + y1: 321.77, + y2: 484.17, + }, + { + y1: 120.18, + y2: 54.962, + }, + { + y1: 366.2, + y2: 418.5, + }, + { + y1: 451.45, + y2: 181.32, + }, + { + y1: 294.8, + y2: 440.9, + }, + { + y1: 121.83, + y2: 273.52, + }, + { + y1: 287.7, + y2: 346.7, + }, + { + y1: 134.06, + y2: 74.528, + }, + { + y1: 104.5, + y2: 150.9, + }, + { + y1: 413.07, + y2: 26.483, + }, + { + y1: 74.68, + y2: 333.2, + }, + { + y1: 360.6, + y2: 422.0, + }, + { + y1: 330.72, + y2: 488.06, + }, +]; diff --git a/docs/data/charts/zoom-and-pan/ZoomLineChart.tsx b/docs/data/charts/zoom-and-pan/ZoomLineChart.tsx new file mode 100644 index 0000000000000..e792dfc457979 --- /dev/null +++ b/docs/data/charts/zoom-and-pan/ZoomLineChart.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { LineChartPro } from '@mui/x-charts-pro/LineChartPro'; + +export default function ZoomLineChart() { + return ( + v.y1), + }, + { + label: 'Series B', + data: data.map((v) => v.y2), + }, + ]} + /> + ); +} + +const data = [ + { + y1: 443.28, + y2: 153.9, + }, + { + y1: 110.5, + y2: 217.8, + }, + { + y1: 175.23, + y2: 286.32, + }, + { + y1: 195.97, + y2: 325.12, + }, + { + y1: 351.77, + y2: 144.58, + }, + { + y1: 43.253, + y2: 146.51, + }, + { + y1: 376.34, + y2: 309.69, + }, + { + y1: 31.514, + y2: 236.38, + }, + { + y1: 231.31, + y2: 440.72, + }, + { + y1: 108.04, + y2: 20.29, + }, + { + y1: 321.77, + y2: 484.17, + }, + { + y1: 120.18, + y2: 54.962, + }, + { + y1: 366.2, + y2: 418.5, + }, + { + y1: 451.45, + y2: 181.32, + }, + { + y1: 294.8, + y2: 440.9, + }, + { + y1: 121.83, + y2: 273.52, + }, + { + y1: 287.7, + y2: 346.7, + }, + { + y1: 134.06, + y2: 74.528, + }, + { + y1: 104.5, + y2: 150.9, + }, + { + y1: 413.07, + y2: 26.483, + }, + { + y1: 74.68, + y2: 333.2, + }, + { + y1: 360.6, + y2: 422.0, + }, + { + y1: 330.72, + y2: 488.06, + }, +]; diff --git a/docs/data/charts/zoom-and-pan/ZoomLineChart.tsx.preview b/docs/data/charts/zoom-and-pan/ZoomLineChart.tsx.preview new file mode 100644 index 0000000000000..d26060a665745 --- /dev/null +++ b/docs/data/charts/zoom-and-pan/ZoomLineChart.tsx.preview @@ -0,0 +1,15 @@ + v.y1), + }, + { + label: 'Series B', + data: data.map((v) => v.y2), + }, + ]} +/> \ No newline at end of file diff --git a/docs/data/charts/zoom-and-pan/ZoomScatterChart.js b/docs/data/charts/zoom-and-pan/ZoomScatterChart.js new file mode 100644 index 0000000000000..43faf6db2f4be --- /dev/null +++ b/docs/data/charts/zoom-and-pan/ZoomScatterChart.js @@ -0,0 +1,186 @@ +import * as React from 'react'; +import { ScatterChartPro } from '@mui/x-charts-pro/ScatterChartPro'; + +export default function ZoomScatterChart() { + return ( + ({ x: v.x1, y: v.y1, id: v.id })), + }, + { + label: 'Series B', + data: data.map((v) => ({ x: v.x1, y: v.y2, id: v.id })), + }, + ]} + /> + ); +} + +const data = [ + { + id: 'data-0', + x1: 329.39, + x2: 391.29, + y1: 443.28, + y2: 153.9, + }, + { + id: 'data-1', + x1: 96.94, + x2: 139.6, + y1: 110.5, + y2: 217.8, + }, + { + id: 'data-2', + x1: 336.35, + x2: 282.34, + y1: 175.23, + y2: 286.32, + }, + { + id: 'data-3', + x1: 159.44, + x2: 384.85, + y1: 195.97, + y2: 325.12, + }, + { + id: 'data-4', + x1: 188.86, + x2: 182.27, + y1: 351.77, + y2: 144.58, + }, + { + id: 'data-5', + x1: 143.86, + x2: 360.22, + y1: 43.253, + y2: 146.51, + }, + { + id: 'data-6', + x1: 202.02, + x2: 209.5, + y1: 376.34, + y2: 309.69, + }, + { + id: 'data-7', + x1: 384.41, + x2: 258.93, + y1: 31.514, + y2: 236.38, + }, + { + id: 'data-8', + x1: 256.76, + x2: 70.571, + y1: 231.31, + y2: 440.72, + }, + { + id: 'data-9', + x1: 143.79, + x2: 419.02, + y1: 108.04, + y2: 20.29, + }, + { + id: 'data-10', + x1: 103.48, + x2: 15.886, + y1: 321.77, + y2: 484.17, + }, + { + id: 'data-11', + x1: 272.39, + x2: 189.03, + y1: 120.18, + y2: 54.962, + }, + { + id: 'data-12', + x1: 23.57, + x2: 456.4, + y1: 366.2, + y2: 418.5, + }, + { + id: 'data-13', + x1: 219.73, + x2: 235.96, + y1: 451.45, + y2: 181.32, + }, + { + id: 'data-14', + x1: 54.99, + x2: 434.5, + y1: 294.8, + y2: 440.9, + }, + { + id: 'data-15', + x1: 134.13, + x2: 383.8, + y1: 121.83, + y2: 273.52, + }, + { + id: 'data-16', + x1: 12.7, + x2: 270.8, + y1: 287.7, + y2: 346.7, + }, + { + id: 'data-17', + x1: 176.51, + x2: 119.17, + y1: 134.06, + y2: 74.528, + }, + { + id: 'data-18', + x1: 65.05, + x2: 78.93, + y1: 104.5, + y2: 150.9, + }, + { + id: 'data-19', + x1: 162.25, + x2: 63.707, + y1: 413.07, + y2: 26.483, + }, + { + id: 'data-20', + x1: 68.88, + x2: 150.8, + y1: 74.68, + y2: 333.2, + }, + { + id: 'data-21', + x1: 95.29, + x2: 329.1, + y1: 360.6, + y2: 422.0, + }, + { + id: 'data-22', + x1: 390.62, + x2: 10.01, + y1: 330.72, + y2: 488.06, + }, +]; diff --git a/docs/data/charts/zoom-and-pan/ZoomScatterChart.tsx b/docs/data/charts/zoom-and-pan/ZoomScatterChart.tsx new file mode 100644 index 0000000000000..43faf6db2f4be --- /dev/null +++ b/docs/data/charts/zoom-and-pan/ZoomScatterChart.tsx @@ -0,0 +1,186 @@ +import * as React from 'react'; +import { ScatterChartPro } from '@mui/x-charts-pro/ScatterChartPro'; + +export default function ZoomScatterChart() { + return ( + ({ x: v.x1, y: v.y1, id: v.id })), + }, + { + label: 'Series B', + data: data.map((v) => ({ x: v.x1, y: v.y2, id: v.id })), + }, + ]} + /> + ); +} + +const data = [ + { + id: 'data-0', + x1: 329.39, + x2: 391.29, + y1: 443.28, + y2: 153.9, + }, + { + id: 'data-1', + x1: 96.94, + x2: 139.6, + y1: 110.5, + y2: 217.8, + }, + { + id: 'data-2', + x1: 336.35, + x2: 282.34, + y1: 175.23, + y2: 286.32, + }, + { + id: 'data-3', + x1: 159.44, + x2: 384.85, + y1: 195.97, + y2: 325.12, + }, + { + id: 'data-4', + x1: 188.86, + x2: 182.27, + y1: 351.77, + y2: 144.58, + }, + { + id: 'data-5', + x1: 143.86, + x2: 360.22, + y1: 43.253, + y2: 146.51, + }, + { + id: 'data-6', + x1: 202.02, + x2: 209.5, + y1: 376.34, + y2: 309.69, + }, + { + id: 'data-7', + x1: 384.41, + x2: 258.93, + y1: 31.514, + y2: 236.38, + }, + { + id: 'data-8', + x1: 256.76, + x2: 70.571, + y1: 231.31, + y2: 440.72, + }, + { + id: 'data-9', + x1: 143.79, + x2: 419.02, + y1: 108.04, + y2: 20.29, + }, + { + id: 'data-10', + x1: 103.48, + x2: 15.886, + y1: 321.77, + y2: 484.17, + }, + { + id: 'data-11', + x1: 272.39, + x2: 189.03, + y1: 120.18, + y2: 54.962, + }, + { + id: 'data-12', + x1: 23.57, + x2: 456.4, + y1: 366.2, + y2: 418.5, + }, + { + id: 'data-13', + x1: 219.73, + x2: 235.96, + y1: 451.45, + y2: 181.32, + }, + { + id: 'data-14', + x1: 54.99, + x2: 434.5, + y1: 294.8, + y2: 440.9, + }, + { + id: 'data-15', + x1: 134.13, + x2: 383.8, + y1: 121.83, + y2: 273.52, + }, + { + id: 'data-16', + x1: 12.7, + x2: 270.8, + y1: 287.7, + y2: 346.7, + }, + { + id: 'data-17', + x1: 176.51, + x2: 119.17, + y1: 134.06, + y2: 74.528, + }, + { + id: 'data-18', + x1: 65.05, + x2: 78.93, + y1: 104.5, + y2: 150.9, + }, + { + id: 'data-19', + x1: 162.25, + x2: 63.707, + y1: 413.07, + y2: 26.483, + }, + { + id: 'data-20', + x1: 68.88, + x2: 150.8, + y1: 74.68, + y2: 333.2, + }, + { + id: 'data-21', + x1: 95.29, + x2: 329.1, + y1: 360.6, + y2: 422.0, + }, + { + id: 'data-22', + x1: 390.62, + x2: 10.01, + y1: 330.72, + y2: 488.06, + }, +]; diff --git a/docs/data/charts/zoom-and-pan/ZoomScatterChart.tsx.preview b/docs/data/charts/zoom-and-pan/ZoomScatterChart.tsx.preview new file mode 100644 index 0000000000000..9f4bf4a61addd --- /dev/null +++ b/docs/data/charts/zoom-and-pan/ZoomScatterChart.tsx.preview @@ -0,0 +1,15 @@ + ({ x: v.x1, y: v.y1, id: v.id })), + }, + { + label: 'Series B', + data: data.map((v) => ({ x: v.x1, y: v.y2, id: v.id })), + }, + ]} +/> \ No newline at end of file diff --git a/docs/data/charts/zoom-and-pan/zoom-and-pan.md b/docs/data/charts/zoom-and-pan/zoom-and-pan.md new file mode 100644 index 0000000000000..f2b80cd8eab0b --- /dev/null +++ b/docs/data/charts/zoom-and-pan/zoom-and-pan.md @@ -0,0 +1,30 @@ +--- +title: Zoom & Pan +productId: x-charts +--- + +# Zoom & Pan [](/x/introduction/licensing/#pro-plan 'Pro plan') 🚧 + +

Enables zooming and panning on specific charts or axis.

+ +Zooming is possible on the **Pro**[](/x/introduction/licensing/#pro-plan 'Pro plan') versions of the charts: ``, ``, ``. + +:::warning +Zooming is currently only possible on the `X axis`. +::: + +## Basic usage + +To enable zooming and panning, set the `zoom` prop to `true` on the chart component. + +Enabling zoom will enable all the interactions, which are made to be as intuitive as possible. + +The following actions are supported: + +- **Scroll**: Zoom in/out by scrolling the mouse wheel. +- **Drag**: Pan the chart by dragging the mouse. +- **Pinch**: Zoom in/out by pinching the chart. + +{{"demo": "ZoomScatterChart.js"}} +{{"demo": "ZoomBarChart.js"}} +{{"demo": "ZoomLineChart.js"}} diff --git a/docs/data/pages.ts b/docs/data/pages.ts index 51c89d18d3715..12a8c186dfc8d 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -442,6 +442,7 @@ const pages: MuiPage[] = [ { pathname: '/x/react-charts/stacking' }, { pathname: '/x/react-charts/styling' }, { pathname: '/x/react-charts/tooltip', title: 'Tooltip & Highlights' }, + { pathname: '/x/react-charts/zoom-and-pan', title: 'Zoom & Pan', plan: 'pro' }, ], }, { diff --git a/docs/pages/x/react-charts/zoom-and-pan.js b/docs/pages/x/react-charts/zoom-and-pan.js new file mode 100644 index 0000000000000..4a2393ba8b38e --- /dev/null +++ b/docs/pages/x/react-charts/zoom-and-pan.js @@ -0,0 +1,7 @@ +import * as React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; +import * as pageProps from 'docsx/data/charts/zoom-and-pan/zoom-and-pan.md?muiMarkdown'; + +export default function Page() { + return ; +} diff --git a/packages/x-charts-pro/src/BarChartPro/BarChartPro.tsx b/packages/x-charts-pro/src/BarChartPro/BarChartPro.tsx index 4f474bf9de4b0..c701d6aa71463 100644 --- a/packages/x-charts-pro/src/BarChartPro/BarChartPro.tsx +++ b/packages/x-charts-pro/src/BarChartPro/BarChartPro.tsx @@ -9,10 +9,16 @@ import { ChartsAxisHighlight } from '@mui/x-charts/ChartsAxisHighlight'; import { ChartsTooltip } from '@mui/x-charts/ChartsTooltip'; import { ChartsClipPath } from '@mui/x-charts/ChartsClipPath'; import { useBarChartProps } from '@mui/x-charts/internals'; +import { BarPlotProps } from '@mui/x-charts'; import { ResponsiveChartContainerPro } from '../ResponsiveChartContainerPro'; +import { ZoomSetup } from '../context/ZoomProvider/ZoomSetup'; +import { useZoom } from '../context/ZoomProvider/useZoom'; export interface BarChartProProps extends BarChartProps { - // TODO: Add zoom props + /** + * If `true`, the chart will be zoomable. + */ + zoom?: boolean; } /** @@ -27,6 +33,7 @@ export interface BarChartProProps extends BarChartProps { * - [BarChart API](https://mui.com/x/api/charts/bar-chart/) */ const BarChartPro = React.forwardRef(function BarChartPro(props: BarChartProProps, ref) { + const { zoom, ...restProps } = props; const { chartContainerProps, barPlotProps, @@ -39,16 +46,15 @@ const BarChartPro = React.forwardRef(function BarChartPro(props: BarChartProProp axisHighlightProps, legendProps, tooltipProps, - children, - } = useBarChartProps(props); + } = useBarChartProps(restProps); return ( {props.onAxisClick && } {props.grid && } - + @@ -56,9 +62,16 @@ const BarChartPro = React.forwardRef(function BarChartPro(props: BarChartProProp {!props.loading && } + {zoom && } {children} ); }); +function BarChartPlotZoom(props: BarPlotProps) { + const { isInteracting } = useZoom(); + + return ; +} + export { BarChartPro }; diff --git a/packages/x-charts-pro/src/ChartContainerPro/ChartContainerPro.tsx b/packages/x-charts-pro/src/ChartContainerPro/ChartContainerPro.tsx index 8361a9df78827..8c0e759232b2c 100644 --- a/packages/x-charts-pro/src/ChartContainerPro/ChartContainerPro.tsx +++ b/packages/x-charts-pro/src/ChartContainerPro/ChartContainerPro.tsx @@ -4,7 +4,6 @@ import { ChartContainerProps } from '@mui/x-charts/ChartContainer'; import { ChartsSurface } from '@mui/x-charts/ChartsSurface'; import { HighlightedProvider, ZAxisContextProvider } from '@mui/x-charts/context'; import { - CartesianContextProvider, ChartsAxesGradients, ColorProvider, DrawingProvider, @@ -14,6 +13,8 @@ import { } from '@mui/x-charts/internals'; import { useLicenseVerifier } from '@mui/x-license/useLicenseVerifier'; import { getReleaseInfo } from '../internals/utils/releaseInfo'; +import { CartesianContextProviderPro } from '../context/CartesianProviderPro'; +import { ZoomProvider } from '../context/ZoomProvider'; const releaseInfo = getReleaseInfo(); @@ -63,35 +64,37 @@ const ChartContainerPro = React.forwardRef(function ChartContainer( dataset={dataset} seriesFormatters={seriesFormatters} > - - - - - + + + + - - {children} - - - - - + + + {children} + + + + + + diff --git a/packages/x-charts-pro/src/LineChartPro/LineChartPro.tsx b/packages/x-charts-pro/src/LineChartPro/LineChartPro.tsx index 384a56b509a41..108fcb795a571 100644 --- a/packages/x-charts-pro/src/LineChartPro/LineChartPro.tsx +++ b/packages/x-charts-pro/src/LineChartPro/LineChartPro.tsx @@ -15,10 +15,16 @@ import { ChartsLegend } from '@mui/x-charts/ChartsLegend'; import { ChartsTooltip } from '@mui/x-charts/ChartsTooltip'; import { ChartsClipPath } from '@mui/x-charts/ChartsClipPath'; import { useLineChartProps } from '@mui/x-charts/internals'; +import { MarkPlotProps } from '@mui/x-charts'; import { ResponsiveChartContainerPro } from '../ResponsiveChartContainerPro'; +import { ZoomSetup } from '../context/ZoomProvider/ZoomSetup'; +import { useZoom } from '../context/ZoomProvider/useZoom'; export interface LineChartProProps extends LineChartProps { - // TODO: Add zoom props + /** + * If `true`, the chart will be zoomable. + */ + zoom?: boolean; } /** @@ -32,6 +38,7 @@ export interface LineChartProProps extends LineChartProps { * - [LineChart API](https://mui.com/x/api/charts/line-chart/) */ const LineChartPro = React.forwardRef(function LineChartPro(props: LineChartProProps, ref) { + const { zoom, ...restProps } = props; const { chartContainerProps, axisClickHandlerProps, @@ -47,9 +54,8 @@ const LineChartPro = React.forwardRef(function LineChartPro(props: LineChartProP lineHighlightPlotProps, legendProps, tooltipProps, - children, - } = useLineChartProps(props); + } = useLineChartProps(restProps); return ( @@ -62,14 +68,20 @@ const LineChartPro = React.forwardRef(function LineChartPro(props: LineChartProP - + {!props.loading && } + {zoom && } {children} ); }); +function MarkPlotZoom(props: MarkPlotProps) { + const { isInteracting } = useZoom(); + return ; +} + export { LineChartPro }; diff --git a/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.tsx b/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.tsx index ad91bb1f9005f..557bbf228be63 100644 --- a/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.tsx +++ b/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.tsx @@ -10,9 +10,13 @@ import { ChartsAxisHighlight } from '@mui/x-charts/ChartsAxisHighlight'; import { ChartsTooltip } from '@mui/x-charts/ChartsTooltip'; import { useScatterChartProps } from '@mui/x-charts/internals'; import { ResponsiveChartContainerPro } from '../ResponsiveChartContainerPro'; +import { ZoomSetup } from '../context/ZoomProvider/ZoomSetup'; export interface ScatterChartProProps extends ScatterChartProps { - // TODO: Add zoom props + /** + * If `true`, the chart will be zoomable. + */ + zoom?: boolean; } /** @@ -29,6 +33,7 @@ const ScatterChartPro = React.forwardRef(function ScatterChartPro( props: ScatterChartProProps, ref, ) { + const { zoom, ...restProps } = props; const { chartContainerProps, zAxisProps, @@ -41,7 +46,7 @@ const ScatterChartPro = React.forwardRef(function ScatterChartPro( axisHighlightProps, tooltipProps, children, - } = useScatterChartProps(props); + } = useScatterChartProps(restProps); return ( @@ -53,6 +58,7 @@ const ScatterChartPro = React.forwardRef(function ScatterChartPro( {!props.loading && } + {zoom && } {children} diff --git a/packages/x-charts-pro/src/context/CartesianProviderPro/CartesianProviderPro.tsx b/packages/x-charts-pro/src/context/CartesianProviderPro/CartesianProviderPro.tsx new file mode 100644 index 0000000000000..a3b30b79037d4 --- /dev/null +++ b/packages/x-charts-pro/src/context/CartesianProviderPro/CartesianProviderPro.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { + useDrawingArea, + useSeries, + CartesianContext, + CartesianContextProviderProps, + cartesianProviderUtils, +} from '@mui/x-charts/internals'; +import { useZoom } from '../ZoomProvider/useZoom'; + +const { normalizeAxis, computeValue } = cartesianProviderUtils; + +export interface CartesianContextProviderProProps extends CartesianContextProviderProps {} + +function CartesianContextProviderPro(props: CartesianContextProviderProProps) { + const { + xAxis: inXAxis, + yAxis: inYAxis, + dataset, + xExtremumGetters, + yExtremumGetters, + children, + } = props; + + const formattedSeries = useSeries(); + const drawingArea = useDrawingArea(); + const { zoomRange } = useZoom(); + + const xAxis = React.useMemo(() => normalizeAxis(inXAxis, dataset, 'x'), [inXAxis, dataset]); + + const yAxis = React.useMemo(() => normalizeAxis(inYAxis, dataset, 'y'), [inYAxis, dataset]); + + const xValues = React.useMemo( + () => computeValue(drawingArea, formattedSeries, xAxis, xExtremumGetters, 'x', zoomRange), + [drawingArea, formattedSeries, xAxis, xExtremumGetters, zoomRange], + ); + + const yValues = React.useMemo( + () => computeValue(drawingArea, formattedSeries, yAxis, yExtremumGetters, 'y'), + [drawingArea, formattedSeries, yAxis, yExtremumGetters], + ); + + const value = React.useMemo( + () => ({ + isInitialized: true, + data: { + xAxis: xValues.axis, + yAxis: yValues.axis, + xAxisIds: xValues.axisIds, + yAxisIds: yValues.axisIds, + }, + }), + [xValues, yValues], + ); + + return {children}; +} + +export { CartesianContextProviderPro }; diff --git a/packages/x-charts-pro/src/context/CartesianProviderPro/index.ts b/packages/x-charts-pro/src/context/CartesianProviderPro/index.ts new file mode 100644 index 0000000000000..6b24b23470175 --- /dev/null +++ b/packages/x-charts-pro/src/context/CartesianProviderPro/index.ts @@ -0,0 +1 @@ +export * from './CartesianProviderPro'; diff --git a/packages/x-charts-pro/src/context/ZoomProvider/ZoomContext.ts b/packages/x-charts-pro/src/context/ZoomProvider/ZoomContext.ts new file mode 100644 index 0000000000000..aa7ae3688708f --- /dev/null +++ b/packages/x-charts-pro/src/context/ZoomProvider/ZoomContext.ts @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { Initializable } from '@mui/x-charts/internals'; + +export type ZoomState = { + zoomRange: [number, number]; + setZoomRange: (range: [number, number]) => void; + isInteracting: boolean; + setIsInteracting: (isInteracting: boolean) => void; +}; + +export const ZoomContext = React.createContext>({ + isInitialized: false, + data: { + zoomRange: [0, 100], + setZoomRange: () => {}, + isInteracting: false, + setIsInteracting: () => {}, + }, +}); + +if (process.env.NODE_ENV !== 'production') { + ZoomContext.displayName = 'ZoomContext'; +} diff --git a/packages/x-charts-pro/src/context/ZoomProvider/ZoomProvider.tsx b/packages/x-charts-pro/src/context/ZoomProvider/ZoomProvider.tsx new file mode 100644 index 0000000000000..a2d235f5a5efd --- /dev/null +++ b/packages/x-charts-pro/src/context/ZoomProvider/ZoomProvider.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { ZoomContext } from './ZoomContext'; + +type ZoomProviderProps = { + children: React.ReactNode; +}; + +export function ZoomProvider({ children }: ZoomProviderProps) { + const [zoomRange, setZoomRange] = React.useState<[number, number]>([0, 100]); + const [isInteracting, setIsInteracting] = React.useState(false); + + const value = React.useMemo( + () => ({ + isInitialized: true, + data: { + zoomRange, + setZoomRange, + isInteracting, + setIsInteracting, + }, + }), + [zoomRange, setZoomRange, isInteracting, setIsInteracting], + ); + + return {children}; +} diff --git a/packages/x-charts-pro/src/context/ZoomProvider/ZoomSetup.ts b/packages/x-charts-pro/src/context/ZoomProvider/ZoomSetup.ts new file mode 100644 index 0000000000000..072baa872d421 --- /dev/null +++ b/packages/x-charts-pro/src/context/ZoomProvider/ZoomSetup.ts @@ -0,0 +1,10 @@ +import { useSetupPan } from './useSetupPan'; +import { useSetupZoom } from './useSetupZoom'; + +function ZoomSetup() { + useSetupZoom(); + useSetupPan(); + return null; +} + +export { ZoomSetup }; diff --git a/packages/x-charts-pro/src/context/ZoomProvider/index.ts b/packages/x-charts-pro/src/context/ZoomProvider/index.ts new file mode 100644 index 0000000000000..04bb323d80ae2 --- /dev/null +++ b/packages/x-charts-pro/src/context/ZoomProvider/index.ts @@ -0,0 +1,3 @@ +export * from './ZoomContext'; +export * from './ZoomProvider'; +export * from './useSetupZoom'; diff --git a/packages/x-charts-pro/src/context/ZoomProvider/useSetupPan.ts b/packages/x-charts-pro/src/context/ZoomProvider/useSetupPan.ts new file mode 100644 index 0000000000000..65f9544059328 --- /dev/null +++ b/packages/x-charts-pro/src/context/ZoomProvider/useSetupPan.ts @@ -0,0 +1,113 @@ +import * as React from 'react'; +import { useDrawingArea, useSvgRef } from '@mui/x-charts/hooks'; +import { getSVGPoint } from '@mui/x-charts/internals'; +import { useZoom } from './useZoom'; + +const MAX_RANGE = 100; +const MIN_RANGE = 0; + +const isPointOutside = ( + point: { x: number; y: number }, + area: { left: number; top: number; width: number; height: number }, +) => { + const outsideX = point.x < area.left || point.x > area.left + area.width; + const outsideY = point.y < area.top || point.y > area.top + area.height; + return outsideX || outsideY; +}; + +export const useSetupPan = () => { + const { zoomRange, setZoomRange, setIsInteracting } = useZoom(); + const area = useDrawingArea(); + + const svgRef = useSvgRef(); + + const isDraggingRef = React.useRef(false); + const touchStartRef = React.useRef<{ x: number; minX: number; maxX: number } | null>(null); + const eventCacheRef = React.useRef([]); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handlePan = (event: PointerEvent) => { + if (element === null || !isDraggingRef.current || eventCacheRef.current.length > 1) { + return; + } + + if (touchStartRef.current == null) { + return; + } + + const point = getSVGPoint(element, event); + const movementX = point.x - touchStartRef.current.x; + + const max = touchStartRef.current.maxX; + const min = touchStartRef.current.minX; + const span = max - min; + + let newMinRange = min - (movementX / area.width) * span; + let newMaxRange = max - (movementX / area.width) * span; + + if (newMinRange < MIN_RANGE) { + newMinRange = MIN_RANGE; + newMaxRange = span; + } + + if (newMaxRange > MAX_RANGE) { + newMaxRange = MAX_RANGE; + newMinRange = MAX_RANGE - span; + } + + setZoomRange([newMinRange, newMaxRange]); + }; + + const handleDown = (event: PointerEvent) => { + eventCacheRef.current.push(event); + const point = getSVGPoint(element, event); + + if (isPointOutside(point, area)) { + return; + } + + // If there is only one pointer, prevent selecting text + if (eventCacheRef.current.length === 1) { + event.preventDefault(); + } + + isDraggingRef.current = true; + setIsInteracting(true); + + touchStartRef.current = { + x: point.x, + minX: zoomRange[0], + maxX: zoomRange[1], + }; + }; + + const handleUp = (event: PointerEvent) => { + eventCacheRef.current.splice( + eventCacheRef.current.findIndex((e) => e.pointerId === event.pointerId), + 1, + ); + setIsInteracting(false); + isDraggingRef.current = false; + touchStartRef.current = null; + }; + + element.addEventListener('pointerdown', handleDown); + document.addEventListener('pointermove', handlePan); + document.addEventListener('pointerup', handleUp); + document.addEventListener('pointercancel', handleUp); + document.addEventListener('pointerleave', handleUp); + + return () => { + element.removeEventListener('pointerdown', handleDown); + document.removeEventListener('pointermove', handlePan); + document.removeEventListener('pointerup', handleUp); + document.removeEventListener('pointercancel', handleUp); + document.removeEventListener('pointerleave', handleUp); + }; + }, [area, svgRef, isDraggingRef, zoomRange, setZoomRange, setIsInteracting]); +}; diff --git a/packages/x-charts-pro/src/context/ZoomProvider/useSetupZoom.ts b/packages/x-charts-pro/src/context/ZoomProvider/useSetupZoom.ts new file mode 100644 index 0000000000000..c32ef62edd90b --- /dev/null +++ b/packages/x-charts-pro/src/context/ZoomProvider/useSetupZoom.ts @@ -0,0 +1,280 @@ +import * as React from 'react'; +import { useDrawingArea, useSvgRef } from '@mui/x-charts/hooks'; +import { getSVGPoint } from '@mui/x-charts/internals'; +import { useZoom } from './useZoom'; + +const MAX_RANGE = 100; +const MIN_RANGE = 0; + +const MIN_ALLOWED_SPAN = 10; +const MAX_ALLOWED_SPAN = 100; + +/** + * Helper to get the range (in percents of a reference range) corresponding to a given scale. + * @param centerRatio {number} The ratio of the point that should not move between the previous and next range. + * @param scaleRatio {number} The target scale ratio. + * @returns The range to display. + */ +const zoomAtPoint = ( + centerRatio: number, + scaleRatio: number, + currentRange: readonly [number, number], +) => { + const [minRange, maxRange] = currentRange; + + const point = minRange + centerRatio * (maxRange - minRange); + + let newMinRange = (minRange + point * (scaleRatio - 1)) / scaleRatio; + let newMaxRange = (maxRange + point * (scaleRatio - 1)) / scaleRatio; + + let minSpillover = 0; + let maxSpillover = 0; + + if (newMinRange < MIN_RANGE) { + minSpillover = Math.abs(newMinRange); + newMinRange = MIN_RANGE; + } + if (newMaxRange > MAX_RANGE) { + maxSpillover = Math.abs(newMaxRange - MAX_RANGE); + newMaxRange = MAX_RANGE; + } + + if (minSpillover > 0 && maxSpillover > 0) { + return [MIN_RANGE, MAX_RANGE]; + } + + newMaxRange += minSpillover; + newMinRange -= maxSpillover; + + newMinRange = Math.min(MAX_RANGE - MIN_ALLOWED_SPAN, Math.max(MIN_RANGE, newMinRange)); + newMaxRange = Math.max(MIN_ALLOWED_SPAN, Math.min(MAX_RANGE, newMaxRange)); + + return [newMinRange, newMaxRange]; +}; + +const isPointOutside = ( + point: { x: number; y: number }, + area: { left: number; top: number; width: number; height: number }, +) => { + const outsideX = point.x < area.left || point.x > area.left + area.width; + const outsideY = point.y < area.top || point.y > area.top + area.height; + return outsideX || outsideY; +}; + +export const useSetupZoom = () => { + const { zoomRange, setZoomRange } = useZoom(); + const area = useDrawingArea(); + + const svgRef = useSvgRef(); + const eventCacheRef = React.useRef([]); + const eventPrevDiff = React.useRef(0); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const wheelHandler = (event: WheelEvent) => { + if (element === null) { + return; + } + + const point = getSVGPoint(element, event); + + if (isPointOutside(point, area)) { + return; + } + + event.preventDefault(); + + const centerRatio = getHorizontalCenterRatio(point, area); + + // TODO: make step a config option. + const step = 5; + const { scaleRatio, isZoomIn } = getWheelScaleRatio(event, step); + + const [newMinRange, newMaxRange] = zoomAtPoint(centerRatio, scaleRatio, zoomRange); + + // TODO: make span a config option. + if (!isSpanValid(newMinRange, newMaxRange, isZoomIn)) { + return; + } + + setZoomRange([newMinRange, newMaxRange]); + }; + + function pointerDownHandler(event: PointerEvent) { + eventCacheRef.current.push(event); + } + + function pointerMoveHandler(event: PointerEvent) { + if (element === null) { + return; + } + + const index = eventCacheRef.current.findIndex( + (cachedEv) => cachedEv.pointerId === event.pointerId, + ); + eventCacheRef.current[index] = event; + + // If two pointers are down, check for pinch gestures + if (eventCacheRef.current.length === 2) { + // TODO: make step configurable + const step = 5; + const { scaleRatio, isZoomIn, curDiff, firstEvent } = getPinchScaleRatio( + eventCacheRef.current, + eventPrevDiff.current, + step, + ); + + // If the scale ratio is 0, it means the pinch gesture is not valid. + if (scaleRatio === 0) { + eventPrevDiff.current = curDiff; + return; + } + + const point = getSVGPoint(element, firstEvent); + + const centerRatio = getHorizontalCenterRatio(point, area); + + const [newMinRange, newMaxRange] = zoomAtPoint(centerRatio, scaleRatio, zoomRange); + + // TODO: make span a config option. + if (!isSpanValid(newMinRange, newMaxRange, isZoomIn)) { + eventPrevDiff.current = curDiff; + return; + } + + eventPrevDiff.current = curDiff; + setZoomRange([newMinRange, newMaxRange]); + } + } + + function pointerUpHandler(event: PointerEvent) { + eventCacheRef.current.splice( + eventCacheRef.current.findIndex((e) => e.pointerId === event.pointerId), + 1, + ); + + if (eventCacheRef.current.length < 2) { + eventPrevDiff.current = 0; + } + } + + element.addEventListener('wheel', wheelHandler); + element.addEventListener('pointerdown', pointerDownHandler); + element.addEventListener('pointermove', pointerMoveHandler); + element.addEventListener('pointerup', pointerUpHandler); + element.addEventListener('pointercancel', pointerUpHandler); + element.addEventListener('pointerout', pointerUpHandler); + element.addEventListener('pointerleave', pointerUpHandler); + + // Prevent zooming the entire page on touch devices + element.addEventListener('touchstart', preventDefault); + element.addEventListener('touchmove', preventDefault); + + return () => { + element.removeEventListener('wheel', wheelHandler); + element.removeEventListener('pointerdown', pointerDownHandler); + element.removeEventListener('pointermove', pointerMoveHandler); + element.removeEventListener('pointerup', pointerUpHandler); + element.removeEventListener('pointercancel', pointerUpHandler); + element.removeEventListener('pointerout', pointerUpHandler); + element.removeEventListener('pointerleave', pointerUpHandler); + element.removeEventListener('touchstart', preventDefault); + element.removeEventListener('touchmove', preventDefault); + }; + }, [svgRef, setZoomRange, zoomRange, area]); +}; + +/** + * Checks if the new span is valid. + */ +function isSpanValid(minRange: number, maxRange: number, isZoomIn: boolean) { + const newSpanPercent = maxRange - minRange; + + // TODO: make span a config option. + if ( + (isZoomIn && newSpanPercent < MIN_ALLOWED_SPAN) || + (!isZoomIn && newSpanPercent > MAX_ALLOWED_SPAN) + ) { + return false; + } + + return true; +} + +function getMultiplier(event: WheelEvent) { + const ctrlMultiplier = event.ctrlKey ? 3 : 1; + + // DeltaMode: 0 is pixel, 1 is line, 2 is page + // This is defined by the browser. + if (event.deltaMode === 1) { + return 1 * ctrlMultiplier; + } + if (event.deltaMode) { + return 10 * ctrlMultiplier; + } + return 0.2 * ctrlMultiplier; +} + +/** + * Get the scale ratio and if it's a zoom in or out from a wheel event. + */ +function getWheelScaleRatio(event: WheelEvent, step: number) { + const deltaY = -event.deltaY; + const multiplier = getMultiplier(event); + const scaledStep = (step * multiplier * deltaY) / 1000; + // Clamp the scale ratio between 0.1 and 1.9 so that the zoom is not too big or too small. + const scaleRatio = Math.min(Math.max(1 + scaledStep, 0.1), 1.9); + const isZoomIn = deltaY > 0; + return { scaleRatio, isZoomIn }; +} + +/** + * Get the scale ratio and if it's a zoom in or out from a pinch gesture. + */ +function getPinchScaleRatio(eventCache: PointerEvent[], prevDiff: number, step: number) { + const scaledStep = step / 1000; + let scaleRatio: number = 0; + let isZoomIn: boolean = false; + + const [firstEvent, secondEvent] = eventCache; + + // Calculate the distance between the two pointers + const curDiff = Math.hypot( + firstEvent.pageX - secondEvent.pageX, + firstEvent.pageY - secondEvent.pageY, + ); + + const hasMoved = prevDiff > 0; + + if (hasMoved && curDiff > prevDiff) { + // The distance between the two pointers has increased + scaleRatio = 1 + scaledStep; + isZoomIn = true; + } + if (hasMoved && curDiff < prevDiff) { + // The distance between the two pointers has decreased + scaleRatio = 1 - scaledStep; + isZoomIn = false; + } + + return { scaleRatio, isZoomIn, curDiff, firstEvent }; +} + +/** + * Get the ratio of the point in the horizontal center of the area. + */ +function getHorizontalCenterRatio( + point: { x: number; y: number }, + area: { left: number; width: number }, +) { + const { left, width } = area; + return (point.x - left) / width; +} + +function preventDefault(event: TouchEvent) { + event.preventDefault(); +} diff --git a/packages/x-charts-pro/src/context/ZoomProvider/useZoom.ts b/packages/x-charts-pro/src/context/ZoomProvider/useZoom.ts new file mode 100644 index 0000000000000..59bda96ca395a --- /dev/null +++ b/packages/x-charts-pro/src/context/ZoomProvider/useZoom.ts @@ -0,0 +1,7 @@ +import * as React from 'react'; +import { ZoomContext } from './ZoomContext'; + +export const useZoom = () => { + const { data } = React.useContext(ZoomContext); + return data; +}; diff --git a/packages/x-charts-pro/src/index.ts b/packages/x-charts-pro/src/index.ts index a8798cfd6abce..eade0525b9407 100644 --- a/packages/x-charts-pro/src/index.ts +++ b/packages/x-charts-pro/src/index.ts @@ -29,7 +29,7 @@ export * from '@mui/x-charts/ChartsSurface'; // Pro components export * from './Heatmap'; export * from './ResponsiveChartContainerPro'; +export * from './ChartContainerPro'; export * from './ScatterChartPro'; export * from './BarChartPro'; export * from './LineChartPro'; -export * from './ChartContainerPro'; diff --git a/packages/x-charts/src/ChartsXAxis/ChartsXAxis.tsx b/packages/x-charts/src/ChartsXAxis/ChartsXAxis.tsx index ea796b2e333ec..247a7ba5a7deb 100644 --- a/packages/x-charts/src/ChartsXAxis/ChartsXAxis.tsx +++ b/packages/x-charts/src/ChartsXAxis/ChartsXAxis.tsx @@ -205,38 +205,39 @@ function ChartsXAxis(inProps: ChartsXAxisProps) { className={classes.root} > {!disableLine && ( - + )} - {xTicksWithDimension.map(({ formattedValue, offset, labelOffset, skipLabel }, index) => { - const xTickLabel = labelOffset ?? 0; - const yTickLabel = positionSign * (tickSize + 3); - return ( - - {!disableTicks && ( - - )} + {xTicksWithDimension + .filter((tick) => tick.offset >= left - 1 && tick.offset <= left + width + 1) + .map(({ formattedValue, offset, labelOffset, skipLabel }, index) => { + const xTickLabel = labelOffset ?? 0; + const yTickLabel = positionSign * (tickSize + 3); - {formattedValue !== undefined && !skipLabel && ( - - )} - - ); - })} + const showTick = offset >= left - 1 && offset <= left + width + 1; + const showTickLabel = + offset + xTickLabel >= left - 1 && offset + xTickLabel <= left + width + 1; + return ( + + {!disableTicks && showTick && ( + + )} + + {formattedValue !== undefined && !skipLabel && showTickLabel && ( + + )} + + ); + })} {label && ( diff --git a/packages/x-charts/src/LineChart/MarkPlot.tsx b/packages/x-charts/src/LineChart/MarkPlot.tsx index ceecfbb0b6f77..7498c3f3eb4f6 100644 --- a/packages/x-charts/src/LineChart/MarkPlot.tsx +++ b/packages/x-charts/src/LineChart/MarkPlot.tsx @@ -9,6 +9,7 @@ import { LineItemIdentifier } from '../models/seriesType/line'; import { cleanId } from '../internals/utils'; import getColor from './getColor'; import { useLineSeries } from '../hooks/useSeries'; +import { useDrawingArea } from '../hooks/useDrawingArea'; export interface MarkPlotSlots { mark?: React.JSXElementConstructor; @@ -58,6 +59,7 @@ function MarkPlot(props: MarkPlotProps) { const seriesData = useLineSeries(); const axisData = useCartesianContext(); const chartId = useChartId(); + const { left, width } = useDrawingArea(); const Mark = slots?.mark ?? MarkElement; @@ -89,11 +91,10 @@ function MarkPlot(props: MarkPlotProps) { const yScale = yAxis[yAxisKey].scale; const xData = xAxis[xAxisKey].data; - const xRange = xAxis[xAxisKey].scale.range(); const yRange = yScale.range(); const isInRange = ({ x, y }: { x: number; y: number }) => { - if (x < Math.min(...xRange) || x > Math.max(...xRange)) { + if (x < left || x > left + width) { return false; } if (y < Math.min(...yRange) || y > Math.max(...yRange)) { diff --git a/packages/x-charts/src/ScatterChart/Scatter.tsx b/packages/x-charts/src/ScatterChart/Scatter.tsx index 782c58ef531e4..abead767285ed 100644 --- a/packages/x-charts/src/ScatterChart/Scatter.tsx +++ b/packages/x-charts/src/ScatterChart/Scatter.tsx @@ -10,6 +10,7 @@ import { useInteractionItemProps } from '../hooks/useInteractionItemProps'; import { InteractionContext } from '../context/InteractionProvider'; import { D3Scale } from '../models/axis'; import { useHighlighted } from '../context'; +import { useDrawingArea } from '../hooks/useDrawingArea'; export interface ScatterProps { series: DefaultizedScatterSeriesType; @@ -42,6 +43,8 @@ export interface ScatterProps { function Scatter(props: ScatterProps) { const { series, xScale, yScale, color, colorGetter, markerSize, onItemClick } = props; + const { left, width } = useDrawingArea(); + const { useVoronoiInteraction } = React.useContext(InteractionContext); const skipInteractionHandlers = useVoronoiInteraction || series.disableHover; @@ -51,11 +54,9 @@ function Scatter(props: ScatterProps) { const cleanData = React.useMemo(() => { const getXPosition = getValueToPositionMapper(xScale); const getYPosition = getValueToPositionMapper(yScale); - const xRange = xScale.range(); + const yRange = yScale.range(); - const minXRange = Math.min(...xRange); - const maxXRange = Math.max(...xRange); const minYRange = Math.min(...yRange); const maxYRange = Math.max(...yRange); @@ -73,7 +74,7 @@ function Scatter(props: ScatterProps) { const x = getXPosition(scatterPoint.x); const y = getYPosition(scatterPoint.y); - const isInRange = x >= minXRange && x <= maxXRange && y >= minYRange && y <= maxYRange; + const isInRange = x >= left && x <= left + width && y >= minYRange && y <= maxYRange; const pointCtx = { type: 'scatter' as const, seriesId: series.id, dataIndex: i }; @@ -100,13 +101,15 @@ function Scatter(props: ScatterProps) { }, [ xScale, yScale, + left, + width, series.data, series.id, + isHighlighted, + isFaded, getInteractionItemProps, - color, colorGetter, - isFaded, - isHighlighted, + color, ]); return ( diff --git a/packages/x-charts/src/context/CartesianProvider/computeValue.ts b/packages/x-charts/src/context/CartesianProvider/computeValue.ts index b9125a212ffef..0d7da4f5b40d8 100644 --- a/packages/x-charts/src/context/CartesianProvider/computeValue.ts +++ b/packages/x-charts/src/context/CartesianProvider/computeValue.ts @@ -27,6 +27,17 @@ const getRange = (drawingArea: DrawingArea, axisName: 'x' | 'y', isReverse?: boo return isReverse ? range.reverse() : range; }; +const zoomedScaleRange = (scaleRange: [number, number] | number[], zoomRange: [number, number]) => { + const rangeGap = scaleRange[1] - scaleRange[0]; + const zoomGap = zoomRange[1] - zoomRange[0]; + + // If current zoom show the scale between p1 and p2 percents + // The range should be extended by adding [0, p1] and [p2, 100] segments + const min = scaleRange[0] - (zoomRange[0] * rangeGap) / zoomGap; + const max = scaleRange[1] + ((100 - zoomRange[1]) * rangeGap) / zoomGap; + + return [min, max]; +}; const isDateData = (data?: any[]): data is Date[] => data?.[0] instanceof Date; function createDateFormatter( @@ -48,6 +59,7 @@ export function computeValue( axis: MakeOptional, 'id'>[] | undefined, extremumGetters: { [K in CartesianChartSeriesType]?: ExtremumGetter }, axisName: 'y', + zoomRange?: [number, number], ): { axis: DefaultizedAxisConfig; axisIds: string[]; @@ -58,6 +70,7 @@ export function computeValue( inAxis: MakeOptional, 'id'>[] | undefined, extremumGetters: { [K in CartesianChartSeriesType]?: ExtremumGetter }, axisName: 'x', + zoomRange?: [number, number], ): { axis: DefaultizedAxisConfig; axisIds: string[]; @@ -68,6 +81,7 @@ export function computeValue( inAxis: MakeOptional, 'id'>[] | undefined, extremumGetters: { [K in CartesianChartSeriesType]?: ExtremumGetter }, axisName: 'x' | 'y', + zoomRange: [number, number] = [0, 100], ) { const DEFAULT_AXIS_KEY = axisName === 'x' ? DEFAULT_X_AXIS_KEY : DEFAULT_Y_AXIS_KEY; @@ -96,12 +110,13 @@ export function computeValue( const barGapRatio = axis.barGapRatio ?? DEFAULT_BAR_GAP_RATIO; // Reverse range because ordinal scales are presented from top to bottom on y-axis const scaleRange = axisName === 'x' ? range : [range[1], range[0]]; + const zoomedRange = zoomedScaleRange(scaleRange, zoomRange); completeAxis[axis.id] = { categoryGapRatio, barGapRatio, ...axis, - scale: scaleBand(axis.data!, scaleRange) + scale: scaleBand(axis.data!, zoomedRange) .paddingInner(categoryGapRatio) .paddingOuter(categoryGapRatio / 2), tickNumber: axis.data!.length, @@ -119,10 +134,11 @@ export function computeValue( } if (isPointScaleConfig(axis)) { const scaleRange = axisName === 'x' ? range : [...range].reverse(); + const zoomedRange = zoomedScaleRange(scaleRange, zoomRange); completeAxis[axis.id] = { ...axis, - scale: scalePoint(axis.data!, scaleRange), + scale: scalePoint(axis.data!, zoomedRange), tickNumber: axis.data!.length, colorScale: axis.colorMap && @@ -144,9 +160,13 @@ export function computeValue( const scaleType = axis.scaleType ?? ('linear' as const); const extremums = [axis.min ?? minData, axis.max ?? maxData]; - const tickNumber = getTickNumber({ ...axis, range, domain: extremums }); + const rawTickNumber = getTickNumber({ ...axis, range, domain: extremums }); + const tickNumber = rawTickNumber / ((zoomRange[1] - zoomRange[0]) / 100); + + const zoomedRange = zoomedScaleRange(range, zoomRange); - const scale = getScale(scaleType, extremums, range).nice(tickNumber); + // TODO: move nice to prop? Disable when there is zoom? + const scale = getScale(scaleType, extremums, zoomedRange).nice(rawTickNumber); const [minDomain, maxDomain] = scale.domain(); const domain = [axis.min ?? minDomain, axis.max ?? maxDomain];