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 10 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: 2 additions & 1 deletion package.json
Expand Up @@ -93,10 +93,10 @@
"@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",
Expand All @@ -123,6 +123,7 @@
"chromatic": "^6.15.0",
"concurrently": "^7.6.0",
"cross-env": "^7.0.3",
"d3-scale": "^4.0.2",
csdiehl marked this conversation as resolved.
Show resolved Hide resolved
"d3-scale-chromatic": "^3.0.0",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
Expand Down
127 changes: 127 additions & 0 deletions src/chart/SunburstChart.tsx
@@ -0,0 +1,127 @@
import React from 'react';
import { scaleLinear } from 'd3-scale';
import { Surface } from '../container/Surface';
import { Layer } from '../container/Layer';
import { Sector } from '../shape/Sector';

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

export interface SunburstChartProps {
data?: SunburstData;
width?: number;
height?: number;
padding?: number;
ringPadding?: number;
innerRadius?: number;
children?: React.ReactNode;
fill?: string;
}

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

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',
}: 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="#FFF"
csdiehl marked this conversation as resolved.
Show resolved Hide resolved
strokeWidth={padding}
startAngle={start}
endAngle={start + arcLength}
innerRadius={innerR}
outerRadius={innerR + r}
cx={cx}
cy={cy}
/>
<text
csdiehl marked this conversation as resolved.
Show resolved Hide resolved
fontSize=".875rem"
alignmentBaseline="middle"
textAnchor="middle"
stroke="#FFF"
strokeWidth={0.5}
paintOrder="stroke fill"
fill="#333"
fontFamily="sans-serif"
fontWeight="bold"
Copy link
Member

Choose a reason for hiding this comment

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

some of these values are pretty arbitrary, probably we should allow the user to pass in their own Text props or a custom Text component

Copy link
Contributor

Choose a reason for hiding this comment

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

👍 We should evaluate all props to be maximum consistent with the existing API in other places.

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 the remaining props needed to make it consistent are dataKey, event handlers and animation props (if those are needed for this chart type). What is the dataKey used for?

Copy link
Contributor

Choose a reason for hiding this comment

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

Given a dataset with multiple rows such as [{a:1, b:2}. {a:3, b:4}]
The dataKey for example for a Line component identifies which row to use for the line values. The dataKey on the XAxis identifies which row to use for the xAxis ticks.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

got it - I added this in, so instead of just value, the user can pass any data key

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';