Skip to content
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

Sunburst chart #4037

Merged
merged 25 commits into from Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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.

3 changes: 1 addition & 2 deletions package.json
Expand Up @@ -93,17 +93,16 @@
"@storybook/addon-links": "^7.6.3",
"@storybook/addon-mdx-gfm": "^7.6.3",
"@storybook/addon-storysource": "^7.6.3",
"@storybook/source-loader": "^7.6.3",
"@storybook/jest": "^0.2.3",
"@storybook/react": "^7.6.3",
"@storybook/react-webpack5": "^7.6.3",
"@storybook/source-loader": "^7.6.3",
"@storybook/test-runner": "^0.16.0",
"@storybook/testing-library": "^0.2.2",
"@testing-library/jest-dom": "^5.16.5",
"@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",
csdiehl marked this conversation as resolved.
Show resolved Hide resolved
"@types/d3-shape": "^3.1.0",
"@types/d3-time-format": "^4.0.0",
"@types/lodash": "^4.14.144",
Expand Down
136 changes: 136 additions & 0 deletions src/chart/SunburstChart.tsx
@@ -0,0 +1,136 @@
import React from 'react';
import { scaleLinear } from 'victory-vendor/d3-scale';
import { Surface } from '../container/Surface';
import { Layer } from '../container/Layer';
import { Sector } from '../shape/Sector';
import { Text } from '../component/Text';

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

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

export interface SunburstChartProps {
data?: SunburstData;
width?: number;
height?: number;
padding?: number;
ringPadding?: number;
innerRadius?: number;
children?: React.ReactNode;
fill?: string;
stroke?: string;
textOptions?: TextOptions;
Copy link
Contributor

Choose a reason for hiding this comment

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

Comments above each of these definitions will automatically land in storybook user facing documentation. We do not do this everywhere, but we totally should with all new Charts.

This will show up in the ArgsTable in the description column.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

seems like most of these already have default descriptions that fit pretty well, but I added comments for additional clarification. Let me know if anything is still unclear

}

interface DrawArcOptions {
r: number;
csdiehl marked this conversation as resolved.
Show resolved Hide resolved
innerR: number;
initialAngle: number;
childColor?: string;
}

const defaultTextProps = {
csdiehl marked this conversation as resolved.
Show resolved Hide resolved
fontFamily: 'sans-serif',
fontWeight: 'bold',
paintOrder: 'stroke fill',
stroke: '#FFF',
fill: 'black',
};

