Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
## 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
Showing
7 changed files
with
461 additions
and
1 deletion.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'} /> |
Oops, something went wrong.