Skip to content

[charts] Add radar labels #16839

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Mar 7, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/data/charts/radar/radar.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: React Radar chart
productId: x-charts
components: RadarChart, RadarGrid, RadarSeriesArea, RadarSeriesMarks, RadarSeriesPlot, RadarDataProvider
components: RadarChart, RadarGrid, RadarSeriesArea, RadarSeriesMarks, RadarSeriesPlot, RadarMetricLabels, RadarDataProvider
---

# Charts - Radar 🚧
Expand Down
4 changes: 4 additions & 0 deletions docs/data/chartsApiPages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ const chartsApiPages: MuiPage[] = [
pathname: '/x/api/charts/radar-grid',
title: 'RadarGrid',
},
{
pathname: '/x/api/charts/radar-metric-labels',
title: 'RadarMetricLabels',
},
{
pathname: '/x/api/charts/radar-series-area',
title: 'RadarSeriesArea',
Expand Down
2 changes: 1 addition & 1 deletion docs/pages/x/api/charts/radar-chart.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"radar": {
"type": {
"name": "shape",
"description": "{ max?: number, metrics: Array&lt;string&gt;<br>&#124;&nbsp;Array&lt;{ max?: number, min?: number, name: string }&gt;, startAngle?: number }"
"description": "{ labelFormatter?: func, max?: number, metrics: Array&lt;string&gt;<br>&#124;&nbsp;Array&lt;{ max?: number, min?: number, name: string }&gt;, startAngle?: number }"
},
"required": true
},
Expand Down
2 changes: 1 addition & 1 deletion docs/pages/x/api/charts/radar-data-provider.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"radar": {
"type": {
"name": "shape",
"description": "{ max?: number, metrics: Array&lt;string&gt;<br>&#124;&nbsp;Array&lt;{ max?: number, min?: number, name: string }&gt;, startAngle?: number }"
"description": "{ labelFormatter?: func, max?: number, metrics: Array&lt;string&gt;<br>&#124;&nbsp;Array&lt;{ max?: number, min?: number, name: string }&gt;, startAngle?: number }"
},
"required": true
},
Expand Down
23 changes: 23 additions & 0 deletions docs/pages/x/api/charts/radar-metric-labels.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from 'react';
import ApiPage from 'docs/src/modules/components/ApiPage';
import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations';
import jsonPageContent from './radar-metric-labels.json';

export default function Page(props) {
const { descriptions, pageContent } = props;
return <ApiPage descriptions={descriptions} pageContent={pageContent} />;
}

Page.getInitialProps = () => {
const req = require.context(
'docsx/translations/api-docs/charts/radar-metric-labels',
false,
/\.\/radar-metric-labels.*.json$/,
);
const descriptions = mapApiPageTranslations(req);

return {
descriptions,
pageContent: jsonPageContent,
};
};
15 changes: 15 additions & 0 deletions docs/pages/x/api/charts/radar-metric-labels.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"props": { "divisions": { "type": { "name": "number" }, "default": "5" } },
"name": "RadarMetricLabels",
"imports": [
"import { RadarMetricLabels } from '@mui/x-charts/RadarChart';",
"import { RadarMetricLabels } from '@mui/x-charts';",
"import { RadarMetricLabels } from '@mui/x-charts-pro';"
],
"classes": [],
"muiName": "MuiRadarMetricLabels",
"filename": "/packages/x-charts/src/RadarChart/RadarMetricLabels/RadarMetricLabels.tsx",
"inheritance": null,
"demos": "<ul><li><a href=\"/x/react-charts/radar/\">Charts - Radar 🚧</a></li></ul>",
"cssComponent": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"componentDescription": "",
"propDescriptions": {
"divisions": { "description": "The number of divisions in the radar grid." }
},
"classDescriptions": {}
}
44 changes: 44 additions & 0 deletions packages/x-charts/src/ChartsText/defaultTextPlacement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { clampAngle } from '../internals/clampAngle';
import { ChartsTextStyle } from '../internals/getWordsByLines';

/**
* Provide the text-anchor based on the angle between the text and the associated element.
* - 0 means the element is on top of the text, 180 bellow, and 90 on the right of the text.
* @param {number} angle The angle between the text and the element.
* @returns
*/
export function getDefaultTextAnchor(angle: number): ChartsTextStyle['textAnchor'] {
const adjustedAngle = clampAngle(angle);

if (adjustedAngle <= 30 || adjustedAngle >= 330) {
// +/-30° around 0°
return 'middle';
}

if (adjustedAngle <= 210 && adjustedAngle >= 150) {
// +/-30° around 180°
return 'middle';
}

if (adjustedAngle <= 180) {
return 'end';
}

return 'start';
}

