diff --git a/packages/dendogram/src/Dendogram.tsx b/packages/dendogram/src/Dendogram.tsx index 7b14737f8a..85c32584a3 100644 --- a/packages/dendogram/src/Dendogram.tsx +++ b/packages/dendogram/src/Dendogram.tsx @@ -34,6 +34,8 @@ const InnerDendogram = ({ debugMesh = svgDefaultProps.debugMesh, highlightAncestorNodes = svgDefaultProps.highlightAncestorNodes, highlightDescendantNodes = svgDefaultProps.highlightDescendantNodes, + highlightAncestorLinks = svgDefaultProps.highlightAncestorLinks, + highlightDescendantLinks = svgDefaultProps.highlightDescendantLinks, onNodeMouseEnter, onNodeMouseMove, onNodeMouseLeave, @@ -69,6 +71,8 @@ const InnerDendogram = ({ highlightDescendantNodes, linkThickness, linkColor, + highlightAncestorLinks, + highlightDescendantLinks, }) const layerById: Record = { diff --git a/packages/dendogram/src/Link.tsx b/packages/dendogram/src/Link.tsx index d3b71b373d..281e0e3688 100644 --- a/packages/dendogram/src/Link.tsx +++ b/packages/dendogram/src/Link.tsx @@ -40,8 +40,8 @@ export const Link = ({ ]) } )} - strokeWidth={link.thickness} - stroke={link.color} + strokeWidth={animatedProps.thickness} + stroke={animatedProps.color} {...eventHandlers} /> ) diff --git a/packages/dendogram/src/Links.tsx b/packages/dendogram/src/Links.tsx index 2ef4184bf2..6727f5338a 100644 --- a/packages/dendogram/src/Links.tsx +++ b/packages/dendogram/src/Links.tsx @@ -1,7 +1,13 @@ import { createElement } from 'react' import { useTransition } from '@react-spring/web' import { useMotionConfig } from '@nivo/core' -import { ComputedLink, LinkComponent, LinkMouseEventHandler, LinkTooltip } from './types' +import { + ComputedLink, + LinkComponent, + LinkMouseEventHandler, + LinkTooltip, + LinkAnimatedProps, +} from './types' interface LinksProps { links: ComputedLink[] @@ -14,17 +20,21 @@ interface LinksProps { tooltip?: LinkTooltip } -const regularTransition = (link: ComputedLink) => ({ +const regularTransition = (link: ComputedLink): LinkAnimatedProps => ({ sourceX: link.source.x, sourceY: link.source.y, targetX: link.target.x, targetY: link.target.y, + thickness: link.thickness, + color: link.color, }) -const leaveTransition = (link: ComputedLink) => ({ +const leaveTransition = (link: ComputedLink): LinkAnimatedProps => ({ sourceX: link.source.x, sourceY: link.source.y, targetX: link.target.x, targetY: link.target.y, + thickness: link.thickness, + color: link.color, }) export const Links = ({ @@ -39,15 +49,7 @@ export const Links = ({ }: LinksProps) => { const { animate, config: springConfig } = useMotionConfig() - const transition = useTransition< - ComputedLink, - { - sourceX: number - sourceY: number - targetX: number - targetY: number - } - >(links, { + const transition = useTransition, LinkAnimatedProps>(links, { keys: link => link.id, from: regularTransition, enter: regularTransition, diff --git a/packages/dendogram/src/defaults.ts b/packages/dendogram/src/defaults.ts index f9a4b5b3ec..c330501552 100644 --- a/packages/dendogram/src/defaults.ts +++ b/packages/dendogram/src/defaults.ts @@ -16,6 +16,8 @@ export const commonDefaultProps: Pick< | 'debugMesh' | 'highlightAncestorNodes' | 'highlightDescendantNodes' + | 'highlightAncestorLinks' + | 'highlightDescendantLinks' | 'role' | 'animate' | 'motionConfig' @@ -32,6 +34,8 @@ export const commonDefaultProps: Pick< debugMesh: false, highlightAncestorNodes: true, highlightDescendantNodes: false, + highlightAncestorLinks: true, + highlightDescendantLinks: false, role: 'img', animate: true, motionConfig: 'gentle', @@ -40,7 +44,7 @@ export const commonDefaultProps: Pick< export const svgDefaultProps: typeof commonDefaultProps & Required, 'layers' | 'nodeComponent' | 'linkComponent'>> = { ...commonDefaultProps, - layers: ['links', 'nodes', 'labels', 'mesh'], + layers: ['links', 'nodes', 'mesh'], nodeComponent: Node, linkComponent: Link, } diff --git a/packages/dendogram/src/hooks.ts b/packages/dendogram/src/hooks.ts index c4c7b1bd58..351c7cf1ca 100644 --- a/packages/dendogram/src/hooks.ts +++ b/packages/dendogram/src/hooks.ts @@ -25,19 +25,40 @@ import { } from './types' import { commonDefaultProps } from './defaults' -export const useHierarchy = ({ root }: { root: Datum }) => - useMemo(() => d3Hierarchy(root) as HierarchyDendogramNode, [root]) - -export const useCluster = (_props: { - width: number - height: number - layout: Layout +export const useRoot = ({ + data, + getIdentity, +}: { + data: DendogramDataProps['data'] + getIdentity: (node: Datum) => string }) => useMemo(() => { + const root = d3Hierarchy(data) as HierarchyDendogramNode const cluster = d3Cluster().size([1, 1]) - return cluster - }, []) + root.eachBefore(node => { + const ancestors = node + .ancestors() + .filter(ancestor => ancestor !== node) + .reverse() + const ancestorIds = ancestors.map(ancestor => getIdentity(ancestor.data)) + + node.ancestorIds = ancestorIds + node.uid = [...ancestorIds, getIdentity(node.data)].join('.') + node.ancestorUids = ancestors.map(ancestor => ancestor.uid!) + }) + + root.each(node => { + node.descendantUids = node + .descendants() + .filter(descendant => descendant !== node) + .map(descendant => descendant.uid!) + }) + + cluster(root) + + return root + }, [data, getIdentity]) /** * By default, the x/y positions are computed for a 0~1 range, @@ -104,8 +125,6 @@ const useNodes = ({ activeNodeSize, inactiveNodeSize, nodeColor, - highlightAncestorNodes, - highlightDescendantNodes, }: { root: HierarchyDendogramNode xScale: ScaleLinear @@ -116,8 +135,6 @@ const useNodes = ({ activeNodeSize?: CommonProps['activeNodeSize'] inactiveNodeSize?: CommonProps['inactiveNodeSize'] nodeColor: Exclude['nodeColor'], undefined> - highlightAncestorNodes: boolean - highlightDescendantNodes: boolean }) => { const intermediateNodes = useMemo[]>(() => { return root.descendants().map(node => { @@ -156,24 +173,6 @@ const useNodes = ({ const [activeNodeUids, setActiveNodeUids] = useState([]) - const setCurrentNode = useCallback( - (node: ComputedNode | null) => { - if (node === null) { - setActiveNodeUids([]) - } else { - let uids: string[] = [node.uid] - if (highlightAncestorNodes) { - uids = [...uids, ...node.ancestorUids] - } - if (highlightDescendantNodes) { - uids = [...uids, ...node.descendantUids] - } - setActiveNodeUids(uids) - } - }, - [setActiveNodeUids, highlightAncestorNodes, highlightDescendantNodes] - ) - const computed = useMemo(() => { const nodeByUid: Record> = {} @@ -209,7 +208,7 @@ const useNodes = ({ activeNodeUids, ]) - return { ...computed, setCurrentNode } + return { ...computed, setActiveNodeUids } } const useLinks = ({ @@ -222,7 +221,7 @@ const useLinks = ({ nodeByUid: Record> linkThickness: Exclude['linkThickness'], undefined> linkColor: Exclude['linkColor'], undefined> -}): ComputedLink[] => { +}) => { const intermediateLinks = useMemo[]>(() => { return (root.links() as HierarchyDendogramLink[]).map(link => { return { @@ -242,23 +241,42 @@ const useLinks = ({ const theme = useTheme() const getLinkColor = useInheritedColor(linkColor, theme) - return useMemo(() => { + const [activeLinkIds, setActiveLinkIds] = useState([]) + + const links = useMemo(() => { return intermediateLinks.map(intermediateLink => { - return { + const computedLink: ComputedLink = { ...intermediateLink, thickness: getLinkThickness(intermediateLink), color: getLinkColor(intermediateLink), + isActive: null, + } + + if (activeLinkIds.length > 0) { + computedLink.isActive = activeLinkIds.includes(computedLink.id) + if (computedLink.isActive) { + computedLink.thickness = 10 + } else { + computedLink.thickness = 1 + } } + + return computedLink }) - }, [intermediateLinks, getLinkThickness, getLinkColor]) + }, [intermediateLinks, getLinkThickness, getLinkColor, activeLinkIds]) + + return { + links, + setActiveLinkIds, + } } export const useDendogram = ({ data, - identity = commonDefaultProps.identity, - layout = commonDefaultProps.layout, width, height, + identity = commonDefaultProps.identity, + layout = commonDefaultProps.layout, nodeSize = commonDefaultProps.nodeSize, activeNodeSize, inactiveNodeSize, @@ -267,12 +285,14 @@ export const useDendogram = ({ highlightDescendantNodes = commonDefaultProps.highlightDescendantNodes, linkThickness = commonDefaultProps.linkThickness, linkColor = commonDefaultProps.linkColor, + highlightAncestorLinks = commonDefaultProps.highlightAncestorLinks, + highlightDescendantLinks = commonDefaultProps.highlightDescendantLinks, }: { data: DendogramDataProps['data'] - identity?: CommonProps['identity'] - layout?: Layout width: number height: number + identity?: CommonProps['identity'] + layout?: Layout nodeSize?: CommonProps['nodeSize'] activeNodeSize?: CommonProps['activeNodeSize'] inactiveNodeSize?: CommonProps['inactiveNodeSize'] @@ -281,33 +301,14 @@ export const useDendogram = ({ highlightDescendantNodes?: boolean linkThickness?: CommonProps['linkThickness'] linkColor?: CommonProps['linkColor'] + highlightAncestorLinks?: boolean + highlightDescendantLinks?: boolean }) => { const getIdentity = usePropertyAccessor(identity) - - const root = useHierarchy({ root: data }) - root.eachBefore(node => { - const ancestors = node - .ancestors() - .filter(ancestor => ancestor !== node) - .reverse() - const ancestorIds = ancestors.map(ancestor => getIdentity(ancestor.data)) - - node.ancestorIds = ancestorIds - node.uid = [...ancestorIds, getIdentity(node.data)].join('.') - node.ancestorUids = ancestors.map(ancestor => ancestor.uid!) - }) - root.each(node => { - node.descendantUids = node - .descendants() - .filter(descendant => descendant !== node) - .map(descendant => descendant.uid!) - }) - const cluster = useCluster({ width, height, layout }) - cluster(root) + const root = useRoot({ data, getIdentity }) const { xScale, yScale } = useCartesianScales({ width, height, layout }) - - const { nodes, nodeByUid, setCurrentNode } = useNodes({ + const { nodes, nodeByUid, setActiveNodeUids } = useNodes({ root, xScale, yScale, @@ -317,11 +318,68 @@ export const useDendogram = ({ activeNodeSize, inactiveNodeSize, nodeColor, - highlightAncestorNodes, - highlightDescendantNodes, }) - const links = useLinks({ root, nodeByUid, linkThickness, linkColor }) + const { links, setActiveLinkIds } = useLinks({ + root, + nodeByUid, + linkThickness, + linkColor, + }) + + const setCurrentNode = useCallback( + (node: ComputedNode | null) => { + if (node === null) { + setActiveNodeUids([]) + setActiveLinkIds([]) + } else { + let nodeUids: string[] = [node.uid] + if (highlightAncestorNodes) { + nodeUids = [...nodeUids, ...node.ancestorUids] + } + if (highlightDescendantNodes) { + nodeUids = [...nodeUids, ...node.descendantUids] + } + setActiveNodeUids(nodeUids) + + const linkIds: string[] = [] + if (highlightAncestorLinks) { + links + .filter(link => { + return ( + link.target.uid === node.uid || + node.ancestorUids.includes(link.target.uid) + ) + }) + .forEach(link => { + linkIds.push(link.id) + }) + } + if (highlightDescendantLinks) { + links + .filter(link => { + return ( + link.source.uid === node.uid || + node.descendantUids.includes(link.source.uid) + ) + }) + .forEach(link => { + linkIds.push(link.id) + }) + } + setActiveLinkIds(linkIds) + } + }, + [ + setActiveNodeUids, + highlightAncestorNodes, + highlightDescendantNodes, + links, + setActiveLinkIds, + highlightAncestorLinks, + highlightDescendantLinks, + ] + ) return { nodes, diff --git a/packages/dendogram/src/types.ts b/packages/dendogram/src/types.ts index 412745f73a..21a6d7c959 100644 --- a/packages/dendogram/src/types.ts +++ b/packages/dendogram/src/types.ts @@ -57,6 +57,7 @@ export interface IntermediateComputedLink { export interface ComputedLink extends IntermediateComputedLink { thickness: number color: string + isActive: boolean | null } export type NodeSizeFunction = ( @@ -99,6 +100,19 @@ export type LinkThicknessFunction = ( link: IntermediateComputedLink ) => number +export type LinkThicknessModifierFunction = ( + link: ComputedLink +) => number + +export type LinkAnimatedProps = { + sourceX: number + sourceY: number + targetX: number + targetY: number + thickness: number + color: string +} + export interface LinkComponentProps { link: ComputedLink isInteractive: boolean @@ -107,12 +121,7 @@ export interface LinkComponentProps { onMouseLeave?: LinkMouseEventHandler onClick?: LinkMouseEventHandler tooltip?: LinkTooltip - animatedProps: SpringValues<{ - sourceX: number - sourceY: number - targetX: number - targetY: number - }> + animatedProps: SpringValues } export type LinkComponent = FunctionComponent> @@ -150,6 +159,8 @@ export interface CommonProps extends MotionProps { inactiveNodeSize: number | NodeSizeModifierFunction nodeColor: OrdinalColorScaleConfig> linkThickness: number | LinkThicknessFunction + activeLinkThickness: number | LinkThicknessModifierFunction + inactiveLinkThickness: number | LinkThicknessModifierFunction linkColor: InheritedColorConfig> isInteractive: boolean @@ -158,6 +169,8 @@ export interface CommonProps extends MotionProps { debugMesh: boolean highlightAncestorNodes: boolean highlightDescendantNodes: boolean + highlightAncestorLinks: boolean + highlightDescendantLinks: boolean onNodeMouseEnter: NodeMouseEventHandler onNodeMouseMove: NodeMouseEventHandler onNodeMouseLeave: NodeMouseEventHandler diff --git a/website/src/data/components/dendogram/meta.yml b/website/src/data/components/dendogram/meta.yml index 8b5675ebdf..2c6b82f8b9 100644 --- a/website/src/data/components/dendogram/meta.yml +++ b/website/src/data/components/dendogram/meta.yml @@ -15,4 +15,12 @@ Dendogram: - label: With link tooltip link: dendogram--with-link-tooltip description: | - Dendogram + Nivo dendogram graph. + + For now this component doesn't support labels, but that's something + you could do using a custom layer. + + While it's part of the nivo internals, and not formally documented, + you could use the `useDendogram` hook directly in order to build + a fully custom component, this hook takes a config object which + is very close to the component's props. diff --git a/website/src/data/components/dendogram/props.ts b/website/src/data/components/dendogram/props.ts index 7a5a79bd2f..fe55cc319e 100644 --- a/website/src/data/components/dendogram/props.ts +++ b/website/src/data/components/dendogram/props.ts @@ -1,6 +1,6 @@ import { commonDefaultProps as defaults, - IntermediateComputedLink, + svgDefaultProps as svgDefaults, IntermediateComputedNode, } from '@nivo/dendogram' import { motionProperties, groupProperties, themeProperty } from '../../../lib/componentProperties' @@ -11,8 +11,6 @@ import { ordinalColors, } from '../../../lib/chart-properties' import { ChartProperty, Flavor } from '../../../types' -import { InheritedColorConfig, OrdinalColorScaleConfig } from '@nivo/colors' -import { svgDefaultProps } from '@nivo/bar' import { a } from '@react-spring/web' const allFlavors: Flavor[] = ['svg'] @@ -110,6 +108,26 @@ const props: ChartProperty[] = [ defaultValue: defaults.linkThickness, flavors: ['svg'], }, + { + key: 'activeLinkThickness', + group: 'Style', + type: 'number | (link: ComputedLink) => number', + control: { type: 'lineWidth' }, + help: 'Defines the size of active links, statically or dynamically.', + required: false, + defaultValue: defaults.activeLinkThickness, + flavors: allFlavors, + }, + { + key: 'inactiveLinkThickness', + group: 'Style', + type: 'number | (link: ComputedLink) => number', + control: { type: 'lineWidth' }, + help: 'Defines the thickness of inactive links, statically or dynamically.', + required: false, + defaultValue: defaults.inactiveLinkThickness, + flavors: allFlavors, + }, { key: 'linkColor', group: 'Style', @@ -131,7 +149,7 @@ const props: ChartProperty[] = [ }, { key: 'layers', - type: `('links' | 'nodes' | 'labels' | CustomSvgLayer)[]`, + type: `('links' | 'nodes' | 'mesh' | CustomSvgLayer)[]`, group: 'Customization', help: 'Defines the order of layers and add custom layers.', description: ` @@ -141,8 +159,8 @@ const props: ChartProperty[] = [ The layer function which will receive the chart's context & computed data and must return a valid SVG element. `, - defaultValue: defaults.layers, - flavors: ['svg'], + defaultValue: svgDefaults.layers, + flavors: allFlavors, }, { key: 'nodeComponent', @@ -171,7 +189,7 @@ const props: ChartProperty[] = [ required: false, }, isInteractive({ - flavors: ['svg'], + flavors: allFlavors, defaultValue: defaults.isInteractive, }), { @@ -231,6 +249,24 @@ const props: ChartProperty[] = [ required: false, control: { type: 'switch' }, }, + { + key: 'highlightAncestorLinks', + flavors: allFlavors, + group: 'Interactivity', + type: 'boolean', + help: 'Highlight active node ancestor links.', + required: false, + control: { type: 'switch' }, + }, + { + key: 'highlightDescendantLinks', + flavors: allFlavors, + group: 'Interactivity', + type: 'boolean', + help: 'Highlight active node descendant links.', + required: false, + control: { type: 'switch' }, + }, { key: 'onNodeMouseEnter', flavors: allFlavors, diff --git a/website/src/pages/dendogram/index.tsx b/website/src/pages/dendogram/index.tsx index 7fef4bec2c..debc156d16 100644 --- a/website/src/pages/dendogram/index.tsx +++ b/website/src/pages/dendogram/index.tsx @@ -22,6 +22,8 @@ const initialProperties: Pick< | 'nodeSize' | 'nodeColor' | 'linkThickness' + | 'activeLinkThickness' + | 'inactiveLinkThickness' | 'linkColor' | 'margin' | 'animate' @@ -32,6 +34,8 @@ const initialProperties: Pick< | 'debugMesh' | 'highlightAncestorNodes' | 'highlightDescendantNodes' + | 'highlightAncestorLinks' + | 'highlightDescendantLinks' > = { identity: 'name', layout: 'left-to-right', @@ -40,6 +44,8 @@ const initialProperties: Pick< inactiveNodeSize: 8, nodeColor: { scheme: 'dark2' }, linkThickness: 2, + activeLinkThickness: 6, + inactiveLinkThickness: 1, linkColor: defaults.linkColor, margin: { @@ -54,10 +60,12 @@ const initialProperties: Pick< isInteractive: defaults.isInteractive, useMesh: true, - meshDetectionThreshold: 60, + meshDetectionThreshold: 80, debugMesh: defaults.debugMesh, highlightAncestorNodes: defaults.highlightAncestorNodes, highlightDescendantNodes: defaults.highlightDescendantNodes, + highlightAncestorLinks: defaults.highlightAncestorLinks, + highlightDescendantLinks: defaults.highlightDescendantLinks, } const Dendogram = () => {