Skip to content

Commit

Permalink
Sunburst chart (#4037)
Browse files Browse the repository at this point in the history
## Description

Adds an entirely new chart type, the Sunburst Chart - resolves #3842. 

Note: this is a WIP. I've added a Typescript component for a responsive
sunburst chart, a story to demonstrate the functionality, and mock data.
I still need to add event handlers, and a tooltip - I wanted to give
this a chance to be reviewed before changes grow too large, and I'm
looking for guidance on the best way to integrate these elements to
match the style of existing components.

## Related Issue

#3842 

## Motivation and Context

This adds a popular and useful new hierarchical chart type that is not
addressed by any existing component in the library.

## How Has This Been Tested?

I have not added tests yet. 

## Screenshots (if appropriate):

Current state of the chart - responsive dimensions and labels, still
needs interactivity.

<img width="660" alt="Screenshot 2024-01-03 at 6 59 24 PM"
src="https://github.com/recharts/recharts/assets/59377951/6fbad951-f864-42c0-9c55-d70a4bf76b6e">

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [X] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)

## Checklist:

<!--- Go over all the following points, and put an `x` in all the boxes
that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're
here to help! -->

- [X] My code follows the code style of this project.
- [X] My change requires a change to the documentation.
- [X] I have updated the documentation accordingly.
- [X] I have added tests to cover my changes.
- [X] I have added a storybook story or extended an existing story to
show my changes
- [X] All new and existing tests passed.
  • Loading branch information
csdiehl committed Jan 10, 2024
1 parent 2e5f004 commit b0c71e3
Show file tree
Hide file tree
Showing 7 changed files with 461 additions and 1 deletion.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Expand Up @@ -105,7 +105,6 @@
"@testing-library/react": "^12.1.5",
"@testing-library/user-event": "^14.3.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time-format": "^4.0.0",
"@types/lodash": "^4.14.144",
Expand Down
215 changes: 215 additions & 0 deletions src/chart/SunburstChart.tsx
@@ -0,0 +1,215 @@
import React, { useState } from 'react';
import { scaleLinear } from 'victory-vendor/d3-scale';
import clsx from 'clsx';
import { findChildByType } from '../util/ReactUtils';
import { Surface } from '../container/Surface';
import { Layer } from '../container/Layer';
import { Sector } from '../shape/Sector';
import { Text } from '../component/Text';
import { polarToCartesian } from '../util/PolarUtils';
import { Tooltip } from '../component/Tooltip';

export interface SunburstData {
[key: string]: any;
name: string;
value?: number;
fill?: string;
children?: SunburstData[];
}

interface TextOptions {
fontFamily?: string;
fontWeight?: string;
paintOrder?: string;
stroke?: string;
fill?: string;
fontSize?: string;
pointerEvents?: string;
}

export interface SunburstChartProps {
className?: string;
data?: SunburstData;
width?: number;
height?: number;
padding?: number;
dataKey?: string;
/* Padding between each hierarchical level. */
ringPadding?: number;
/* The radius of the inner circle at the center of the chart. */
innerRadius?: number;
/* Outermost edge of the chart. Defaults to the max possible radius for a circle inscribed in the chart container */
outerRadius?: number;
/** The abscissa of pole in polar coordinate */
cx?: number;
/** The ordinate of pole in polar coordinate */
cy?: number;
/** Angle in degrees from which the chart should start. */
startAngle?: number;
/** Angle, in degrees, at which the chart should end. Can be used to generate partial sunbursts. */
endAngle?: number;
children?: React.ReactNode;
fill?: string;
stroke?: string;
/* an object with svg text options to control the appearance of the chart labels. */
textOptions?: TextOptions;

onMouseEnter?: (node: SunburstData, e: React.MouseEvent) => void;

onMouseLeave?: (node: SunburstData, e: React.MouseEvent) => void;

onClick?: (node: SunburstData) => void;
}

interface DrawArcOptions {
radius: number;
innerR: number;
initialAngle: number;
childColor?: string;
}

const defaultTextProps = {
fontWeight: 'bold',
paintOrder: 'stroke fill',
fontSize: '.75rem',
stroke: '#FFF',
fill: 'black',
pointerEvents: 'none',
};

function getMaxDepthOf(node: SunburstData): number {
if (!node.children || node.children.length === 0) return 1;

// Calculate depth for each child and find the maximum
const childDepths = node.children.map(d => getMaxDepthOf(d));
return 1 + Math.max(...childDepths);
}

