diff --git a/packages/arcs/src/centers.ts b/packages/arcs/src/centers.ts index 03d170f0bd..33c1a8d5c6 100644 --- a/packages/arcs/src/centers.ts +++ b/packages/arcs/src/centers.ts @@ -47,6 +47,11 @@ export const useArcCentersTransition = <Datum extends DatumWithArc, ExtraProps = mode: ArcTransitionMode = 'innerRadius', extra?: TransitionExtra<Datum, ExtraProps> ) => { + // center root node label + const dataWithCenteredRoot = data.map(d => + d.arc.innerRadius === 0 ? { ...d, arc: { ...d.arc, outerRadius: 0 } } : d + ) + const { animate, config: springConfig } = useMotionConfig() const phases = useArcTransitionMode<Datum, ExtraProps>(mode, extra) @@ -60,7 +65,7 @@ export const useArcCentersTransition = <Datum extends DatumWithArc, ExtraProps = innerRadius: number outerRadius: number } & ExtraProps - >(data, { + >(dataWithCenteredRoot, { keys: datum => datum.id, initial: phases.update, from: phases.enter, diff --git a/packages/sunburst/package.json b/packages/sunburst/package.json index 6bf2f9164b..e4e65a0184 100644 --- a/packages/sunburst/package.json +++ b/packages/sunburst/package.json @@ -35,6 +35,7 @@ "@nivo/tooltip": "workspace:*", "@types/d3-hierarchy": "^1.1.8", "d3-hierarchy": "^1.1.8", + "d3-scale": "^3.2.3", "lodash": "^4.17.21" }, "peerDependencies": { diff --git a/packages/sunburst/src/Sunburst.tsx b/packages/sunburst/src/Sunburst.tsx index c7f4a9e717..e306f9b092 100644 --- a/packages/sunburst/src/Sunburst.tsx +++ b/packages/sunburst/src/Sunburst.tsx @@ -25,6 +25,8 @@ const InnerSunburst = <RawDatum,>({ data, id = defaultProps.id, value = defaultProps.value, + innerRadius = defaultProps.innerRadius, + renderRootNode = defaultProps.renderRootNode, valueFormat, cornerRadius = defaultProps.cornerRadius, layers = defaultProps.layers as SunburstLayer<RawDatum>[], @@ -73,6 +75,8 @@ const InnerSunburst = <RawDatum,>({ valueFormat, radius, cornerRadius, + innerRadius, + renderRootNode, colors, colorBy, inheritColorFromParent, diff --git a/packages/sunburst/src/hooks.ts b/packages/sunburst/src/hooks.ts index f0318c9dc4..435bcee000 100644 --- a/packages/sunburst/src/hooks.ts +++ b/packages/sunburst/src/hooks.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { partition as d3Partition, hierarchy as d3Hierarchy } from 'd3-hierarchy' +import { scaleRadial as d3ScaleRadial } from 'd3-scale' import cloneDeep from 'lodash/cloneDeep' import sortBy from 'lodash/sortBy' import { usePropertyAccessor, useTheme, useValueFormatter } from '@nivo/core' @@ -21,6 +22,8 @@ export const useSunburst = <RawDatum>({ valueFormat, radius, cornerRadius = defaultProps.cornerRadius, + innerRadius = defaultProps.innerRadius, + renderRootNode = defaultProps.renderRootNode, colors = defaultProps.colors, colorBy = defaultProps.colorBy, inheritColorFromParent = defaultProps.inheritColorFromParent, @@ -32,6 +35,8 @@ export const useSunburst = <RawDatum>({ valueFormat?: DataProps<RawDatum>['valueFormat'] radius: number cornerRadius?: SunburstCommonProps<RawDatum>['cornerRadius'] + innerRadius?: SunburstCommonProps<RawDatum>['innerRadius'] + renderRootNode?: SunburstCommonProps<RawDatum>['renderRootNode'] colors?: SunburstCommonProps<RawDatum>['colors'] colorBy?: SunburstCommonProps<RawDatum>['colorBy'] inheritColorFromParent?: SunburstCommonProps<RawDatum>['inheritColorFromParent'] @@ -58,7 +63,9 @@ export const useSunburst = <RawDatum>({ const partition = d3Partition<RawDatum>().size([2 * Math.PI, radius * radius]) // exclude root node - const descendants = partition(hierarchy).descendants().slice(1) + const descendants = renderRootNode + ? partition(hierarchy).descendants() + : partition(hierarchy).descendants().slice(1) const total = hierarchy.value ?? 0 @@ -68,6 +75,12 @@ export const useSunburst = <RawDatum>({ // are going to be computed first const sortedNodes = sortBy(descendants, 'depth') + const innerRadiusOffset = radius * Math.min(innerRadius, 1) + + const maxDepth = Math.max(...sortedNodes.map(n => n.depth)) + + const scale = d3ScaleRadial().domain([0, maxDepth]).range([innerRadiusOffset, radius]) + return sortedNodes.reduce<ComputedDatum<RawDatum>[]>((acc, descendant) => { const id = getId(descendant.data) // d3 hierarchy node value is optional by default as it depends on @@ -82,8 +95,12 @@ export const useSunburst = <RawDatum>({ const arc: Arc = { startAngle: descendant.x0, endAngle: descendant.x1, - innerRadius: Math.sqrt(descendant.y0), - outerRadius: Math.sqrt(descendant.y1), + innerRadius: + renderRootNode && descendant.depth === 0 ? 0 : scale(descendant.depth - 1), + outerRadius: + renderRootNode && descendant.depth === 0 + ? innerRadius + : scale(descendant.depth), } let parent: ComputedDatum<RawDatum> | undefined @@ -125,6 +142,8 @@ export const useSunburst = <RawDatum>({ getColor, inheritColorFromParent, getChildColor, + innerRadius, + renderRootNode, ]) const arcGenerator = useArcGenerator({ cornerRadius }) diff --git a/packages/sunburst/src/props.ts b/packages/sunburst/src/props.ts index aa59533eba..41b33617e3 100644 --- a/packages/sunburst/src/props.ts +++ b/packages/sunburst/src/props.ts @@ -7,6 +7,8 @@ export const defaultProps = { id: 'id', value: 'value', cornerRadius: 0, + innerRadius: 0.4, + renderRootNode: false, layers: ['arcs', 'arcLabels'] as SunburstLayerId[], colors: { scheme: 'nivo' } as unknown as OrdinalColorScaleConfig, colorBy: 'id' as const, diff --git a/packages/sunburst/src/types.ts b/packages/sunburst/src/types.ts index 75d26c508a..c2ed3a5949 100644 --- a/packages/sunburst/src/types.ts +++ b/packages/sunburst/src/types.ts @@ -56,6 +56,8 @@ export type SunburstCommonProps<RawDatum> = { height: number margin?: Box cornerRadius: number + innerRadius: number + renderRootNode: boolean theme: Theme colors: OrdinalColorScaleConfig<Omit<ComputedDatum<RawDatum>, 'color' | 'fill'>> colorBy: 'id' | 'depth' diff --git a/packages/sunburst/stories/sunburst.stories.tsx b/packages/sunburst/stories/sunburst.stories.tsx new file mode 100644 index 0000000000..6f3194581e --- /dev/null +++ b/packages/sunburst/stories/sunburst.stories.tsx @@ -0,0 +1,238 @@ +import { useState } from 'react' +import { storiesOf } from '@storybook/react' +import { action } from '@storybook/addon-actions' +import { withKnobs, boolean, select } from '@storybook/addon-knobs' +// @ts-ignore +import { linearGradientDef, patternDotsDef, useTheme } from '@nivo/core' +// @ts-ignore +import { generateLibTree } from '@nivo/generators' +import { colorSchemes } from '@nivo/colors' +// @ts-ignore +import { Sunburst, ComputedDatum, SunburstCustomLayerProps } from '../src' + +interface RawDatum { + name: string + loc: number +} + +const commonProperties = { + width: 900, + height: 500, + data: generateLibTree(), + id: 'name', + value: 'loc', +} + +const stories = storiesOf('Sunburst', module) + +stories.addDecorator(withKnobs) + +stories.add('default', () => <Sunburst {...commonProperties} />) + +stories.add('with child color modifier', () => ( + <Sunburst<RawDatum> + {...commonProperties} + childColor={{ from: 'color', modifiers: [['brighter', 0.13]] }} + /> +)) + +stories.add('with child colors independent of parent', () => ( + <Sunburst<RawDatum> {...commonProperties} inheritColorFromParent={false} /> +)) + +const customPalette = ['#ffd700', '#ffb14e', '#fa8775', '#ea5f94', '#cd34b5', '#9d02d7', '#0000ff'] + +stories.add('with custom colors', () => ( + <Sunburst<RawDatum> {...commonProperties} colors={customPalette} /> +)) + +stories.add('with custom child colors', () => ( + <Sunburst<RawDatum> + {...commonProperties} + childColor={(parent, child) => { + // @ts-expect-error + return child.data.color + }} + /> +)) + +stories.add('with formatted tooltip value', () => ( + <Sunburst<RawDatum> {...commonProperties} valueFormat=" >-$,.2f" /> +)) + +const CustomTooltip = ({ id, value, color }: ComputedDatum<unknown>) => { + const theme = useTheme() + + return ( + <strong style={{ ...theme.tooltip.container, color }}> + {id}: {value} + </strong> + ) +} + +stories.add('custom tooltip', () => ( + <Sunburst<RawDatum> + {...commonProperties} + tooltip={CustomTooltip} + theme={{ + tooltip: { + container: { + background: '#333', + }, + }, + }} + /> +)) + +stories.add('enter/leave (check actions)', () => ( + <Sunburst<RawDatum> + {...commonProperties} + onMouseEnter={action('onMouseEnter')} + onMouseLeave={action('onMouseLeave')} + /> +)) + +stories.add('patterns & gradients', () => ( + <Sunburst<RawDatum> + {...commonProperties} + defs={[ + linearGradientDef('gradient', [ + { offset: 0, color: '#ffffff' }, + { offset: 15, color: 'inherit' }, + { offset: 100, color: 'inherit' }, + ]), + patternDotsDef('pattern', { + background: 'inherit', + color: '#ffffff', + size: 2, + padding: 3, + stagger: true, + }), + ]} + fill={[ + { + match: (node: ComputedDatum<RawDatum>) => + ['viz', 'text', 'utils'].includes(node.id), + id: 'gradient', + }, + { + match: (node: ComputedDatum<RawDatum>) => + ['set', 'generators', 'misc'].includes(node.id), + id: 'pattern', + }, + ]} + /> +)) + +const flatten = data => + data.reduce((acc, item) => { + if (item.children) { + return [...acc, item, ...flatten(item.children)] + } + + return [...acc, item] + }, []) + +const findObject = (data, name) => data.find(searchedName => searchedName.name === name) + +const drillDownColors = colorSchemes.brown_blueGreen[7] +const drillDownColorMap = { + viz: drillDownColors[0], + colors: drillDownColors[1], + utils: drillDownColors[2], + generators: drillDownColors[3], + set: drillDownColors[4], + text: drillDownColors[5], + misc: drillDownColors[6], +} +const getDrillDownColor = (node: Omit<ComputedDatum<RawDatum>, 'color' | 'fill'>) => { + const category = [...node.path].reverse()[1] as keyof typeof drillDownColorMap + + return drillDownColorMap[category] +} + +stories.add( + 'children drill down', + () => { + const [data, setData] = useState(commonProperties.data) + + return ( + <> + <button onClick={() => setData(commonProperties.data)}>Reset</button> + <Sunburst<RawDatum> + {...commonProperties} + colors={getDrillDownColor} + inheritColorFromParent={false} + borderWidth={1} + borderColor={{ + from: 'color', + modifiers: [['darker', 0.6]], + }} + animate={boolean('animate', true)} + motionConfig={select( + 'motion config', + ['default', 'gentle', 'wobbly', 'stiff', 'slow', 'molasses'], + 'gentle' + )} + enableArcLabels + arcLabelsSkipAngle={12} + arcLabelsTextColor={{ + from: 'color', + modifiers: [['darker', 3]], + }} + data={data} + transitionMode="pushIn" + onClick={clickedData => { + const foundObject = findObject(flatten(data.children), clickedData.id) + if (foundObject && foundObject.children) { + setData(foundObject) + } + }} + /> + </> + ) + }, + { + info: { + text: ` + You can drill down into individual children by clicking on them + `, + }, + } +) + +const CenteredMetric = ({ nodes, centerX, centerY }: SunburstCustomLayerProps<RawDatum>) => { + const total = nodes.reduce((total, datum) => total + datum.value, 0) + + return ( + <text + x={centerX} + y={centerY} + textAnchor="middle" + dominantBaseline="central" + style={{ + fontSize: '42px', + fontWeight: 600, + }} + > + {Number.parseFloat(`${total}`).toExponential(2)} + </text> + ) +} + +stories.add('adding a metric in the center using a custom layer', () => ( + <Sunburst<RawDatum> {...commonProperties} layers={['arcs', 'arcLabels', CenteredMetric]} /> +)) + +stories.add('with root node', () => ( + <Sunburst<RawDatum> + {...commonProperties} + innerRadius={number('innerRadius', 0.25, { + range: true, + min: 0.0, + max: 0.95, + step: 0.05, + })} + renderRootNode={boolean('renderRootNode', true)} + /> +)) \ No newline at end of file diff --git a/website/src/data/components/sunburst/props.ts b/website/src/data/components/sunburst/props.ts index 2fde45db2f..293d4b9c1d 100644 --- a/website/src/data/components/sunburst/props.ts +++ b/website/src/data/components/sunburst/props.ts @@ -111,6 +111,29 @@ const props: ChartProperty[] = [ step: 1, }, }, + { + key: 'innerRadius', + help: `Size of the center circle. Value should be between 0~1 as it's a ratio from original radius.`, + type: 'number', + required: false, + defaultValue: defaultProps.innerRadiusRatio, + group: 'Base', + control: { + type: 'range', + min: 0, + max: 0.95, + step: 0.05 + }, + }, + { + key: 'renderRootNode', + help: `Render the root node. By default, the root node is omitted.`, + type: 'boolean', + required: false, + defaultValue: defaultProps.renderRootNode, + control: { type: 'switch' }, + group: 'Base', + }, ...chartDimensions(allFlavors), themeProperty(['svg', 'api']), ordinalColors({ diff --git a/website/src/pages/sunburst/api.tsx b/website/src/pages/sunburst/api.tsx index 7a31b8090f..5664d9daab 100644 --- a/website/src/pages/sunburst/api.tsx +++ b/website/src/pages/sunburst/api.tsx @@ -53,6 +53,8 @@ const SunburstApi = () => { value: 'loc', valueFormat: { format: '', enabled: false }, cornerRadius: 2, + innerRadius: 0.4, + renderRootNode: false, borderWidth: 1, borderColor: 'white', colors: { scheme: 'nivo' }, diff --git a/website/src/pages/sunburst/index.js b/website/src/pages/sunburst/index.js index ec61e19e11..e93c79ca49 100644 --- a/website/src/pages/sunburst/index.js +++ b/website/src/pages/sunburst/index.js @@ -25,6 +25,8 @@ const initialProperties = { value: 'loc', valueFormat: { format: '', enabled: false }, cornerRadius: 2, + innerRadius: 0.4, + renderRootNode: false, borderWidth: 1, borderColor: { theme: 'background' }, colors: { scheme: 'nivo' },