function maxDepth(node: SunburstData): number {
csdiehl marked this conversation as resolved.
Show resolved Hide resolved
if (!node.children || node.children.length === 0) return 1;

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

function polarToCartesian(r: number, angleInDegrees: number): number[] {
csdiehl marked this conversation as resolved.
Show resolved Hide resolved
const angleInRadians = (angleInDegrees * Math.PI) / 180.0;
const x = r * Math.cos(angleInRadians);
const y = r * Math.sin(angleInRadians);
return [x, y];
}

export const SunburstChart = ({
data,
children,
width,
height,
padding = 2,
ringPadding = 2,
innerRadius = 50,
fill = '#333',
stroke = '#FFF',
textOptions = defaultTextProps,
}: SunburstChartProps) => {
// get the max possible radius for a circle inscribed in the chart container
const outerRadius = Math.min(width, height) / 2;

const cx = width / 2,
cy = height / 2;
csdiehl marked this conversation as resolved.
Show resolved Hide resolved

const rScale = scaleLinear([0, data.value], [0, 360]);
const treeDepth = maxDepth(data);
const thickness = (outerRadius - innerRadius) / treeDepth;

const sectors: React.ReactNode[] = [];

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

let currentAngle = initialAngle;

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

childNodes.forEach(d => {
const arcLength = rScale(d.value);
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 [textX, textY] = polarToCartesian(innerR + (innerR + r - innerR) / 2, start + arcLength - arcLength / 2);
currentAngle += arcLength;
sectors.push(
<g>
Copy link
Member

Choose a reason for hiding this comment

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

wonder if there are any accessibility concerns we can take care of up front? aria-labels, tabIndex, etc.?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

that's a good idea - I set these attributes on the g element that wraps each data point, and tested that the chart is now keyboard accessible

<Sector
fill={fillColor}
stroke={stroke}
strokeWidth={padding}
startAngle={start}
endAngle={start + arcLength}
innerRadius={innerR}
outerRadius={innerR + r}
cx={cx}
cy={cy}
/>
<Text {...textOptions} alignmentBaseline="middle" textAnchor="middle" x={textX + cx} y={cy - textY}>
{d.value}
</Text>
</g>,
);

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

drawArcs(data.children, { r: thickness, innerR: innerRadius, initialAngle: 0 });

return (
<Surface width={width} height={height}>
{children}
<Layer>{sectors}</Layer>
</Surface>
);
};
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
26 changes: 26 additions & 0 deletions storybook/stories/API/chart/SunburstChart.stories.tsx
@@ -0,0 +1,26 @@
import React from 'react';
import { ResponsiveContainer, SunburstChart, Tooltip } from '../../../../src';
import { CategoricalChartProps } from '../props/ChartProps';
import { hierarchy } from '../../data';

export default {
argTypes: {
...CategoricalChartProps,
csdiehl marked this conversation as resolved.
Show resolved Hide resolved
},
component: SunburstChart,
};

export const Sunburst = {
render: (args: Record<string, any>) => {
return (
<ResponsiveContainer width="100%" height={400}>
<SunburstChart fill="purple" {...args}>
<Tooltip />
</SunburstChart>
</ResponsiveContainer>
);
},
args: {
data: hierarchy,
},
};
88 changes: 88 additions & 0 deletions storybook/stories/data/Hierarchy.ts
@@ -0,0 +1,88 @@
export interface SunburstData {
name: string;
value?: number;
children?: SunburstData[];
fill?: string;
}

const hierarchy: SunburstData = {
Copy link
Contributor

Choose a reason for hiding this comment

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

The file is called hierarchy, the type used here is SunburstData though. Should this exist as a separate file?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think this file would make a lot of sense if we were to use a shared type between TreeMap and SunburstChart data? Is that possible / does it even make sense?

If not, I would recommend to integrate this data into the SunburstChart story.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it would make sense to use the same type for both. But I think the size property should be re-named value since it could represent any quantitative value, and it should support a custom fill color.

For now, I'll just delete this file and integrate the data into the sunburst story to avoid changing treemap.

name: 'Root',
value: 100,
children: [
{
name: 'Child1',
fill: 'steelblue',
value: 30,
children: [
{
name: 'third child',
value: 10,
},
{
name: 'another child',
value: 5,
},
{
name: 'next child',
value: 15,
children: [
{
name: 'third level child',
value: 5,
},
{
name: 'third level child',
value: 5,
},
{
name: 'third level child',
value: 5,
children: [{ name: 'level 4', value: 2 }],
},
],
},
],
},
{
name: 'Child2',
fill: 'green',
value: 20,
children: [
{
name: 'another child',
value: 10,
},
{
name: 'next child',
value: 10,
children: [
{ name: 'level 3 of child 2', value: 5 },
{ name: 'level 3 of child 2', value: 3 },
{ name: 'level 3 of child 2', value: 2 },
],
},
],
},
{
name: 'Child3',
fill: 'red',
value: 20,
},
{
name: 'Child4',
fill: 'purple',
value: 10,
children: [
{ name: 'child4 child', value: 5 },
{ name: 'child4 child', value: 5 },
],
},
{
name: 'Child5',
fill: 'orange',
value: 20,
},
],
};

export { hierarchy };
1 change: 1 addition & 0 deletions storybook/stories/data/index.ts
Expand Up @@ -9,3 +9,4 @@ export * from './Time';
export * from './BoxPlot';
export * from './Impression';
export * from './Error';
export * from './Hierarchy';