export function getDefaultBaseline(angle: number): ChartsTextStyle['dominantBaseline'] {
const adjustedAngle = clampAngle(angle);

if (adjustedAngle <= 30 || adjustedAngle >= 330) {
// +/-60° around 0°
return 'hanging';
}

if (adjustedAngle <= 210 && adjustedAngle >= 150) {
// +/-60° around 180°
return 'auto';
}

return 'central';
}
50 changes: 11 additions & 39 deletions packages/x-charts/src/ChartsXAxis/ChartsXAxis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@ import useSlotProps from '@mui/utils/useSlotProps';
import composeClasses from '@mui/utils/composeClasses';
import { useThemeProps, useTheme, styled } from '@mui/material/styles';
import { useRtl } from '@mui/system/RtlProvider';
import { clampAngle } from '../internals/clampAngle';
import { getStringSize } from '../internals/domUtils';
import { useTicks, TickItemType } from '../hooks/useTicks';
import { AxisConfig, AxisDefaultized, ChartsXAxisProps, ScaleName } from '../models/axis';
import { getAxisUtilityClass } from '../ChartsAxis/axisClasses';
import { AxisRoot } from '../internals/components/AxisSharedComponents';
import { ChartsText, ChartsTextProps, ChartsTextStyle } from '../ChartsText';
import { ChartsText, ChartsTextProps } from '../ChartsText';
import { getMinXTranslation } from '../internals/geometry';
import { useMounted } from '../hooks/useMounted';
import { useDrawingArea } from '../hooks/useDrawingArea';
Expand All @@ -20,6 +19,7 @@ import { isInfinity } from '../internals/isInfinity';
import { isBandScale } from '../internals/isBandScale';
import { useChartContext } from '../context/ChartProvider/useChartContext';
import { useXAxes } from '../hooks/useAxis';
import { getDefaultBaseline, getDefaultTextAnchor } from '../ChartsText/defaultTextPlacement';
import { invertTextAnchor } from '../internals/invertTextAnchor';

const useUtilityClasses = (ownerState: AxisConfig<any, any, ChartsXAxisProps>) => {
Expand Down Expand Up @@ -111,37 +111,6 @@ function getVisibleLabels(
);
}

function getDefaultTextAnchor(angle: number): ChartsTextStyle['textAnchor'] {
const adjustedAngle = clampAngle(angle);

if (adjustedAngle === 0 || adjustedAngle === 180) {
return 'middle';
}

if (adjustedAngle < 180) {
return 'start';
}

return 'end';
}

function getDefaultBaseline(
angle: number,
position: 'top' | 'bottom' | 'none' | undefined,
): ChartsTextStyle['dominantBaseline'] {
const adjustedAngle = clampAngle(angle);

if (adjustedAngle === 0) {
return position === 'bottom' ? 'hanging' : 'auto';
}

if (adjustedAngle === 180) {
return position === 'bottom' ? 'auto' : 'hanging';
}

return 'central';
}

