diff --git a/packages/x-charts/src/ChartsLegend/ContinuousColorLegend.tsx b/packages/x-charts/src/ChartsLegend/ContinuousColorLegend.tsx index 98c1cb1ed7c09..1bb3155185a57 100644 --- a/packages/x-charts/src/ChartsLegend/ContinuousColorLegend.tsx +++ b/packages/x-charts/src/ChartsLegend/ContinuousColorLegend.tsx @@ -16,7 +16,7 @@ import { ContinuousColorLegendClasses, useUtilityClasses, } from './continuousColorLegendClasses'; -import { useChartGradientObjectBound } from '../internals/components/ChartsAxesGradients'; +import { useChartGradientIdObjectBoundBuilder } from '../hooks/useChartGradientId'; type LabelFormatter = (params: { value: number | Date; formattedValue: string }) => string; @@ -210,7 +210,7 @@ const ContinuousColorLegend = consumeThemeProps( ...other } = props; - const generateGradientId = useChartGradientObjectBound(); + const generateGradientId = useChartGradientIdObjectBoundBuilder(); const axisItem = useAxis({ axisDirection, axisId }); const colorMap = axisItem?.colorMap; @@ -261,7 +261,7 @@ const ContinuousColorLegend = consumeThemeProps( rotate={rotateGradient} reverse={reverse} thickness={thickness} - gradientId={gradientId ?? generateGradientId(axisItem.id, axisDirection!)} + gradientId={gradientId ?? generateGradientId(axisItem.id)} /> {reverse ? minComponent : maxComponent} diff --git a/packages/x-charts/src/LineChart/AreaPlot.tsx b/packages/x-charts/src/LineChart/AreaPlot.tsx index 9dd04d10888f2..cb1e500013376 100644 --- a/packages/x-charts/src/LineChart/AreaPlot.tsx +++ b/packages/x-charts/src/LineChart/AreaPlot.tsx @@ -14,10 +14,9 @@ import { getValueToPositionMapper } from '../hooks/useScale'; import getCurveFactory from '../internals/getCurve'; import { DEFAULT_X_AXIS_KEY } from '../constants'; import { LineItemIdentifier } from '../models/seriesType/line'; -import { useChartGradient } from '../internals/components/ChartsAxesGradients'; import { useLineSeries } from '../hooks/useSeries'; -import { AxisId } from '../models/axis'; import { useSkipAnimation } from '../context/AnimationProvider'; +import { useChartGradientIdBuilder } from '../hooks/useChartGradientId'; import { useXAxes, useYAxes } from '../hooks/useAxis'; export interface AreaPlotSlots extends AreaElementSlots {} @@ -52,6 +51,7 @@ const useAggregatedData = () => { const seriesData = useLineSeries(); const { xAxis, xAxisIds } = useXAxes(); const { yAxis, yAxisIds } = useYAxes(); + const getGradientId = useChartGradientIdBuilder(); // This memo prevents odd line chart behavior when hydrating. const allData = React.useMemo(() => { @@ -81,9 +81,9 @@ const useAggregatedData = () => { const yScale = yAxis[yAxisId].scale; const xData = xAxis[xAxisId].data; - const gradientUsed: [AxisId, 'x' | 'y'] | undefined = - (yAxis[yAxisId].colorScale && [yAxisId, 'y']) || - (xAxis[xAxisId].colorScale && [xAxisId, 'x']) || + const gradientId: string | undefined = + (yAxis[yAxisId].colorScale && getGradientId(yAxisId)) || + (xAxis[xAxisId].colorScale && getGradientId(xAxisId)) || undefined; if (process.env.NODE_ENV !== 'production') { @@ -137,13 +137,13 @@ const useAggregatedData = () => { const d = areaPath.curve(curve)(d3Data) || ''; return { ...series[seriesId], - gradientUsed, + gradientId, d, seriesId, }; }); }); - }, [seriesData, xAxisIds, yAxisIds, xAxis, yAxis]); + }, [seriesData, xAxisIds, yAxisIds, xAxis, yAxis, getGradientId]); return allData; }; @@ -163,20 +163,19 @@ function AreaPlot(props: AreaPlotProps) { const { slots, slotProps, onItemClick, skipAnimation: inSkipAnimation, ...other } = props; const skipAnimation = useSkipAnimation(inSkipAnimation); - const getGradientId = useChartGradient(); const completedData = useAggregatedData(); return ( {completedData.map( - ({ d, seriesId, color, area, gradientUsed }) => + ({ d, seriesId, color, area, gradientId }) => !!area && ( onItemClick(event, { type: 'line', seriesId }))} diff --git a/packages/x-charts/src/LineChart/LinePlot.tsx b/packages/x-charts/src/LineChart/LinePlot.tsx index 1cb23b7c92dd2..27b0c1794df4d 100644 --- a/packages/x-charts/src/LineChart/LinePlot.tsx +++ b/packages/x-charts/src/LineChart/LinePlot.tsx @@ -14,10 +14,9 @@ import { getValueToPositionMapper } from '../hooks/useScale'; import getCurveFactory from '../internals/getCurve'; import { DEFAULT_X_AXIS_KEY } from '../constants'; import { LineItemIdentifier } from '../models/seriesType/line'; -import { useChartGradient } from '../internals/components/ChartsAxesGradients'; import { useLineSeries } from '../hooks/useSeries'; -import { AxisId } from '../models/axis'; import { useSkipAnimation } from '../context/AnimationProvider'; +import { useChartGradientIdBuilder } from '../hooks/useChartGradientId'; import { useXAxes, useYAxes } from '../hooks'; export interface LinePlotSlots extends LineElementSlots {} @@ -53,6 +52,7 @@ const useAggregatedData = () => { const { xAxis, xAxisIds } = useXAxes(); const { yAxis, yAxisIds } = useYAxes(); + const getGradientId = useChartGradientIdBuilder(); // This memo prevents odd line chart behavior when hydrating. const allData = React.useMemo(() => { @@ -78,9 +78,9 @@ const useAggregatedData = () => { const yScale = yAxis[yAxisId].scale; const xData = xAxis[xAxisId].data; - const gradientUsed: [AxisId, 'x' | 'y'] | undefined = - (yAxis[yAxisId].colorScale && [yAxisId, 'y']) || - (xAxis[xAxisId].colorScale && [xAxisId, 'x']) || + const gradientId: string | undefined = + (yAxis[yAxisId].colorScale && getGradientId(yAxisId)) || + (xAxis[xAxisId].colorScale && getGradientId(xAxisId)) || undefined; if (process.env.NODE_ENV !== 'production') { @@ -116,13 +116,13 @@ const useAggregatedData = () => { const d = linePath.curve(getCurveFactory(series[seriesId].curve))(d3Data) || ''; return { ...series[seriesId], - gradientUsed, + gradientId, d, seriesId, }; }); }); - }, [seriesData, xAxisIds, yAxisIds, xAxis, yAxis]); + }, [seriesData, xAxisIds, yAxisIds, xAxis, yAxis, getGradientId]); return allData; }; @@ -141,18 +141,17 @@ function LinePlot(props: LinePlotProps) { const { slots, slotProps, skipAnimation: inSkipAnimation, onItemClick, ...other } = props; const skipAnimation = useSkipAnimation(inSkipAnimation); - const getGradientId = useChartGradient(); const completedData = useAggregatedData(); return ( - {completedData.map(({ d, seriesId, color, gradientUsed }) => { + {completedData.map(({ d, seriesId, color, gradientId }) => { return ( { const errorMessage2 = 'It looks like you rendered your component outside of a ChartsContainer parent component.'; const errorMessage3 = 'The above error occurred in the component:'; - const expextedError = + const expectedError = reactMajor < 19 ? [errorMessage1, errorMessage2, errorMessage3] : `${errorMessage1}\n${errorMessage2}`; @@ -49,7 +49,7 @@ describe('useSkipAnimation', () => { , ), - ).toErrorDev(expextedError); + ).toErrorDev(expectedError); expect((errorRef.current as any).errors).to.have.length(1); expect((errorRef.current as any).errors[0].toString()).to.include( diff --git a/packages/x-charts/src/context/HighlightedProvider/useHighlighted.test.tsx b/packages/x-charts/src/context/HighlightedProvider/useHighlighted.test.tsx index aec49d7297c12..b16a625f62b1b 100644 --- a/packages/x-charts/src/context/HighlightedProvider/useHighlighted.test.tsx +++ b/packages/x-charts/src/context/HighlightedProvider/useHighlighted.test.tsx @@ -23,7 +23,7 @@ describe('useHighlighted', () => { const errorMessage2 = 'It looks like you rendered your component outside of a ChartsContainer parent component.'; const errorMessage3 = 'The above error occurred in the component:'; - const expextedError = + const expectedError = reactMajor < 19 ? [errorMessage1, errorMessage2, errorMessage3] : `${errorMessage1}\n${errorMessage2}`; @@ -34,7 +34,7 @@ describe('useHighlighted', () => { , ), - ).toErrorDev(expextedError); + ).toErrorDev(expectedError); expect((errorRef.current as any).errors).to.have.length(1); expect((errorRef.current as any).errors[0].toString()).to.include( diff --git a/packages/x-charts/src/hooks/index.ts b/packages/x-charts/src/hooks/index.ts index 9ba0336f9586c..23b19a9b0780e 100644 --- a/packages/x-charts/src/hooks/index.ts +++ b/packages/x-charts/src/hooks/index.ts @@ -12,3 +12,4 @@ export { useScatterSeries as unstable_useScatterSeries, } from './useSeries'; export * from './useLegend'; +export { useChartGradientId, useChartGradientIdObjectBound } from './useChartGradientId'; diff --git a/packages/x-charts/src/hooks/useChartGradientId.test.tsx b/packages/x-charts/src/hooks/useChartGradientId.test.tsx new file mode 100644 index 0000000000000..da6616bbbff19 --- /dev/null +++ b/packages/x-charts/src/hooks/useChartGradientId.test.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, screen } from '@mui/internal-test-utils'; +import { useChartGradientId, useChartGradientIdObjectBound } from './useChartGradientId'; +import { ChartDataProvider } from '../context'; + +function UseGradientId() { + const id = useChartGradientId('test-id'); + return
{id}
; +} + +function UseGradientIdObjectBound() { + const id = useChartGradientIdObjectBound('test-id'); + return
{id}
; +} + +describe('useChartGradientId', () => { + const { render } = createRenderer(); + + it('should properly generate a correct id', () => { + render( + + + , + ); + + expect(screen.getByText(/:\w+:-gradient-test-id/)).toBeVisible(); + }); + + describe('useChartGradientIdObjectBound', () => { + it('should properly generate a correct id', () => { + render( + + + , + ); + + expect(screen.getByText(/:\w+:-gradient-test-id-object-bound/)).toBeVisible(); + }); + }); +}); diff --git a/packages/x-charts/src/hooks/useChartGradientId.tsx b/packages/x-charts/src/hooks/useChartGradientId.tsx new file mode 100644 index 0000000000000..d69eef90322a1 --- /dev/null +++ b/packages/x-charts/src/hooks/useChartGradientId.tsx @@ -0,0 +1,51 @@ +'use client'; +import * as React from 'react'; +import { useChartId } from './useChartId'; +import { AxisId } from '../models/axis'; + +/** + * Returns a function that generates a gradient id for the given axis id. + */ +export function useChartGradientIdBuilder() { + const chartId = useChartId(); + return React.useCallback((axisId: AxisId) => `${chartId}-gradient-${axisId}`, [chartId]); +} + +/** + * Returns a function that generates a gradient id for the given axis id. + */ +export function useChartGradientIdObjectBoundBuilder() { + const chartId = useChartId(); + return React.useCallback( + (axisId: AxisId) => `${chartId}-gradient-${axisId}-object-bound`, + [chartId], + ); +} + +/** + * Returns a gradient id for the given axis id. + * + * Can be useful when reusing the same gradient on custom components. + * + * For a gradient that respects the coordinates of the object on which it is applied, use `useChartGradientIdObjectBound` instead. + * + * @param axisId the axis id + * @returns the gradient id + */ +export function useChartGradientId(axisId: AxisId) { + return useChartGradientIdBuilder()(axisId); +} + +/** + * Returns a gradient id for the given axis id. + * + * Can be useful when reusing the same gradient on custom components. + * + * This gradient differs from `useChartGradientId` in that it respects the coordinates of the object on which it is applied. + * + * @param axisId the axis id + * @returns the gradient id + */ +export function useChartGradientIdObjectBound(axisId: AxisId) { + return useChartGradientIdObjectBoundBuilder()(axisId); +} diff --git a/packages/x-charts/src/hooks/useSeries.test.tsx b/packages/x-charts/src/hooks/useSeries.test.tsx index 3a2030f25a8aa..056cf53870f5a 100644 --- a/packages/x-charts/src/hooks/useSeries.test.tsx +++ b/packages/x-charts/src/hooks/useSeries.test.tsx @@ -22,7 +22,7 @@ describe('useSeries', () => { const errorMessage2 = 'It looks like you rendered your component outside of a ChartDataProvider.'; const errorMessage3 = 'The above error occurred in the component:'; - const expextedError = + const expectedError = reactMajor < 19 ? [errorMessage1, errorMessage2, errorMessage3] : [errorMessage1, errorMessage2].join('\n'); @@ -33,7 +33,7 @@ describe('useSeries', () => { , ), - ).toErrorDev(expextedError); + ).toErrorDev(expectedError); expect((errorRef.current as any).errors).to.have.length(1); expect((errorRef.current as any).errors[0].toString()).to.include(errorMessage1); diff --git a/packages/x-charts/src/hooks/useSvgRef.test.tsx b/packages/x-charts/src/hooks/useSvgRef.test.tsx index 0aab7124e4bc2..6b7d5ce291f0f 100644 --- a/packages/x-charts/src/hooks/useSvgRef.test.tsx +++ b/packages/x-charts/src/hooks/useSvgRef.test.tsx @@ -27,7 +27,7 @@ describe('useSvgRef', () => { 'It looks like you rendered your component outside of a ChartDataProvider.', 'The above error occurred in the component', ]; - const expextedError = reactMajor < 19 ? errorMessages : errorMessages.slice(0, 2).join('\n'); + const expectedError = reactMajor < 19 ? errorMessages : errorMessages.slice(0, 2).join('\n'); expect(() => render( @@ -35,7 +35,7 @@ describe('useSvgRef', () => { , ), - ).toErrorDev(expextedError); + ).toErrorDev(expectedError); expect((errorRef.current as any).errors).to.have.length(1); expect((errorRef.current as any).errors[0].toString()).to.include( diff --git a/packages/x-charts/src/internals/components/ChartsAxesGradients/ChartsAxesGradients.tsx b/packages/x-charts/src/internals/components/ChartsAxesGradients/ChartsAxesGradients.tsx index 1af30129e62c5..972c8872f1007 100644 --- a/packages/x-charts/src/internals/components/ChartsAxesGradients/ChartsAxesGradients.tsx +++ b/packages/x-charts/src/internals/components/ChartsAxesGradients/ChartsAxesGradients.tsx @@ -1,28 +1,13 @@ import * as React from 'react'; -import { useChartId, useDrawingArea, useXAxes, useYAxes } from '../../../hooks'; +import { useDrawingArea, useXAxes, useYAxes } from '../../../hooks'; import ChartsPiecewiseGradient from './ChartsPiecewiseGradient'; import ChartsContinuousGradient from './ChartsContinuousGradient'; -import { AxisId } from '../../../models/axis'; import ChartsContinuousGradientObjectBound from './ChartsContinuousGradientObjectBound'; import { useZAxis } from '../../../hooks/useZAxis'; - -export function useChartGradient() { - const chartId = useChartId(); - return React.useCallback( - (axisId: AxisId, direction: 'x' | 'y' | 'z') => `${chartId}-gradient-${direction}-${axisId}`, - [chartId], - ); -} - -// TODO: make public? -export function useChartGradientObjectBound() { - const chartId = useChartId(); - return React.useCallback( - (axisId: AxisId, direction: 'x' | 'y' | 'z') => - `${chartId}-gradient-${direction}-${axisId}-object-bound`, - [chartId], - ); -} +import { + useChartGradientIdBuilder, + useChartGradientIdObjectBoundBuilder, +} from '../../../hooks/useChartGradientId'; export function ChartsAxesGradients() { const { top, height, bottom, left, width, right } = useDrawingArea(); @@ -30,8 +15,8 @@ export function ChartsAxesGradients() { const svgHeight = top + height + bottom; const svgWidth = left + width + right; - const getGradientId = useChartGradient(); - const getObjectBoundGradientId = useChartGradientObjectBound(); + const getGradientId = useChartGradientIdBuilder(); + const getObjectBoundGradientId = useChartGradientIdObjectBoundBuilder(); const { xAxis, xAxisIds } = useXAxes(); const { yAxis, yAxisIds } = useYAxes(); @@ -52,8 +37,8 @@ export function ChartsAxesGradients() { return ( {filteredYAxisIds.map((axisId) => { - const gradientId = getGradientId(axisId, 'y'); - const objectBoundGradientId = getObjectBoundGradientId(axisId, 'y'); + const gradientId = getGradientId(axisId); + const objectBoundGradientId = getObjectBoundGradientId(axisId); const { colorMap, scale, colorScale, reverse } = yAxis[axisId]; if (colorMap?.type === 'piecewise') { return ( @@ -92,8 +77,8 @@ export function ChartsAxesGradients() { return null; })} {filteredXAxisIds.map((axisId) => { - const gradientId = getGradientId(axisId, 'x'); - const objectBoundGradientId = getObjectBoundGradientId(axisId, 'x'); + const gradientId = getGradientId(axisId); + const objectBoundGradientId = getObjectBoundGradientId(axisId); const { colorMap, scale, reverse, colorScale } = xAxis[axisId]; if (colorMap?.type === 'piecewise') { @@ -133,7 +118,7 @@ export function ChartsAxesGradients() { return null; })} {filteredZAxisIds.map((axisId) => { - const objectBoundGradientId = getObjectBoundGradientId(axisId, 'z'); + const objectBoundGradientId = getObjectBoundGradientId(axisId); const { colorMap, colorScale } = zAxis[axisId]; if (colorMap?.type === 'continuous') { return ( diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.test.tsx b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.test.tsx new file mode 100644 index 0000000000000..b30fb399143f6 --- /dev/null +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.test.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, reactMajor } from '@mui/internal-test-utils'; +import { testSkipIf, isJSDOM } from 'test/utils/skipIf'; +import { BarChart } from '@mui/x-charts/BarChart'; + +describe('useChartCartesianAxis', () => { + const { render } = createRenderer(); + + // can't catch render errors in the browser for unknown reason + // tried try-catch + error boundary + window onError preventDefault + testSkipIf(!isJSDOM)('should throw an error when axis have duplicate ids', () => { + const errorMessage1 = 'MUI X: The following axis ids are duplicated: qwerty.'; + const errorMessage2 = 'Please make sure that each axis has a unique id.'; + const expectedError = + reactMajor < 19 ? [errorMessage1, errorMessage2] : `${errorMessage1}\n${errorMessage2}`; + + expect(() => + render( + , + ), + ).toErrorDev(expectedError); + }); + + // can't catch render errors in the browser for unknown reason + // tried try-catch + error boundary + window onError preventDefault + testSkipIf(!isJSDOM)( + 'should throw an error when axis have duplicate ids across different directions (x,y)', + () => { + const errorMessage1 = 'MUI X: The following axis ids are duplicated: qwerty.'; + const errorMessage2 = 'Please make sure that each axis has a unique id.'; + const expectedError = + reactMajor < 19 ? [errorMessage1, errorMessage2] : `${errorMessage1}\n${errorMessage2}`; + + expect(() => + render( + , + ), + ).toErrorDev(expectedError); + }, + ); +}); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts index 6e5e579723024..8919d49bb5c6c 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts @@ -1,5 +1,6 @@ 'use client'; import * as React from 'react'; +import { warnOnce } from '@mui/x-internals/warning'; import { ChartPlugin } from '../../models'; import { ChartSeriesType } from '../../../../models/seriesType/config'; import { UseChartCartesianAxisSignature } from './useChartCartesianAxis.types'; @@ -14,6 +15,22 @@ export const useChartCartesianAxis: ChartPlugin< > = ({ params, store, seriesConfig }) => { const { xAxis, yAxis, dataset } = params; + if (process.env.NODE_ENV !== 'production') { + const ids = [...(xAxis ?? []), ...(yAxis ?? [])] + .filter((axis) => axis.id) + .map((axis) => axis.id); + const duplicates = new Set(ids.filter((id, index) => ids.indexOf(id) !== index)); + if (duplicates.size > 0) { + warnOnce( + [ + `MUI X: The following axis ids are duplicated: ${Array.from(duplicates).join(', ')}.`, + `Please make sure that each axis has a unique id.`, + ].join('\n'), + 'error', + ); + } + } + const drawingArea = useSelector(store, selectorChartDrawingArea); const formattedSeries = useSelector(store, selectorChartSeriesState); diff --git a/scripts/x-charts-pro.exports.json b/scripts/x-charts-pro.exports.json index 861893ba3bb69..922530b85e437 100644 --- a/scripts/x-charts-pro.exports.json +++ b/scripts/x-charts-pro.exports.json @@ -306,6 +306,8 @@ { "name": "unstable_useSeries", "kind": "Function" }, { "name": "useAxisTooltip", "kind": "Function" }, { "name": "UseAxisTooltipReturnValue", "kind": "Interface" }, + { "name": "useChartGradientId", "kind": "Function" }, + { "name": "useChartGradientIdObjectBound", "kind": "Function" }, { "name": "useChartId", "kind": "Function" }, { "name": "useDrawingArea", "kind": "Function" }, { "name": "useGaugeState", "kind": "Function" }, diff --git a/scripts/x-charts.exports.json b/scripts/x-charts.exports.json index 2749f317484ce..28e649df3dd20 100644 --- a/scripts/x-charts.exports.json +++ b/scripts/x-charts.exports.json @@ -299,6 +299,8 @@ { "name": "unstable_useSeries", "kind": "Function" }, { "name": "useAxisTooltip", "kind": "Function" }, { "name": "UseAxisTooltipReturnValue", "kind": "Interface" }, + { "name": "useChartGradientId", "kind": "Function" }, + { "name": "useChartGradientIdObjectBound", "kind": "Function" }, { "name": "useChartId", "kind": "Function" }, { "name": "useDrawingArea", "kind": "Function" }, { "name": "useGaugeState", "kind": "Function" },