export const SunburstChart = ({
className,
data,
children,
width,
height,
padding = 2,
dataKey = 'value',
ringPadding = 2,
innerRadius = 50,
fill = '#333',
stroke = '#FFF',
textOptions = defaultTextProps,
outerRadius = Math.min(width, height) / 2,
cx = width / 2,
cy = height / 2,
startAngle = 0,
endAngle = 360,
onClick,
onMouseEnter,
onMouseLeave,
}: SunburstChartProps) => {
const [isTooltipActive, setIsTooltipActive] = useState(false);
const [activeNode, setActiveNode] = useState<SunburstData | null>(null);

const rScale = scaleLinear([0, data[dataKey]], [0, endAngle]);
const treeDepth = getMaxDepthOf(data);
const thickness = (outerRadius - innerRadius) / treeDepth;

const sectors: React.ReactNode[] = [];
const positions = new Map([]);

// event handlers
function handleMouseEnter(node: SunburstData, e: React.MouseEvent) {
if (onMouseEnter) onMouseEnter(node, e);
setActiveNode(node);

setIsTooltipActive(true);
}

function handleMouseLeave(node: SunburstData, e: React.MouseEvent) {
if (onMouseLeave) onMouseLeave(node, e);
setActiveNode(null);
setIsTooltipActive(false);
}

function handleClick(node: SunburstData) {
if (onClick) onClick(node);
}

// recursively add nodes for each data point and its children
function drawArcs(childNodes: SunburstData[] | undefined, options: DrawArcOptions): any {
const { radius, innerR, initialAngle, childColor } = options;

let currentAngle = initialAngle;

if (!childNodes) return; // base case: no children of this node

childNodes.forEach(d => {
const arcLength = rScale(d[dataKey]);
const start = currentAngle;
// color priority - if there's a color on the individual point use that, otherwise use parent color or default
const fillColor = d?.fill ?? childColor ?? fill;
const { x: textX, y: textY } = polarToCartesian(0, 0, innerR + radius / 2, -(start + arcLength - arcLength / 2));
currentAngle += arcLength;
sectors.push(
<g aria-label={d.name} tabIndex={0}>
<Sector
onClick={() => handleClick(d)}
onMouseEnter={e => handleMouseEnter(d, e)}
onMouseLeave={e => handleMouseLeave(d, e)}
fill={fillColor}
stroke={stroke}
strokeWidth={padding}
startAngle={start}
endAngle={start + arcLength}
innerRadius={innerR}
outerRadius={innerR + radius}
cx={cx}
cy={cy}
/>
<Text {...textOptions} alignmentBaseline="middle" textAnchor="middle" x={textX + cx} y={cy - textY}>
{d[dataKey]}
</Text>
</g>,
);

const { x: tooltipX, y: tooltipY } = polarToCartesian(cx, cy, innerR + radius / 2, start);
positions.set(d.name, { x: tooltipX, y: tooltipY });

return drawArcs(d.children, {
radius,
innerR: innerR + radius + ringPadding,
initialAngle: start,
childColor: fillColor,
});
});
}

drawArcs(data.children, { radius: thickness, innerR: innerRadius, initialAngle: startAngle });

const layerClass = clsx('recharts-sunburst', className);

function renderTooltip() {
const tooltipComponent = findChildByType([children], Tooltip);

if (!tooltipComponent || !activeNode) return null;

const viewBox = { x: 0, y: 0, width, height };

return React.cloneElement(tooltipComponent as React.DetailedReactHTMLElement<any, HTMLElement>, {
viewBox,
coordinate: positions.get(activeNode.name),
payload: [activeNode],
active: isTooltipActive,
});
}

return (
<div className={clsx('recharts-wrapper', className)} style={{ position: 'relative', width, height }} role="region">
<Surface width={width} height={height}>
{children}
<Layer className={layerClass}>{sectors}</Layer>
</Surface>
{renderTooltip()}
</div>
);
};
2 changes: 2 additions & 0 deletions src/index.ts
@@ -1,6 +1,7 @@
// "export type" declarations on separate lines are in use
// to workaround babel issue(s) 11465 12578
//

// see https://github.com/babel/babel/issues/11464#issuecomment-617606898
export { Surface } from './container/Surface';
export type { Props as SurfaceProps } from './container/Surface';
Expand Down Expand Up @@ -96,6 +97,7 @@ export { ScatterChart } from './chart/ScatterChart';
export { AreaChart } from './chart/AreaChart';
export { RadialBarChart } from './chart/RadialBarChart';
export { ComposedChart } from './chart/ComposedChart';
export { SunburstChart } from './chart/SunburstChart';

export { Funnel } from './numberAxis/Funnel';
export type { FunnelProps } from './numberAxis/Funnel';
Expand Down
19 changes: 19 additions & 0 deletions storybook/stories/API/chart/SunburstChart.mdx
@@ -0,0 +1,19 @@
import { Canvas, Meta, ArgTypes } from '@storybook/blocks';
import * as SunburstStories from './SunburstChart.stories';

# Sunburst

The sunburst is a hierarchical chart, similar to a treemap, plotted in polar coordinates.
Sunburst charts effectively convey the hierarchical relationships and proportions within each level.
It is easy to see all the middle layers in the hierarchy, which might get lost in other visualizations.
For some datasets, the radial layout may be more visually appealing and intuitive than a traditional treemap.

## Parent Component

The Sunburst can be used within: `<ResponsiveContainer />`.

## Properties

Properties in the groups Other and Internal are not recommended to be used.

<ArgTypes of={SunburstStories} sort={'requiredFirst'} />

0 comments on commit b0c71e3

Please sign in to comment.