const XAxisRoot = styled(AxisRoot, {
name: 'MuiChartsXAxis',
slot: 'Root',
Expand Down Expand Up @@ -213,19 +182,22 @@ function ChartsXAxis(inProps: ChartsXAxisProps) {
const TickLabel = slots?.axisTickLabel ?? ChartsText;
const Label = slots?.axisLabel ?? ChartsText;

const defaultTextAnchor = getDefaultTextAnchor(tickLabelStyle?.angle ?? 0);
const shouldInvertTextAnchor = (isRtl && position !== 'top') || (!isRtl && position === 'top');
const defaultTextAnchor = getDefaultTextAnchor(
(position === 'bottom' ? 0 : 180) - (tickLabelStyle?.angle ?? 0),
);
const defaultDominantBaseline = getDefaultBaseline(
(position === 'bottom' ? 0 : 180) - (tickLabelStyle?.angle ?? 0),
);

const axisTickLabelProps = useSlotProps({
elementType: TickLabel,
externalSlotProps: slotProps?.axisTickLabel,
additionalProps: {
style: {
...theme.typography.caption,
fontSize: 12,
textAnchor: shouldInvertTextAnchor
? invertTextAnchor(defaultTextAnchor)
: defaultTextAnchor,
dominantBaseline: getDefaultBaseline(tickLabelStyle?.angle ?? 0, position),
textAnchor: isRtl ? invertTextAnchor(defaultTextAnchor) : defaultTextAnchor,
dominantBaseline: defaultDominantBaseline,
...tickLabelStyle,
},
} as Partial<ChartsTextProps>,
Expand Down
3 changes: 3 additions & 0 deletions packages/x-charts/src/RadarChart/RadarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ChartsWrapper } from '../internals/components/ChartsWrapper';
import { RadarGrid, RadarGridProps } from './RadarGrid/RadarGrid';
import { RadarDataProvider, RadarDataProviderProps } from './RadarDataProvider/RadarDataProvider';
import { RadarSeriesPlot } from './RadarSeriesPlot';
import { RadarMetricLabels } from './RadarMetricLabels';

export interface RadarChartSlots {}
export interface RadarChartSlotProps {}
Expand Down Expand Up @@ -55,6 +56,7 @@ const RadarChart = React.forwardRef(function RadarChart(
{!props.hideLegend && <ChartsLegend {...legendProps} />}
<ChartsSurface {...chartsSurfaceProps} ref={ref}>
<RadarGrid {...radarGrid} />
<RadarMetricLabels />
<RadarSeriesPlot />
<ChartsOverlay {...overlayProps} />
{children}
Expand Down Expand Up @@ -136,6 +138,7 @@ RadarChart.propTypes = {
* The configuration of the radar scales.
*/
radar: PropTypes.shape({
labelFormatter: PropTypes.func,
max: PropTypes.number,
metrics: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.string),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { MakeOptional } from '@mui/x-internals/types';
import { ChartContainerProps } from '../../ChartContainer';
import { RadarSeriesType } from '../../models/seriesType/radar';
import { AxisConfig, ChartsRadiusAxisProps, ChartsRotationAxisProps } from '../../models/axis';
import { PolarAxisConfig, ChartsRadiusAxisProps, ChartsRotationAxisProps } from '../../models/axis';
import { ChartDataProvider } from '../../ChartDataProvider';
import { defaultizeMargin } from '../../internals/defaultizeMargin';
import {
Expand Down Expand Up @@ -56,24 +56,22 @@ function RadarDataProvider(props: RadarDataProviderProps) {
...other
} = props;

const rotationAxes: AxisConfig<'point', number | string, ChartsRotationAxisProps>[] =
React.useMemo(
() =>
[
{
id: 'radar-rotation-axis',
scaleType: 'point',
data: radar.metrics.map((metric) =>
typeof metric === 'string' ? metric : metric.name,
),
startAngle: radar.startAngle,
endAngle: radar.startAngle !== undefined ? radar.startAngle + 360 : undefined,
},
] as const,
[radar],
);
const rotationAxes: PolarAxisConfig<'point', string, ChartsRotationAxisProps>[] = React.useMemo(
() => [
{
id: 'radar-rotation-axis',
scaleType: 'point',
data: radar.metrics.map((metric) => (typeof metric === 'string' ? metric : metric.name)),
startAngle: radar.startAngle,
endAngle: radar.startAngle !== undefined ? radar.startAngle + 360 : undefined,
valueFormatter: (name, { location }) =>
radar.labelFormatter?.(name, { location: location as 'tick' | 'tooltip' }) ?? name,
},
],
[radar],
);

const radiusAxis: AxisConfig<'linear', any, ChartsRadiusAxisProps>[] = React.useMemo(
const radiusAxis: PolarAxisConfig<'linear', any, ChartsRadiusAxisProps>[] = React.useMemo(
() =>
radar.metrics.map((m) => {
const { name, min = 0, max = radar.max } = typeof m === 'string' ? { name: m } : m;
Expand Down Expand Up @@ -182,6 +180,7 @@ RadarDataProvider.propTypes = {
* The configuration of the radar scales.
*/
radar: PropTypes.shape({
labelFormatter: PropTypes.func,
max: PropTypes.number,
metrics: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.string),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface MetricConfig {
max?: number;
}

export type RadarLabelFormatterContext = { location: 'tick' | 'tooltip' };

export interface RadarConfig {
/**
* The metrics shown by radar.
Expand All @@ -30,4 +32,11 @@ export interface RadarConfig {
* @default 0
*/
startAngle?: number;
/**
* Format metric names according to their placement.
* @param {string} name The matric name.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* @param {string} name The matric name.
* @param {string} name The metric name.

* @param {RadarLabelFormatterContext} context Indicate where the label will be used.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* @param {RadarLabelFormatterContext} context Indicate where the label will be used.
* @param {RadarLabelFormatterContext} context Indicates where the label will be used.

* @returns {string} The label to display.
*/
labelFormatter?: (name: string, context: RadarLabelFormatterContext) => string;
}
14 changes: 12 additions & 2 deletions packages/x-charts/src/RadarChart/RadarGrid/RadarGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { useTheme } from '@mui/material/styles';
import { useRadarGridData } from './useRadarGridData';

export interface RadarGridProps {
Expand All @@ -14,12 +15,20 @@ function RadarGrid(props: RadarGridProps) {
const { divisions = 5 } = props;
const { center, corners } = useRadarGridData();

const theme = useTheme();

const divisionRatio = Array.from({ length: divisions }, (_, index) => (index + 1) / divisions);

return (
<React.Fragment>
{corners.map(({ x, y }, i) => (
<path key={i} d={`M ${center.x} ${center.y} L ${x} ${y}`} stroke="black" />
<path
key={i}
d={`M ${center.x} ${center.y} L ${x} ${y}`}
stroke={(theme.vars || theme).palette.text.primary}
strokeWidth={1}
fill="none"
/>
))}
{divisionRatio.map((ratio) => (
<path
Expand All @@ -30,7 +39,8 @@ function RadarGrid(props: RadarGridProps) {
`${center.x * (1 - ratio) + ratio * x} ${center.y * (1 - ratio) + ratio * y}`,
)
.join(' L ')} Z`}
stroke="black"
stroke={(theme.vars || theme).palette.text.primary}
strokeWidth={1}
fill="none"
/>
))}
Expand Down
Loading