diff --git a/packages/sunburst/src/Sunburst.tsx b/packages/sunburst/src/Sunburst.tsx index e672e456cc..9be4f19627 100644 --- a/packages/sunburst/src/Sunburst.tsx +++ b/packages/sunburst/src/Sunburst.tsx @@ -12,6 +12,7 @@ const InnerSunburst = (props: SvgProps) => { data, id, value, + valueFormat, colors, childColor, @@ -37,13 +38,14 @@ const InnerSunburst = (props: SvgProps) => { role, // interactivity - tooltipFormat, + isInteractive, tooltip, // event handlers onClick, onMouseEnter, onMouseLeave, + onMouseMove, } = { ...defaultProps, ...props } const { innerWidth, innerHeight, margin } = useDimensions(width, height, partialMargin) @@ -60,8 +62,9 @@ const InnerSunburst = (props: SvgProps) => { cornerRadius, data, id, - value, radius, + value, + valueFormat, }) const boundDefs = bindDefs(defs, nodes, fill, { @@ -82,11 +85,13 @@ const InnerSunburst = (props: SvgProps) => { arcGenerator={arcGenerator} borderWidth={borderWidth} borderColor={borderColor} - tooltipFormat={tooltipFormat} + isInteractive={isInteractive} tooltip={tooltip} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} + onMouseMove={onMouseMove} + valueFormat={valueFormat} /> ))} {enableSliceLabels && ( @@ -109,7 +114,7 @@ export const Sunburst = ({ theme, ...otherProps }: SvgProps) => ( - + isInteractive={isInteractive} {...otherProps} /> ) diff --git a/packages/sunburst/src/SunburstArc.tsx b/packages/sunburst/src/SunburstArc.tsx index 2a3ed3a60f..bbbcdbbd9b 100644 --- a/packages/sunburst/src/SunburstArc.tsx +++ b/packages/sunburst/src/SunburstArc.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react' -import { BasicTooltip, useTooltip } from '@nivo/tooltip' import { animated } from 'react-spring' import { useAnimatedPath } from '@nivo/core' +import { useEventHandlers } from './hooks' import { SunburstArcProps } from './types' export const SunburstArc = ({ @@ -9,34 +9,12 @@ export const SunburstArc = ({ arcGenerator, borderWidth, borderColor, - tooltip: _tooltip, - tooltipFormat, - onClick, - onMouseEnter, - onMouseLeave, + ...props }: SunburstArcProps) => { - const { showTooltipFromEvent, hideTooltip } = useTooltip() - const path = useMemo(() => arcGenerator(node), [arcGenerator, node]) - const tooltip = useMemo( - () => ( - - ), - [_tooltip, node.data, tooltipFormat] - ) const animatedPath = useAnimatedPath(path ?? '') + const handlers = useEventHandlers({ data: node.data, ...props }) if (!path) { return null @@ -48,16 +26,7 @@ export const SunburstArc = ({ fill={node.data.fill ?? node.data.color} stroke={borderColor} strokeWidth={borderWidth} - onMouseEnter={event => { - onMouseEnter?.(node.data, event) - showTooltipFromEvent(tooltip, event) - }} - onMouseMove={event => showTooltipFromEvent(tooltip, event)} - onMouseLeave={event => { - onMouseLeave?.(node.data, event) - hideTooltip() - }} - onClick={event => onClick?.(node.data, event)} + {...handlers} /> ) } diff --git a/packages/sunburst/src/SunburstTooltip.tsx b/packages/sunburst/src/SunburstTooltip.tsx new file mode 100644 index 0000000000..df1b8c9e19 --- /dev/null +++ b/packages/sunburst/src/SunburstTooltip.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { BasicTooltip } from '@nivo/tooltip' +import { DataProps, NormalizedDatum } from './types' + +export const SunburstTooltip = ({ + color, + id, + formattedValue, + percentage, + valueFormat, +}: NormalizedDatum & Pick, 'valueFormat'>) => ( + +) diff --git a/packages/sunburst/src/hooks.ts b/packages/sunburst/src/hooks.ts index a730abc229..b1fed088dd 100644 --- a/packages/sunburst/src/hooks.ts +++ b/packages/sunburst/src/hooks.ts @@ -1,17 +1,85 @@ import sortBy from 'lodash/sortBy' import cloneDeep from 'lodash/cloneDeep' -import { getAccessorFor, useTheme } from '@nivo/core' +import React, { createElement, useCallback, useMemo } from 'react' +import { getAccessorFor, useTheme, useValueFormatter } from '@nivo/core' import { arc } from 'd3-shape' import { useOrdinalColorScale, useInheritedColor } from '@nivo/colors' -import { useMemo } from 'react' +import { useTooltip } from '@nivo/tooltip' import { partition as d3Partition, hierarchy as d3Hierarchy } from 'd3-hierarchy' -import { CommonProps, ComputedDatum, DataProps, NormalizedDatum } from './types' +import { CommonProps, ComputedDatum, DataProps, NormalizedDatum, MouseEventHandlers } from './types' type MaybeColor = { color?: string } const pick = (obj: T, ...keys: K[]): Pick => keys.reduce((acc, prop) => ({ ...acc, [prop]: obj[prop] }), {} as Pick) +export const useEventHandlers = ({ + data, + isInteractive, + onClick: onClickHandler, + onMouseEnter: onMouseEnterHandler, + onMouseLeave: onMouseLeaveHandler, + onMouseMove: onMouseMoveHandler, + tooltip, + valueFormat, +}: Pick, 'isInteractive' | 'tooltip'> & + MouseEventHandlers & { + data: NormalizedDatum + valueFormat?: DataProps['valueFormat'] + }) => { + const { showTooltipFromEvent, hideTooltip } = useTooltip() + + const handleTooltip = useCallback( + (event: React.MouseEvent) => + showTooltipFromEvent(createElement(tooltip, { ...data, valueFormat }), event), + [data, showTooltipFromEvent, tooltip, valueFormat] + ) + + const onClick = useCallback( + (event: React.MouseEvent) => onClickHandler?.(data, event), + [data, onClickHandler] + ) + const onMouseEnter = useCallback( + (event: React.MouseEvent) => { + onMouseEnterHandler?.(data, event) + handleTooltip(event) + }, + [data, handleTooltip, onMouseEnterHandler] + ) + const onMouseLeave = useCallback( + (event: React.MouseEvent) => { + onMouseLeaveHandler?.(data, event) + hideTooltip() + }, + [data, hideTooltip, onMouseLeaveHandler] + ) + const onMouseMove = useCallback( + (event: React.MouseEvent) => { + onMouseMoveHandler?.(data, event) + handleTooltip(event) + }, + [data, handleTooltip, onMouseMoveHandler] + ) + + return useMemo(() => { + if (!isInteractive) { + return { + onClick: undefined, + onMouseEnter: undefined, + onMouseLeave: undefined, + onMouseMove: undefined, + } + } + + return { + onClick, + onMouseEnter, + onMouseLeave, + onMouseMove, + } + }, [isInteractive, onClick, onMouseEnter, onMouseLeave, onMouseMove]) +} + export const useSunburst = ({ childColor, colors, @@ -19,15 +87,17 @@ export const useSunburst = ({ data, id, value, + valueFormat, radius, }: { childColor: CommonProps['childColor'] colors: CommonProps['colors'] cornerRadius: CommonProps['cornerRadius'] data: DataProps['data'] - id: DataProps['id'] + id: NonNullable['id']> radius: number - value: DataProps['value'] + value: NonNullable['value']> + valueFormat: DataProps['valueFormat'] }) => { const theme = useTheme() const getColor = useOrdinalColorScale(colors, 'id') @@ -38,43 +108,60 @@ export const useSunburst = ({ const getId = useMemo(() => getAccessorFor(id), [id]) const getValue = useMemo(() => getAccessorFor(value), [value]) + const formatValue = useValueFormatter(valueFormat) + const nodes = useMemo(() => { const partition = d3Partition().size([2 * Math.PI, radius * radius]) const hierarchy = d3Hierarchy(data).sum(getValue) + const descendants = partition(cloneDeep(hierarchy)).descendants() const total = hierarchy.value ?? 0 - return sortBy(partition(cloneDeep(hierarchy)).descendants(), 'depth').reduce< - Array> - >((acc, descendant) => { - // Maybe the types are wrong from d3, but value prop is always present, but types make it optional - const node = { - value: 0, - ...pick(descendant, 'x0', 'y0', 'x1', 'y1', 'depth', 'height', 'parent', 'value'), - } + return sortBy(descendants, 'depth').reduce>>( + (acc, descendant) => { + // Maybe the types are wrong from d3, but value prop is always present, but types make it optional + const node = { + value: 0, + ...pick( + descendant, + 'x0', + 'y0', + 'x1', + 'y1', + 'depth', + 'height', + 'parent', + 'value' + ), + } - const { value } = node - const id = getId(descendant.data) - const data = { - depth: node.depth, - id, - percentage: (100 * value) / total, - value, - } + const { value } = node + const id = getId(descendant.data) + const data = { + color: descendant.data.color, + data: descendant.data, + depth: node.depth, + formattedValue: formatValue(value), + id, + percentage: (100 * value) / total, + value, + } - const parent = acc.find( - computed => node.parent && computed.data.id === getId(node.parent.data) - ) + const parent = acc.find( + computed => node.parent && computed.data.id === getId(node.parent.data) + ) - const color = - node.depth === 1 || childColor === 'noinherit' - ? getColor(data) - : parent - ? getChildColor(parent.data) - : descendant.data.color + const color = + node.depth === 1 || childColor === 'noinherit' + ? getColor(data) + : parent + ? getChildColor(parent.data) + : descendant.data.color - return [...acc, { ...node, data: { ...data, color, parent } }] - }, []) - }, [data, childColor, getChildColor, getColor, getId, getValue, radius]) + return [...acc, { ...node, data: { ...data, color, parent } }] + }, + [] + ) + }, [radius, data, getValue, getId, formatValue, childColor, getColor, getChildColor]) const arcGenerator = useMemo( () => diff --git a/packages/sunburst/src/index.ts b/packages/sunburst/src/index.ts index bf96819659..d876030db3 100644 --- a/packages/sunburst/src/index.ts +++ b/packages/sunburst/src/index.ts @@ -1,4 +1,5 @@ export * from './Sunburst' export * from './ResponsiveSunburst' +export * from './hooks' export * from './props' export * from './types' diff --git a/packages/sunburst/src/props.ts b/packages/sunburst/src/props.ts index 4f16611741..4d6ec537d2 100644 --- a/packages/sunburst/src/props.ts +++ b/packages/sunburst/src/props.ts @@ -1,3 +1,5 @@ +import { SunburstTooltip } from './SunburstTooltip' + export type DefaultSunburstProps = Required export const defaultProps = { @@ -15,10 +17,12 @@ export const defaultProps = { // slices labels enableSliceLabels: false, - sliceLabel: 'value', + sliceLabel: 'formattedValue', sliceLabelsTextColor: { theme: 'labels.text.fill' }, isInteractive: true, animate: false, motionConfig: 'gentle', + + tooltip: SunburstTooltip, } as const diff --git a/packages/sunburst/src/types.ts b/packages/sunburst/src/types.ts index 612a96e9a4..6c9d79f8ba 100644 --- a/packages/sunburst/src/types.ts +++ b/packages/sunburst/src/types.ts @@ -14,18 +14,25 @@ export type DatumId = string | number export type DatumValue = number export type DatumPropertyAccessor = (datum: RawDatum) => T -export type LabelAccessorFunction = (datum: ComputedDatum) => string | number +export type LabelAccessorFunction = (datum: RawDatum) => string | number export interface DataProps { data: RawDatum - id: string | number | DatumPropertyAccessor - value: string | number | DatumPropertyAccessor + id?: string | number | DatumPropertyAccessor + value?: string | number | DatumPropertyAccessor + valueFormat?: string | DataFormatter +} + +export interface ChildrenDatum { + children?: Array> } export interface NormalizedDatum { color?: string + data: RawDatum & ChildrenDatum depth: number id: DatumId + formattedValue: string | number fill?: string parent?: ComputedDatum percentage: number @@ -49,7 +56,7 @@ export type CommonProps = { cornerRadius: number - colors: OrdinalColorScaleConfig, 'color' | 'fill' | 'parent'>> + colors: OrdinalColorScaleConfig, 'fill' | 'parent'>> borderWidth: number borderColor: string @@ -66,8 +73,7 @@ export type CommonProps = { theme: Theme isInteractive: boolean - tooltipFormat: DataFormatter - tooltip: (payload: NormalizedDatum) => JSX.Element + tooltip: (props: NormalizedDatum) => JSX.Element } export type MouseEventHandler = ( @@ -79,6 +85,7 @@ export type MouseEventHandlers = Partial<{ onClick: MouseEventHandler onMouseEnter: MouseEventHandler onMouseLeave: MouseEventHandler + onMouseMove: MouseEventHandler }> export type SvgProps = DataProps & @@ -90,17 +97,18 @@ export type SvgProps = DataProps & export type SunburstArcProps = Pick< SvgProps, - | 'tooltip' - | 'tooltipFormat' | 'onClick' | 'onMouseEnter' | 'onMouseLeave' + | 'onMouseMove' | 'borderWidth' | 'borderColor' -> & { - arcGenerator: Arc> - node: ComputedDatum -} + | 'valueFormat' +> & + Pick, 'isInteractive' | 'tooltip'> & { + arcGenerator: Arc> + node: ComputedDatum + } export type SunburstLabelProps = { label: CommonProps['sliceLabel'] diff --git a/packages/sunburst/stories/sunburst.stories.tsx b/packages/sunburst/stories/sunburst.stories.tsx index c378faf0e9..3428c08c00 100644 --- a/packages/sunburst/stories/sunburst.stories.tsx +++ b/packages/sunburst/stories/sunburst.stories.tsx @@ -4,8 +4,8 @@ import { action } from '@storybook/addon-actions' import { withKnobs, boolean, select } from '@storybook/addon-knobs' import { generateLibTree } from '@nivo/generators' // @ts-ignore -import { linearGradientDef, patternDotsDef } from '@nivo/core' -import { Sunburst } from '../src' +import { linearGradientDef, patternDotsDef, useTheme } from '@nivo/core' +import { Sunburst, NormalizedDatum } from '../src' const commonProperties = { width: 900, @@ -43,22 +43,23 @@ stories.add('with custom colors', () => ( )) stories.add('with formatted tooltip value', () => ( - { - return `~${typeof value === 'number' ? Math.floor(value) : value}%` - }} - /> + )) +const CustomTooltip = ({ id, value, color }: NormalizedDatum) => { + const theme = useTheme() + + return ( + + {id}: {value} + + ) +} + stories.add('custom tooltip', () => ( ( - - {id}: {value} - - )} + tooltip={CustomTooltip} theme={{ tooltip: { container: {