diff --git a/packages/x-charts/package.json b/packages/x-charts/package.json index f4f1fa97de31..f04c575f7cf4 100644 --- a/packages/x-charts/package.json +++ b/packages/x-charts/package.json @@ -46,7 +46,9 @@ "@react-spring/rafz": "^9.7.5", "@react-spring/web": "^9.7.5", "clsx": "^2.1.1", - "prop-types": "^15.8.1" + "prop-types": "^15.8.1", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.0.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", @@ -71,6 +73,7 @@ "@react-spring/core": "^9.7.5", "@react-spring/shared": "^9.7.5", "@types/prop-types": "^15.7.13", + "@types/use-sync-external-store": "^0.0.6", "csstype": "^3.1.3", "rimraf": "^6.0.1" }, diff --git a/packages/x-charts/src/ChartsAxisHighlight/ChartsAxisHighlight.tsx b/packages/x-charts/src/ChartsAxisHighlight/ChartsAxisHighlight.tsx index 5712979e916c..a53bfafff95f 100644 --- a/packages/x-charts/src/ChartsAxisHighlight/ChartsAxisHighlight.tsx +++ b/packages/x-charts/src/ChartsAxisHighlight/ChartsAxisHighlight.tsx @@ -2,29 +2,10 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import composeClasses from '@mui/utils/composeClasses'; -import generateUtilityClass from '@mui/utils/generateUtilityClass'; -import generateUtilityClasses from '@mui/utils/generateUtilityClasses'; -import { styled } from '@mui/material/styles'; -import { InteractionContext } from '../context/InteractionProvider'; -import { useCartesianContext } from '../context/CartesianProvider'; -import { getValueToPositionMapper } from '../hooks/useScale'; -import { isBandScale } from '../internals/isBandScale'; - -export interface ChartsAxisHighlightClasses { - /** Styles applied to the root element. */ - root: string; -} - -export type ChartsAxisHighlightClassKey = keyof ChartsAxisHighlightClasses; - -export function getAxisHighlightUtilityClass(slot: string) { - return generateUtilityClass('MuiChartsAxisHighlight', slot); -} - -export const chartsAxisHighlightClasses: ChartsAxisHighlightClasses = generateUtilityClasses( - 'MuiChartsAxisHighlight', - ['root'], -); +import { getAxisHighlightUtilityClass } from './chartsAxisHighlightClasses'; +import ChartsYHighlight from './ChartsYAxisHighlight'; +import ChartsXHighlight from './ChartsXAxisHighlight'; +import { ChartsAxisHighlightProps } from './ChartsAxisHighlight.types'; const useUtilityClasses = () => { const slots = { @@ -34,47 +15,6 @@ const useUtilityClasses = () => { return composeClasses(slots, getAxisHighlightUtilityClass); }; -export const ChartsAxisHighlightPath = styled('path', { - name: 'MuiChartsAxisHighlight', - slot: 'Root', - overridesResolver: (_, styles) => styles.root, -})<{ ownerState: { axisHighlight: AxisHighlight } }>(({ theme }) => ({ - pointerEvents: 'none', - variants: [ - { - props: { - axisHighlight: 'band', - }, - style: { - fill: 'white', - fillOpacity: 0.1, - ...theme.applyStyles('light', { - fill: 'gray', - }), - }, - }, - { - props: { - axisHighlight: 'line', - }, - style: { - strokeDasharray: '5 2', - stroke: '#ffffff', - ...theme.applyStyles('light', { - stroke: '#000000', - }), - }, - }, - ], -})); - -type AxisHighlight = 'none' | 'line' | 'band'; - -export type ChartsAxisHighlightProps = { - x?: AxisHighlight; - y?: AxisHighlight; -}; - /** * Demos: * @@ -86,88 +26,12 @@ export type ChartsAxisHighlightProps = { */ function ChartsAxisHighlight(props: ChartsAxisHighlightProps) { const { x: xAxisHighlight, y: yAxisHighlight } = props; - const { xAxisIds, xAxis, yAxisIds, yAxis } = useCartesianContext(); - const classes = useUtilityClasses(); - - const USED_X_AXIS_ID = xAxisIds[0]; - const USED_Y_AXIS_ID = yAxisIds[0]; - - const xScale = xAxis[USED_X_AXIS_ID].scale; - const yScale = yAxis[USED_Y_AXIS_ID].scale; - - const { axis } = React.useContext(InteractionContext); - - const getXPosition = getValueToPositionMapper(xScale); - const getYPosition = getValueToPositionMapper(yScale); - - const axisX = axis.x; - const axisY = axis.y; - - const isBandScaleX = xAxisHighlight === 'band' && axisX !== null && isBandScale(xScale); - const isBandScaleY = yAxisHighlight === 'band' && axisY !== null && isBandScale(yScale); - - if (process.env.NODE_ENV !== 'production') { - const isXError = isBandScaleX && xScale(axisX.value) === undefined; - const isYError = isBandScaleY && yScale(axisY.value) === undefined; - - if (isXError || isYError) { - console.error( - [ - `MUI X: The position value provided for the axis is not valid for the current scale.`, - `This probably means something is wrong with the data passed to the chart.`, - `The ChartsAxisHighlight component will not be displayed.`, - ].join('\n'), - ); - } - } + const classes = useUtilityClasses(); return ( - {isBandScaleX && xScale(axisX.value) !== undefined && ( - - )} - - {isBandScaleY && yScale(axisY.value) !== undefined && ( - - )} - - {xAxisHighlight === 'line' && axis.x !== null && ( - - )} - - {yAxisHighlight === 'line' && axis.y !== null && ( - - )} + {xAxisHighlight && } + {yAxisHighlight && } ); } diff --git a/packages/x-charts/src/ChartsAxisHighlight/ChartsAxisHighlight.types.ts b/packages/x-charts/src/ChartsAxisHighlight/ChartsAxisHighlight.types.ts new file mode 100644 index 000000000000..de1b75d43438 --- /dev/null +++ b/packages/x-charts/src/ChartsAxisHighlight/ChartsAxisHighlight.types.ts @@ -0,0 +1,6 @@ +export type ChartsAxisHighlightType = 'none' | 'line' | 'band'; + +export type ChartsAxisHighlightProps = { + x?: ChartsAxisHighlightType; + y?: ChartsAxisHighlightType; +}; diff --git a/packages/x-charts/src/ChartsAxisHighlight/ChartsAxisHighlightPath.ts b/packages/x-charts/src/ChartsAxisHighlight/ChartsAxisHighlightPath.ts new file mode 100644 index 000000000000..d015d2b5d15c --- /dev/null +++ b/packages/x-charts/src/ChartsAxisHighlight/ChartsAxisHighlightPath.ts @@ -0,0 +1,37 @@ +'use client'; +import { styled } from '@mui/material/styles'; +import { ChartsAxisHighlightType } from './ChartsAxisHighlight.types'; + +export const ChartsAxisHighlightPath = styled('path', { + name: 'MuiChartsAxisHighlight', + slot: 'Root', + overridesResolver: (_, styles) => styles.root, +})<{ ownerState: { axisHighlight: ChartsAxisHighlightType } }>(({ theme }) => ({ + pointerEvents: 'none', + variants: [ + { + props: { + axisHighlight: 'band', + }, + style: { + fill: 'white', + fillOpacity: 0.1, + ...theme.applyStyles('light', { + fill: 'gray', + }), + }, + }, + { + props: { + axisHighlight: 'line', + }, + style: { + strokeDasharray: '5 2', + stroke: '#ffffff', + ...theme.applyStyles('light', { + stroke: '#000000', + }), + }, + }, + ], +})); diff --git a/packages/x-charts/src/ChartsAxisHighlight/ChartsXAxisHighlight.tsx b/packages/x-charts/src/ChartsAxisHighlight/ChartsXAxisHighlight.tsx new file mode 100644 index 000000000000..d828547af94e --- /dev/null +++ b/packages/x-charts/src/ChartsAxisHighlight/ChartsXAxisHighlight.tsx @@ -0,0 +1,69 @@ +'use client'; +import * as React from 'react'; +import { getValueToPositionMapper, useXScale } from '../hooks/useScale'; +import { isBandScale } from '../internals/isBandScale'; +import { useSelector } from '../internals/useSelector'; +import { useStore } from '../internals/useStore'; +import { selectorChartsInteractionXAxis } from '../context/InteractionSelectors'; +import { useDrawingArea } from '../hooks'; +import { ChartsAxisHighlightType } from './ChartsAxisHighlight.types'; +import { ChartsAxisHighlightClasses } from './chartsAxisHighlightClasses'; +import { ChartsAxisHighlightPath } from './ChartsAxisHighlightPath'; + +/** + * @ignore - internal component. + */ +export default function ChartsXHighlight(props: { + type: ChartsAxisHighlightType; + classes: ChartsAxisHighlightClasses; +}) { + const { type, classes } = props; + + const { top, height } = useDrawingArea(); + + const xScale = useXScale(); + + const store = useStore(); + const axisX = useSelector(store, selectorChartsInteractionXAxis); + + const getXPosition = getValueToPositionMapper(xScale); + + const isBandScaleX = type === 'band' && axisX !== null && isBandScale(xScale); + + if (process.env.NODE_ENV !== 'production') { + const isError = isBandScaleX && xScale(axisX.value) === undefined; + + if (isError) { + console.error( + [ + `MUI X: The position value provided for the axis is not valid for the current scale.`, + `This probably means something is wrong with the data passed to the chart.`, + `The ChartsAxisHighlight component will not be displayed.`, + ].join('\n'), + ); + } + } + + return ( + + {isBandScaleX && xScale(axisX.value) !== undefined && ( + + )} + + {type === 'line' && axisX !== null && ( + + )} + + ); +} diff --git a/packages/x-charts/src/ChartsAxisHighlight/ChartsYAxisHighlight.tsx b/packages/x-charts/src/ChartsAxisHighlight/ChartsYAxisHighlight.tsx new file mode 100644 index 000000000000..ce996fb544df --- /dev/null +++ b/packages/x-charts/src/ChartsAxisHighlight/ChartsYAxisHighlight.tsx @@ -0,0 +1,71 @@ +'use client'; +import * as React from 'react'; +import { getValueToPositionMapper, useYScale } from '../hooks/useScale'; +import { isBandScale } from '../internals/isBandScale'; +import { useSelector } from '../internals/useSelector'; +import { useStore } from '../internals/useStore'; +import { selectorChartsInteractionYAxis } from '../context/InteractionSelectors'; +import { useDrawingArea } from '../hooks'; +import { ChartsAxisHighlightType } from './ChartsAxisHighlight.types'; +import { ChartsAxisHighlightClasses } from './chartsAxisHighlightClasses'; +import { ChartsAxisHighlightPath } from './ChartsAxisHighlightPath'; + +/** + * @ignore - internal component. + */ +export default function ChartsYHighlight(props: { + type: ChartsAxisHighlightType; + classes: ChartsAxisHighlightClasses; +}) { + const { type, classes } = props; + + const { left, width } = useDrawingArea(); + + const yScale = useYScale(); + + const store = useStore(); + const axisY = useSelector(store, selectorChartsInteractionYAxis); + + const getYPosition = getValueToPositionMapper(yScale); + + const isBandScaleY = type === 'band' && axisY !== null && isBandScale(yScale); + + if (process.env.NODE_ENV !== 'production') { + const isError = isBandScaleY && yScale(axisY.value) === undefined; + + if (isError) { + console.error( + [ + `MUI X: The position value provided for the axis is not valid for the current scale.`, + `This probably means something is wrong with the data passed to the chart.`, + `The ChartsAxisHighlight component will not be displayed.`, + ].join('\n'), + ); + } + } + + return ( + + {isBandScaleY && yScale(axisY.value) !== undefined && ( + + )} + + {type === 'line' && axisY !== null && ( + + )} + + ); +} diff --git a/packages/x-charts/src/ChartsAxisHighlight/chartsAxisHighlightClasses.ts b/packages/x-charts/src/ChartsAxisHighlight/chartsAxisHighlightClasses.ts new file mode 100644 index 000000000000..7d62c32d82fa --- /dev/null +++ b/packages/x-charts/src/ChartsAxisHighlight/chartsAxisHighlightClasses.ts @@ -0,0 +1,18 @@ +import generateUtilityClass from '@mui/utils/generateUtilityClass'; +import generateUtilityClasses from '@mui/utils/generateUtilityClasses'; + +export interface ChartsAxisHighlightClasses { + /** Styles applied to the root element. */ + root: string; +} + +export type ChartsAxisHighlightClassKey = keyof ChartsAxisHighlightClasses; + +export function getAxisHighlightUtilityClass(slot: string) { + return generateUtilityClass('MuiChartsAxisHighlight', slot); +} + +export const chartsAxisHighlightClasses: ChartsAxisHighlightClasses = generateUtilityClasses( + 'MuiChartsAxisHighlight', + ['root'], +); diff --git a/packages/x-charts/src/ChartsAxisHighlight/index.ts b/packages/x-charts/src/ChartsAxisHighlight/index.ts index 14c071e360be..653afd1cfd87 100644 --- a/packages/x-charts/src/ChartsAxisHighlight/index.ts +++ b/packages/x-charts/src/ChartsAxisHighlight/index.ts @@ -1 +1,4 @@ export * from './ChartsAxisHighlight'; +export * from './chartsAxisHighlightClasses'; +export * from './ChartsAxisHighlight.types'; +export * from './ChartsAxisHighlightPath'; diff --git a/packages/x-charts/src/ChartsOnAxisClickHandler/ChartsOnAxisClickHandler.tsx b/packages/x-charts/src/ChartsOnAxisClickHandler/ChartsOnAxisClickHandler.tsx index 8c26ff5650e6..a5a7d65b0ab7 100644 --- a/packages/x-charts/src/ChartsOnAxisClickHandler/ChartsOnAxisClickHandler.tsx +++ b/packages/x-charts/src/ChartsOnAxisClickHandler/ChartsOnAxisClickHandler.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; -import { InteractionContext } from '../context/InteractionProvider'; +import { useStore } from '../internals/useStore'; import { useSeries } from '../hooks/useSeries'; import { useSvgRef } from '../hooks'; import { useCartesianContext } from '../context/CartesianProvider'; @@ -27,7 +27,8 @@ function ChartsOnAxisClickHandler(props: ChartsOnAxisClickHandlerProps) { const svgRef = useSvgRef(); const series = useSeries(); - const { axis } = React.useContext(InteractionContext); + const store = useStore(); + const { xAxisIds, xAxis, yAxisIds, yAxis } = useCartesianContext(); React.useEffect(() => { @@ -39,9 +40,10 @@ function ChartsOnAxisClickHandler(props: ChartsOnAxisClickHandlerProps) { const handleMouseClick = (event: MouseEvent) => { event.preventDefault(); - const isXaxis = axis.x && axis.x.index !== -1; + const { x: axisX, y: axisY } = store.value.interaction.axis; + const isXaxis = axisX && axisX.index !== -1; const USED_AXIS_ID = isXaxis ? xAxisIds[0] : yAxisIds[0]; - const dataIndex = isXaxis ? axis.x && axis.x.index : axis.y && axis.y.index; + const dataIndex = isXaxis ? axisX && axisX.index : axisY && axisY.index; if (dataIndex == null) { return; @@ -72,7 +74,7 @@ function ChartsOnAxisClickHandler(props: ChartsOnAxisClickHandlerProps) { return () => { element.removeEventListener('click', handleMouseClick); }; - }, [axis.x, axis.y, onAxisClick, series, svgRef, xAxis, xAxisIds, yAxis, yAxisIds]); + }, [onAxisClick, series, store, svgRef, xAxis, xAxisIds, yAxis, yAxisIds]); // eslint-disable-next-line react/jsx-no-useless-fragment return ; diff --git a/packages/x-charts/src/ChartsSurface/ChartsSurface.test.tsx b/packages/x-charts/src/ChartsSurface/ChartsSurface.test.tsx index f9bd7b41068c..8a61ef8212f6 100644 --- a/packages/x-charts/src/ChartsSurface/ChartsSurface.test.tsx +++ b/packages/x-charts/src/ChartsSurface/ChartsSurface.test.tsx @@ -19,7 +19,10 @@ describe('', () => { render( - + diff --git a/packages/x-charts/src/ChartsTooltip/ChartsAxisTooltipContent.tsx b/packages/x-charts/src/ChartsTooltip/ChartsAxisTooltipContent.tsx index 28b6f6f833e0..ed4f1a6608b4 100644 --- a/packages/x-charts/src/ChartsTooltip/ChartsAxisTooltipContent.tsx +++ b/packages/x-charts/src/ChartsTooltip/ChartsAxisTooltipContent.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { SxProps, Theme } from '@mui/material/styles'; import useSlotProps from '@mui/utils/useSlotProps'; -import { AxisInteractionData } from '../context/InteractionProvider'; +import { AxisInteractionData } from '../internals/plugins/models'; import { useCartesianContext } from '../context/CartesianProvider'; import { ChartSeriesDefaultized, ChartSeriesType } from '../models/seriesType/config'; import { AxisDefaultized } from '../models/axis'; diff --git a/packages/x-charts/src/ChartsTooltip/ChartsItemTooltipContent.tsx b/packages/x-charts/src/ChartsTooltip/ChartsItemTooltipContent.tsx index 30140400a948..8d374db974f7 100644 --- a/packages/x-charts/src/ChartsTooltip/ChartsItemTooltipContent.tsx +++ b/packages/x-charts/src/ChartsTooltip/ChartsItemTooltipContent.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { SxProps, Theme } from '@mui/material/styles'; import useSlotProps from '@mui/utils/useSlotProps'; -import { ItemInteractionData } from '../context/InteractionProvider'; +import { ItemInteractionData } from '../internals/plugins/models'; import { ChartSeriesDefaultized, ChartSeriesType } from '../models/seriesType/config'; import { ChartsTooltipClasses } from './chartsTooltipClasses'; import { DefaultChartsItemTooltipContent } from './DefaultChartsItemTooltipContent'; diff --git a/packages/x-charts/src/ChartsTooltip/ChartsTooltip.tsx b/packages/x-charts/src/ChartsTooltip/ChartsTooltip.tsx index 560a83ccc9a4..544dac733866 100644 --- a/packages/x-charts/src/ChartsTooltip/ChartsTooltip.tsx +++ b/packages/x-charts/src/ChartsTooltip/ChartsTooltip.tsx @@ -7,17 +7,19 @@ import { styled, useThemeProps, SxProps, Theme } from '@mui/material/styles'; import Popper, { PopperProps as BasePopperProps } from '@mui/material/Popper'; import NoSsr from '@mui/material/NoSsr'; import useSlotProps from '@mui/utils/useSlotProps'; -import { - AxisInteractionData, - InteractionContext, - ItemInteractionData, -} from '../context/InteractionProvider'; import { useSvgRef } from '../hooks/useSvgRef'; import { getTooltipHasData, TriggerOptions, usePointerType } from './utils'; import { ChartSeriesType } from '../models/seriesType/config'; import { ChartsItemContentProps, ChartsItemTooltipContent } from './ChartsItemTooltipContent'; import { ChartsAxisContentProps, ChartsAxisTooltipContent } from './ChartsAxisTooltipContent'; import { ChartsTooltipClasses, getChartsTooltipUtilityClass } from './chartsTooltipClasses'; +import { + selectorChartsInteractionItem, + selectorChartsInteractionAxis, +} from '../context/InteractionSelectors'; +import { ItemInteractionData, AxisInteractionData } from '../internals/plugins/models'; +import { useSelector } from '../internals/useSelector'; +import { useStore } from '../internals/useStore'; export type PopperProps = BasePopperProps & { /** @@ -137,7 +139,9 @@ function ChartsTooltip(inProps: ChartsTooltipProps const positionRef = useLazyRef(() => ({ x: 0, y: 0 })); - const { item, axis } = React.useContext(InteractionContext); + const store = useStore(); + const item = useSelector(store, selectorChartsInteractionItem); + const axis = useSelector(store, selectorChartsInteractionAxis); const displayedData = trigger === 'item' ? item : axis; diff --git a/packages/x-charts/src/ChartsTooltip/useAxisTooltip.tsx b/packages/x-charts/src/ChartsTooltip/useAxisTooltip.tsx index 109665b90e5d..aff76dca6187 100644 --- a/packages/x-charts/src/ChartsTooltip/useAxisTooltip.tsx +++ b/packages/x-charts/src/ChartsTooltip/useAxisTooltip.tsx @@ -1,6 +1,5 @@ 'use client'; import * as React from 'react'; -import { AxisInteractionData, InteractionContext } from '../context/InteractionProvider'; import { useSeries } from '../hooks/useSeries'; import { useCartesianContext } from '../context/CartesianProvider'; import { ZAxisContext } from '../context/ZAxisContextProvider'; @@ -10,6 +9,15 @@ import { CartesianChartSeriesType, ChartsSeriesConfig } from '../models/seriesTy import { getLabel } from '../internals/getLabel'; import { isCartesianSeriesType } from '../internals/isCartesian'; import { utcFormatter } from './utils'; +import { + selectorChartsInteractionAxis, + selectorChartsInteractionXAxis, + selectorChartsInteractionYAxis, +} from '../context/InteractionSelectors'; +import { useXAxis, useYAxis } from '../hooks'; +import { useSelector } from '../internals/useSelector'; +import { useStore } from '../internals/useStore'; +import { AxisInteractionData } from '../internals/plugins/models'; export interface UseAxisTooltipReturnValue< SeriesT extends CartesianChartSeriesType = CartesianChartSeriesType, @@ -30,26 +38,35 @@ interface SeriesItem { } export function useAxisTooltip(): null | UseAxisTooltipReturnValue { - const { axis } = React.useContext(InteractionContext); + const defaultXAxis = useXAxis(); + const defaultYAxis = useYAxis(); + + const xAxisHasData = defaultXAxis.data !== undefined && defaultXAxis.data.length !== 0; + + const store = useStore(); + + // This line will be removed in v8 because it degrade perfs for no reason except avoiding breaking change. + const axis = useSelector(store, selectorChartsInteractionAxis); + const axisData = useSelector( + store, + xAxisHasData ? selectorChartsInteractionXAxis : selectorChartsInteractionYAxis, + ); + const series = useSeries(); - const { xAxis, yAxis, xAxisIds, yAxisIds } = useCartesianContext(); + const { xAxis, yAxis } = useCartesianContext(); + const { zAxis, zAxisIds } = React.useContext(ZAxisContext); const colorProcessors = useColorProcessor(); - // By default use the x-axis - const isXaxis = axis.x !== null && axis.x.index !== -1; - - const axisData = isXaxis ? axis.x && axis.x : axis.y && axis.y; - if (axisData === null) { return null; } const { index: dataIndex, value: axisValue } = axisData; - const USED_AXIS_ID = isXaxis ? xAxisIds[0] : yAxisIds[0]; - const usedAxis = isXaxis ? xAxis[USED_AXIS_ID] : yAxis[USED_AXIS_ID]; + const USED_AXIS_ID = xAxisHasData ? defaultXAxis.id : defaultYAxis.id; + const usedAxis = xAxisHasData ? defaultXAxis : defaultYAxis; const relevantSeries = Object.keys(series) .filter(isCartesianSeriesType) @@ -64,20 +81,20 @@ export function useAxisTooltip(): null | UseAxisTooltipReturnValue { const providedXAxisId = seriesToAdd.xAxisId; const providedYAxisId = seriesToAdd.yAxisId; - const axisKey = isXaxis ? providedXAxisId : providedYAxisId; + const axisKey = xAxisHasData ? providedXAxisId : providedYAxisId; // Test if the series uses the default axis if (axisKey === undefined || axisKey === USED_AXIS_ID) { - const xAxisId = providedXAxisId ?? xAxisIds[0]; - const yAxisId = providedYAxisId ?? yAxisIds[0]; - const zAxisId = (seriesToAdd as any).zAxisId ?? zAxisIds[0]; + const xAxisId = providedXAxisId ?? defaultXAxis.id; + const yAxisId = providedYAxisId ?? defaultYAxis.id; + const zAxisId = 'zAxisId' in seriesToAdd ? seriesToAdd.zAxisId : zAxisIds[0]; const color = colorProcessors[seriesType]?.( seriesToAdd, xAxis[xAxisId], yAxis[yAxisId], - zAxisId && zAxis[zAxisId], + zAxisId ? zAxis[zAxisId] : undefined, )(dataIndex) ?? ''; const value = seriesToAdd.data[dataIndex] ?? null; @@ -107,7 +124,7 @@ export function useAxisTooltip(): null | UseAxisTooltipReturnValue { const axisFormattedValue = axisFormatter(axisValue, { location: 'tooltip' }); return { - identifier: axis as AxisInteractionData, + identifier: axis, seriesItems: relevantSeries, axisValue, axisFormattedValue, diff --git a/packages/x-charts/src/ChartsTooltip/useItemTooltip.tsx b/packages/x-charts/src/ChartsTooltip/useItemTooltip.tsx index a3ab97c287b1..96023ae1b272 100644 --- a/packages/x-charts/src/ChartsTooltip/useItemTooltip.tsx +++ b/packages/x-charts/src/ChartsTooltip/useItemTooltip.tsx @@ -1,6 +1,5 @@ 'use client'; import * as React from 'react'; -import { InteractionContext, ItemInteractionData } from '../context/InteractionProvider'; import { useSeries } from '../hooks/useSeries'; import { useCartesianContext } from '../context/CartesianProvider'; import { ZAxisContext } from '../context/ZAxisContextProvider'; @@ -12,6 +11,10 @@ import { } from '../models/seriesType/config'; import { getLabel } from '../internals/getLabel'; import { CommonSeriesType } from '../models/seriesType/common'; +import { selectorChartsInteractionItem } from '../context/InteractionSelectors'; +import { useSelector } from '../internals/useSelector'; +import { useStore } from '../internals/useStore'; +import { ItemInteractionData } from '../internals/plugins/models'; export interface UseItemTooltipReturnValue { identifier: ItemInteractionData; @@ -22,7 +25,8 @@ export interface UseItemTooltipReturnValue { } export function useItemTooltip(): null | UseItemTooltipReturnValue { - const { item } = React.useContext(InteractionContext); + const stroe = useStore(); + const item = useSelector(stroe, selectorChartsInteractionItem); const series = useSeries(); const { xAxis, yAxis, xAxisIds, yAxisIds } = useCartesianContext(); diff --git a/packages/x-charts/src/ChartsTooltip/utils.tsx b/packages/x-charts/src/ChartsTooltip/utils.tsx index 227c7f58e24e..6964b5ef3e45 100644 --- a/packages/x-charts/src/ChartsTooltip/utils.tsx +++ b/packages/x-charts/src/ChartsTooltip/utils.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { AxisInteractionData, ItemInteractionData } from '../context/InteractionProvider'; +import { AxisInteractionData, ItemInteractionData } from '../internals/plugins/models'; import { ChartSeriesType } from '../models/seriesType/config'; import { useSvgRef } from '../hooks'; diff --git a/packages/x-charts/src/ChartsVoronoiHandler/ChartsVoronoiHandler.tsx b/packages/x-charts/src/ChartsVoronoiHandler/ChartsVoronoiHandler.tsx index ad1dea102fc6..f0169fb95025 100644 --- a/packages/x-charts/src/ChartsVoronoiHandler/ChartsVoronoiHandler.tsx +++ b/packages/x-charts/src/ChartsVoronoiHandler/ChartsVoronoiHandler.tsx @@ -3,9 +3,9 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { Delaunay } from '@mui/x-charts-vendor/d3-delaunay'; import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; -import { InteractionContext } from '../context/InteractionProvider'; import { useCartesianContext } from '../context/CartesianProvider'; import { getValueToPositionMapper } from '../hooks/useScale'; +import { useStore } from '../internals/useStore'; import { getSVGPoint } from '../internals/getSVGPoint'; import { ScatterItemIdentifier } from '../models'; import { SeriesId } from '../models/seriesType/common'; @@ -34,7 +34,7 @@ function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) { const svgRef = useSvgRef(); const drawingArea = useDrawingArea(); const { xAxis, yAxis, xAxisIds, yAxisIds } = useCartesianContext(); - const { dispatch } = React.useContext(InteractionContext); + const store = useStore(); const { series, seriesOrder } = useScatterSeries() ?? {}; const voronoiRef = React.useRef>({}); @@ -47,11 +47,18 @@ function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) { const defaultYAxisId = yAxisIds[0]; useEnhancedEffect(() => { - dispatch({ type: 'updateVoronoiUsage', useVoronoiInteraction: true }); + store.update((prev) => ({ + ...prev, + interaction: { ...prev.interaction, useVoronoiInteraction: true }, + })); + return () => { - dispatch({ type: 'updateVoronoiUsage', useVoronoiInteraction: false }); + store.update((prev) => ({ + ...prev, + interaction: { ...prev.interaction, useVoronoiInteraction: false }, + })); }; - }, [dispatch]); + }, [store]); useEnhancedEffect(() => { // This effect generate and store the Delaunay object that's used to map coordinate to closest point. @@ -153,7 +160,10 @@ function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) { } const handleMouseLeave = () => { - dispatch({ type: 'exitChart' }); + store.update((prev) => ({ + ...prev, + interaction: { ...prev.interaction, axis: { x: null, y: null }, item: null }, + })); clearHighlighted(); }; @@ -161,19 +171,29 @@ function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) { const closestPoint = getClosestPoint(event); if (closestPoint === 'outside-chart') { - dispatch({ type: 'exitChart' }); + store.update((prev) => ({ + ...prev, + interaction: { ...prev.interaction, axis: { x: null, y: null }, item: null }, + })); clearHighlighted(); return; } if (closestPoint === 'outside-voronoi-max-radius' || closestPoint === 'no-point-found') { - dispatch({ type: 'leaveItem', data: { type: 'scatter' } }); + store.update((prev) => ({ + ...prev, + interaction: { ...prev.interaction, item: null }, + })); clearHighlighted(); return; } const { seriesId, dataIndex } = closestPoint; - dispatch({ type: 'enterItem', data: { type: 'scatter', seriesId, dataIndex } }); + store.update((prev) => ({ + ...prev, + interaction: { ...prev.interaction, item: { type: 'scatter', seriesId, dataIndex } }, + })); + setHighlighted({ seriesId, dataIndex, @@ -205,7 +225,6 @@ function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) { }; }, [ svgRef, - dispatch, yAxis, xAxis, voronoiMaxRadius, @@ -213,6 +232,7 @@ function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) { setHighlighted, clearHighlighted, drawingArea, + store, ]); // eslint-disable-next-line react/jsx-no-useless-fragment diff --git a/packages/x-charts/src/LineChart/CircleMarkElement.tsx b/packages/x-charts/src/LineChart/CircleMarkElement.tsx index 584c15bab4d8..ddc1608cc44c 100644 --- a/packages/x-charts/src/LineChart/CircleMarkElement.tsx +++ b/packages/x-charts/src/LineChart/CircleMarkElement.tsx @@ -4,10 +4,12 @@ import PropTypes from 'prop-types'; import { useTheme } from '@mui/material/styles'; import { warnOnce } from '@mui/x-internals/warning'; import { animated, useSpring } from '@react-spring/web'; -import { InteractionContext } from '../context/InteractionProvider'; import { useInteractionItemProps } from '../hooks/useInteractionItemProps'; import { useItemHighlighted } from '../context'; import { MarkElementOwnerState, useUtilityClasses } from './markElementClasses'; +import { useSelector } from '../internals/useSelector'; +import { selectorChartsInteractionXAxis } from '../context/InteractionSelectors'; +import { useStore } from '../internals/useStore'; export type CircleMarkElementProps = Omit & Omit, 'ref' | 'id'> & { @@ -66,13 +68,14 @@ function CircleMarkElement(props: CircleMarkElementProps) { const { isFaded, isHighlighted } = useItemHighlighted({ seriesId: id, }); - const { axis } = React.useContext(InteractionContext); + const store = useStore(); + const xAxisIdentifier = useSelector(store, selectorChartsInteractionXAxis); const position = useSpring({ to: { x, y }, immediate: skipAnimation }); const ownerState = { id, classes: innerClasses, - isHighlighted: axis.x?.index === dataIndex || isHighlighted, + isHighlighted: xAxisIdentifier?.index === dataIndex || isHighlighted, isFaded, color, }; diff --git a/packages/x-charts/src/LineChart/LineHighlightPlot.tsx b/packages/x-charts/src/LineChart/LineHighlightPlot.tsx index a5911052eb10..7c6e30437bd0 100644 --- a/packages/x-charts/src/LineChart/LineHighlightPlot.tsx +++ b/packages/x-charts/src/LineChart/LineHighlightPlot.tsx @@ -5,11 +5,13 @@ import { SlotComponentPropsFromProps } from '@mui/x-internals/types'; import { useCartesianContext } from '../context/CartesianProvider'; import { LineHighlightElement, LineHighlightElementProps } from './LineHighlightElement'; import { getValueToPositionMapper } from '../hooks/useScale'; -import { InteractionContext } from '../context/InteractionProvider'; import { DEFAULT_X_AXIS_KEY } from '../constants'; import getColor from './getColor'; import { useLineSeries } from '../hooks/useSeries'; import { useDrawingArea } from '../hooks/useDrawingArea'; +import { selectorChartsInteractionXAxis } from '../context/InteractionSelectors'; +import { useSelector } from '../internals/useSelector'; +import { useStore } from '../internals/useStore'; export interface LineHighlightPlotSlots { lineHighlight?: React.JSXElementConstructor; @@ -48,9 +50,11 @@ function LineHighlightPlot(props: LineHighlightPlotProps) { const seriesData = useLineSeries(); const axisData = useCartesianContext(); const drawingArea = useDrawingArea(); - const { axis } = React.useContext(InteractionContext); + const store = useStore(); + const xAxisIdentifier = useSelector(store, selectorChartsInteractionXAxis); + + const highlightedIndex = xAxisIdentifier?.index; - const highlightedIndex = axis.x?.index; if (highlightedIndex === undefined) { return null; } diff --git a/packages/x-charts/src/LineChart/MarkElement.tsx b/packages/x-charts/src/LineChart/MarkElement.tsx index 93a77ca25e25..a3d9d6f93857 100644 --- a/packages/x-charts/src/LineChart/MarkElement.tsx +++ b/packages/x-charts/src/LineChart/MarkElement.tsx @@ -5,10 +5,12 @@ import { styled } from '@mui/material/styles'; import { symbol as d3Symbol, symbolsFill as d3SymbolsFill } from '@mui/x-charts-vendor/d3-shape'; import { animated, to, useSpring } from '@react-spring/web'; import { getSymbol } from '../internals/getSymbol'; -import { InteractionContext } from '../context/InteractionProvider'; import { useInteractionItemProps } from '../hooks/useInteractionItemProps'; import { useItemHighlighted } from '../context'; import { MarkElementOwnerState, useUtilityClasses } from './markElementClasses'; +import { selectorChartsInteractionXAxis } from '../context/InteractionSelectors'; +import { useSelector } from '../internals/useSelector'; +import { useStore } from '../internals/useStore'; const MarkElementPath = styled(animated.path, { name: 'MuiMarkElement', @@ -65,13 +67,15 @@ function MarkElement(props: MarkElementProps) { const { isFaded, isHighlighted } = useItemHighlighted({ seriesId: id, }); - const { axis } = React.useContext(InteractionContext); + + const store = useStore(); + const xAxisIdentifier = useSelector(store, selectorChartsInteractionXAxis); const position = useSpring({ to: { x, y }, immediate: skipAnimation }); const ownerState = { id, classes: innerClasses, - isHighlighted: axis.x?.index === dataIndex || isHighlighted, + isHighlighted: xAxisIdentifier?.index === dataIndex || isHighlighted, isFaded, color, }; diff --git a/packages/x-charts/src/ScatterChart/Scatter.tsx b/packages/x-charts/src/ScatterChart/Scatter.tsx index 74aea129dc00..b6a95e703587 100644 --- a/packages/x-charts/src/ScatterChart/Scatter.tsx +++ b/packages/x-charts/src/ScatterChart/Scatter.tsx @@ -8,10 +8,12 @@ import { } from '../models/seriesType/scatter'; import { getValueToPositionMapper } from '../hooks/useScale'; import { useInteractionItemProps } from '../hooks/useInteractionItemProps'; -import { InteractionContext } from '../context/InteractionProvider'; import { D3Scale } from '../models/axis'; import { useHighlighted } from '../context'; import { useDrawingArea } from '../hooks/useDrawingArea'; +import { selectorChartsInteractionIsVoronoiEnabled } from '../context/InteractionSelectors'; +import { useSelector } from '../internals/useSelector'; +import { useStore } from '../internals/useStore'; export interface ScatterProps { series: DefaultizedScatterSeriesType; @@ -46,9 +48,10 @@ function Scatter(props: ScatterProps) { const drawingArea = useDrawingArea(); - const { useVoronoiInteraction } = React.useContext(InteractionContext); + const store = useStore(); + const isVoronoiEnabled = useSelector(store, selectorChartsInteractionIsVoronoiEnabled); - const skipInteractionHandlers = useVoronoiInteraction || series.disableHover; + const skipInteractionHandlers = isVoronoiEnabled || series.disableHover; const getInteractionItemProps = useInteractionItemProps(skipInteractionHandlers); const { isFaded, isHighlighted } = useHighlighted(); diff --git a/packages/x-charts/src/context/InteractionProvider.tsx b/packages/x-charts/src/context/InteractionProvider.tsx index 80c4658bfc1f..f31eac1c155d 100644 --- a/packages/x-charts/src/context/InteractionProvider.tsx +++ b/packages/x-charts/src/context/InteractionProvider.tsx @@ -1,132 +1,19 @@ 'use client'; import * as React from 'react'; -import { ChartItemIdentifier, ChartSeriesType } from '../models/seriesType/config'; +import { useCharts } from '../internals/useCharts'; +import { ChartStore } from '../internals/plugins/utils/ChartStore'; -export interface InteractionProviderProps { - children: React.ReactNode; -} - -export type ItemInteractionData = ChartItemIdentifier; - -export type AxisInteractionData = { - x: null | { - value: number | Date | string; - // Set to -1 if no index. - index: number; - }; - y: null | { - value: number | Date | string; - // Set to -1 if no index. - index: number; - }; -}; - -type InteractionActions = - | { - type: 'enterItem'; - data: ItemInteractionData; - } - | { - type: 'leaveItem'; - data: Partial>; - } - | { - type: 'exitChart'; - } - | { - type: 'updateVoronoiUsage'; - useVoronoiInteraction: boolean; - } - | { - type: 'updateAxis'; - data: AxisInteractionData; - }; - -type InteractionState = { - /** - * The item currently interacting. - */ - item: null | ItemInteractionData; - /** - * The x- and y-axes currently interacting. - */ - axis: AxisInteractionData; - /** - * Set to `true` when `VoronoiHandler` is active. - * Used to prevent collision with mouseEnter events. - */ - useVoronoiInteraction: boolean; - dispatch: React.Dispatch; -}; - -export const InteractionContext = React.createContext({ - item: null, - axis: { x: null, y: null }, - useVoronoiInteraction: false, - dispatch: () => null, -}); +export const ChartsContext = React.createContext<{ store: ChartStore } | null>(null); if (process.env.NODE_ENV !== 'production') { - InteractionContext.displayName = 'InteractionContext'; + ChartsContext.displayName = 'ChartsContext'; } -const dataReducer: React.Reducer, InteractionActions> = ( - prevState, - action, -) => { - switch (action.type) { - case 'enterItem': - return { ...prevState, item: action.data }; - - case 'exitChart': - if (prevState.item === null && prevState.axis.x === null && prevState.axis.y === null) { - return prevState; - } - return { ...prevState, axis: { x: null, y: null }, item: null }; - - case 'updateVoronoiUsage': - return { ...prevState, useVoronoiInteraction: action.useVoronoiInteraction }; - - case 'leaveItem': - if ( - prevState.item === null || - (Object.keys(action.data) as (keyof ItemInteractionData)[]).some( - (key) => action.data[key] !== prevState.item![key], - ) - ) { - // The item is already something else - return prevState; - } - return { ...prevState, item: null }; - - case 'updateAxis': - if (action.data.x === prevState.axis.x && action.data.y === prevState.axis.y) { - return prevState; - } - return { ...prevState, axis: action.data }; - - default: - return prevState; - } -}; - -function InteractionProvider(props: InteractionProviderProps) { +function InteractionProvider(props: React.PropsWithChildren) { const { children } = props; - const [data, dispatch] = React.useReducer(dataReducer, { - item: null, - axis: { x: null, y: null }, - useVoronoiInteraction: false, - }); - - const value = React.useMemo( - () => ({ - ...data, - dispatch, - }), - [data], - ); - return {children}; + const { contextValue } = useCharts(); + return {children}; } export { InteractionProvider }; diff --git a/packages/x-charts/src/context/InteractionSelectors.ts b/packages/x-charts/src/context/InteractionSelectors.ts new file mode 100644 index 000000000000..c911baeae216 --- /dev/null +++ b/packages/x-charts/src/context/InteractionSelectors.ts @@ -0,0 +1,46 @@ +import { ChartState } from '../internals/plugins/models'; +import { createSelector } from '../internals/plugins/utils/selectors'; + +function selectInteraction(state: ChartState) { + return state.interaction; +} + +export const selectorChartsInteractionItem = createSelector( + selectInteraction, + (interaction) => interaction.item, +); + +export const selectorChartsInteractionAxis = createSelector( + selectInteraction, + (interaction) => interaction.axis, +); + +export const selectorChartsInteractionXAxis = createSelector( + selectInteraction, + (interaction) => interaction.axis.x, +); + +export const selectorChartsInteractionYAxis = createSelector( + selectInteraction, + (interaction) => interaction.axis.y, +); + +export const selectorChartsInteractionItemIsDefined = createSelector( + selectorChartsInteractionItem, + (item) => item !== null, +); + +export const selectorChartsInteractionXAxisIsDefined = createSelector( + selectorChartsInteractionXAxis, + (x) => x !== null, +); + +export const selectorChartsInteractionYAxisIsDefined = createSelector( + selectorChartsInteractionYAxis, + (y) => y !== null, +); + +export const selectorChartsInteractionIsVoronoiEnabled = createSelector( + selectInteraction, + (interaction) => interaction.isVoronoiEnabled, +); diff --git a/packages/x-charts/src/hooks/useAxisEvents.ts b/packages/x-charts/src/hooks/useAxisEvents.ts index f3973f3fb393..2860ba00e32e 100644 --- a/packages/x-charts/src/hooks/useAxisEvents.ts +++ b/packages/x-charts/src/hooks/useAxisEvents.ts @@ -1,12 +1,12 @@ 'use client'; import * as React from 'react'; -import { InteractionContext } from '../context/InteractionProvider'; import { useCartesianContext } from '../context/CartesianProvider'; import { isBandScale } from '../internals/isBandScale'; import { AxisDefaultized } from '../models/axis'; import { getSVGPoint } from '../internals/getSVGPoint'; import { useSvgRef } from './useSvgRef'; import { useDrawingArea } from './useDrawingArea'; +import { useStore } from '../internals/useStore'; function getAsANumber(value: number | Date) { return value instanceof Date ? value.getTime() : value; @@ -15,7 +15,8 @@ export const useAxisEvents = (disableAxisListener: boolean) => { const svgRef = useSvgRef(); const drawingArea = useDrawingArea(); const { xAxis, yAxis, xAxisIds, yAxisIds } = useCartesianContext(); - const { dispatch } = React.useContext(InteractionContext); + + const store = useStore(disableAxisListener); const usedXAxis = xAxisIds[0]; const usedYAxis = yAxisIds[0]; @@ -29,7 +30,7 @@ export const useAxisEvents = (disableAxisListener: boolean) => { React.useEffect(() => { const element = svgRef.current; - if (element === null || disableAxisListener) { + if (element === null || disableAxisListener || !store) { return () => {}; } @@ -100,7 +101,11 @@ export const useAxisEvents = (disableAxisListener: boolean) => { x: -1, y: -1, }; - dispatch({ type: 'exitChart' }); + + store.update((prev) => ({ + ...prev, + interaction: { item: null, axis: { x: null, y: null } }, + })); }; const handleMove = (event: MouseEvent | TouchEvent) => { @@ -112,7 +117,10 @@ export const useAxisEvents = (disableAxisListener: boolean) => { if (!drawingArea.isPointInside(svgPoint, { targetElement: event.target as SVGElement })) { if (mousePosition.current.isInChart) { - dispatch({ type: 'exitChart' }); + store.update((prev) => ({ + ...prev, + interaction: { item: null, axis: { x: null, y: null } }, + })); mousePosition.current.isInChart = false; } return; @@ -121,7 +129,24 @@ export const useAxisEvents = (disableAxisListener: boolean) => { const newStateX = getNewAxisState(xAxis[usedXAxis], svgPoint.x); const newStateY = getNewAxisState(yAxis[usedYAxis], svgPoint.y); - dispatch({ type: 'updateAxis', data: { x: newStateX, y: newStateY } }); + store.update((prev) => ({ + ...prev, + interaction: { + ...prev.interaction, + axis: { + // A bit verbose, but prevent losing the x value if only y got modified. + ...prev.interaction.axis, + ...(prev.interaction.axis.x?.index !== newStateX?.index || + prev.interaction.axis.x?.value !== newStateX?.value + ? { x: newStateX } + : {}), + ...(prev.interaction.axis.y?.index !== newStateY?.index || + prev.interaction.axis.y?.value !== newStateY?.value + ? { y: newStateY } + : {}), + }, + }, + })); }; const handleDown = (event: PointerEvent) => { @@ -147,5 +172,5 @@ export const useAxisEvents = (disableAxisListener: boolean) => { element.removeEventListener('pointercancel', handleOut); element.removeEventListener('pointerleave', handleOut); }; - }, [svgRef, dispatch, usedYAxis, yAxis, usedXAxis, xAxis, disableAxisListener, drawingArea]); + }, [svgRef, store, usedYAxis, yAxis, usedXAxis, xAxis, disableAxisListener, drawingArea]); }; diff --git a/packages/x-charts/src/hooks/useInteractionItemProps.ts b/packages/x-charts/src/hooks/useInteractionItemProps.ts index 49a6e5e70c90..7474425fe650 100644 --- a/packages/x-charts/src/hooks/useInteractionItemProps.ts +++ b/packages/x-charts/src/hooks/useInteractionItemProps.ts @@ -1,11 +1,11 @@ 'use client'; import * as React from 'react'; -import { InteractionContext } from '../context/InteractionProvider'; import { SeriesItemIdentifier } from '../models'; import { useHighlighted } from '../context'; +import { useStore } from '../internals/useStore'; export const useInteractionItemProps = (skip?: boolean) => { - const { dispatch: dispatchInteraction } = React.useContext(InteractionContext); + const store = useStore(); const { setHighlighted, clearHighlighted } = useHighlighted(); if (skip) { @@ -18,10 +18,13 @@ export const useInteractionItemProps = (skip?: boolean) => { } }; const onPointerEnter = () => { - dispatchInteraction({ - type: 'enterItem', - data, - }); + store.update((prev) => ({ + ...prev, + interaction: { + ...prev.interaction, + item: data, + }, + })); setHighlighted({ seriesId: data.seriesId, dataIndex: data.dataIndex, @@ -29,7 +32,26 @@ export const useInteractionItemProps = (skip?: boolean) => { }; const onPointerLeave = (event: React.PointerEvent) => { event.currentTarget.releasePointerCapture(event.pointerId); - dispatchInteraction({ type: 'leaveItem', data }); + + store.update((prev) => { + const prevItem = prev.interaction.item; + if ( + prevItem === null || + Object.keys(data).some( + (key) => data[key as keyof typeof data] !== prevItem[key as keyof typeof prevItem], + ) + ) { + // The item is already something else, no need to clean it. + return prev; + } + return { + ...prev, + interaction: { + ...prev.interaction, + item: null, + }, + }; + }); clearHighlighted(); }; return { diff --git a/packages/x-charts/src/internals/plugins/models/index.ts b/packages/x-charts/src/internals/plugins/models/index.ts new file mode 100644 index 000000000000..ba5ae5bd5f5f --- /dev/null +++ b/packages/x-charts/src/internals/plugins/models/index.ts @@ -0,0 +1,39 @@ +import { ChartItemIdentifier, ChartSeriesType } from '../../../models/seriesType/config'; + +export type ItemInteractionData = ChartItemIdentifier; + +export type AxisInteractionData = { + x: null | { + value: number | Date | string; + // Set to -1 if no index. + index: number; + }; + y: null | { + value: number | Date | string; + // Set to -1 if no index. + index: number; + }; +}; + +type InteractionState = { + /** + * The item currently interacting. + */ + item: null | ItemInteractionData; + /** + * The x- and y-axes currently interacting. + */ + axis: AxisInteractionData; + /** + * Set to `true` when `VoronoiHandler` is active. + * Used to prevent collision with mouseEnter events. + */ + isVoronoiEnabled?: boolean; +}; + +export type ChartStateCacheKey = { id: number }; + +export type ChartState = { + interaction: InteractionState; + cacheKey: ChartStateCacheKey; +}; diff --git a/packages/x-charts/src/internals/plugins/utils/ChartStore.ts b/packages/x-charts/src/internals/plugins/utils/ChartStore.ts new file mode 100644 index 000000000000..f4cdbc2324d9 --- /dev/null +++ b/packages/x-charts/src/internals/plugins/utils/ChartStore.ts @@ -0,0 +1,35 @@ +import type { ChartState } from '../models'; // For now this is fixed. Will need to support generic if we add plugins + +type Listener = (value: T) => void; + +export type StoreUpdater = (prevState: ChartState) => ChartState; + +export class ChartStore { + public value: ChartState; + + private listeners: Set>; + + constructor(value: ChartState) { + this.value = value; + this.listeners = new Set(); + } + + public subscribe = (fn: Listener) => { + this.listeners.add(fn); + return () => { + this.listeners.delete(fn); + }; + }; + + public getSnapshot = () => { + return this.value; + }; + + public update = (updater: StoreUpdater) => { + const newState = updater(this.value); + if (newState !== this.value) { + this.value = newState; + this.listeners.forEach((l) => l(newState)); + } + }; +} diff --git a/packages/x-charts/src/internals/plugins/utils/selectors.ts b/packages/x-charts/src/internals/plugins/utils/selectors.ts new file mode 100644 index 000000000000..a91ac4a1a0fa --- /dev/null +++ b/packages/x-charts/src/internals/plugins/utils/selectors.ts @@ -0,0 +1,50 @@ +import { lruMemoize, createSelectorCreator, CreateSelectorFunction } from 'reselect'; +import { ChartState, ChartStateCacheKey } from '../models'; + +const reselectCreateSelector = createSelectorCreator({ + memoize: lruMemoize, + memoizeOptions: { + maxSize: 1, + equalityCheck: Object.is, + }, +}); + +const cache = new WeakMap< + ChartStateCacheKey, + Map, any> +>(); + +export type ChartsRootSelector = (state: ChartState) => ChartState[keyof ChartState]; + +export type ChartsSelector = (state: TState, args: TArgs) => TResult; + +/** + * Method wrapping reselect's createSelector to provide caching for chart instances. + * + */ +export const createSelector = ((...createSelectorArgs: any) => { + const selector: ChartsSelector = (state, selectorArgs) => { + const cacheKey = state.cacheKey; + + // If there is no cache for the current chart instance, create one. + let cacheForCurrentChartInstance = cache.get(cacheKey); + if (!cacheForCurrentChartInstance) { + cacheForCurrentChartInstance = new Map(); + cache.set(cacheKey, cacheForCurrentChartInstance); + } + + // If there is a cached selector, execute it. + const cachedSelector = cacheForCurrentChartInstance.get(createSelectorArgs); + if (cachedSelector) { + return cachedSelector(state, selectorArgs); + } + + // Otherwise, create a new selector and cache it and execute it. + const fn = reselectCreateSelector(...createSelectorArgs); + cacheForCurrentChartInstance.set(createSelectorArgs, fn); + + return fn(state, selectorArgs); + }; + + return selector; +}) as unknown as CreateSelectorFunction; diff --git a/packages/x-charts/src/internals/useCharts.ts b/packages/x-charts/src/internals/useCharts.ts new file mode 100644 index 000000000000..dddafa853ba8 --- /dev/null +++ b/packages/x-charts/src/internals/useCharts.ts @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { ChartStore } from './plugins/utils/ChartStore'; +import { ChartState } from './plugins/models'; + +let globalId = 0; + +export function useCharts() { + const storeRef = React.useRef(null); + if (storeRef.current == null) { + // eslint-disable-next-line react-compiler/react-compiler + globalId += 1; + const initialState: ChartState = { + interaction: { + item: null, + axis: { x: null, y: null }, + }, + cacheKey: { id: globalId }, + }; + storeRef.current = new ChartStore(initialState); + } + + const contextValue = React.useMemo(() => ({ store: storeRef.current as ChartStore }), []); + + return { contextValue }; +} diff --git a/packages/x-charts/src/internals/useSelector.ts b/packages/x-charts/src/internals/useSelector.ts new file mode 100644 index 000000000000..b4940ae5c8d3 --- /dev/null +++ b/packages/x-charts/src/internals/useSelector.ts @@ -0,0 +1,23 @@ +import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'; +import { ChartState } from './plugins/models'; +import { ChartsSelector } from './plugins/utils/selectors'; +import { ChartStore } from './plugins/utils/ChartStore'; + +const defaultCompare = Object.is; + +export const useSelector = ( + store: ChartStore, + selector: ChartsSelector, + args: TArgs = undefined as TArgs, + equals: (a: TValue, b: TValue) => boolean = defaultCompare, +): TValue => { + const selectorWithArgs = (state: ChartState) => selector(state, args); + + return useSyncExternalStoreWithSelector( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + selectorWithArgs, + equals, + ); +}; diff --git a/packages/x-charts/src/internals/useStore.ts b/packages/x-charts/src/internals/useStore.ts new file mode 100644 index 000000000000..17e6d3b6316f --- /dev/null +++ b/packages/x-charts/src/internals/useStore.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { ChartsContext } from '../context/InteractionProvider'; +import { ChartStore } from './plugins/utils/ChartStore'; + +export function useStore(skipError?: boolean): ChartStore { + const charts = React.useContext(ChartsContext); + + if (skipError) { + // TODO: Remove once store is used by all charts. + // TODO: Remove once store is used by all charts. + // This line is only for `useAxisEvents` which is in the surface of the Gauge. + // But the Gauge don't have store yet because it does not need the interaction provider. + // Will be fixed when every thing move to the store since every component will have access to it. + // @ts-ignore + return charts?.store; + } + if (!charts) { + throw new Error( + [ + 'MUI X: Could not find the charts context.', + 'It looks like you rendered your component outside of a ChartsContainer parent component.', + ].join('\n'), + ); + } + + return charts.store; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12dfa325544c..94b2fc4488a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -769,6 +769,12 @@ importers: react-dom: specifier: ^17.0.0 || ^18.0.0 version: 18.3.1(react@18.3.1) + reselect: + specifier: ^5.1.1 + version: 5.1.1 + use-sync-external-store: + specifier: ^1.0.0 + version: 1.2.2(react@18.3.1) devDependencies: '@mui/internal-test-utils': specifier: ^1.0.20 @@ -788,6 +794,9 @@ importers: '@types/prop-types': specifier: ^15.7.13 version: 15.7.13 + '@types/use-sync-external-store': + specifier: ^0.0.6 + version: 0.0.6 csstype: specifier: ^3.1.3 version: 3.1.3 diff --git a/scripts/x-charts-pro.exports.json b/scripts/x-charts-pro.exports.json index ca4a94f205c2..faae92e438c1 100644 --- a/scripts/x-charts-pro.exports.json +++ b/scripts/x-charts-pro.exports.json @@ -68,6 +68,7 @@ { "name": "ChartsAxisHighlightClassKey", "kind": "TypeAlias" }, { "name": "ChartsAxisHighlightPath", "kind": "Variable" }, { "name": "ChartsAxisHighlightProps", "kind": "TypeAlias" }, + { "name": "ChartsAxisHighlightType", "kind": "TypeAlias" }, { "name": "ChartsAxisProps", "kind": "Interface" }, { "name": "ChartsAxisTooltipContent", "kind": "Function" }, { "name": "ChartsClipPath", "kind": "Function" }, diff --git a/scripts/x-charts.exports.json b/scripts/x-charts.exports.json index ced333367ebc..eb38124c022f 100644 --- a/scripts/x-charts.exports.json +++ b/scripts/x-charts.exports.json @@ -66,6 +66,7 @@ { "name": "ChartsAxisHighlightClassKey", "kind": "TypeAlias" }, { "name": "ChartsAxisHighlightPath", "kind": "Variable" }, { "name": "ChartsAxisHighlightProps", "kind": "TypeAlias" }, + { "name": "ChartsAxisHighlightType", "kind": "TypeAlias" }, { "name": "ChartsAxisProps", "kind": "Interface" }, { "name": "ChartsAxisTooltipContent", "kind": "Function" }, { "name": "ChartsClipPath", "kind": "Function" },