diff --git a/packages/network/package.json b/packages/network/package.json index f1cfc45cb8..47664db540 100644 --- a/packages/network/package.json +++ b/packages/network/package.json @@ -35,8 +35,7 @@ "@nivo/colors": "0.76.0", "@nivo/tooltip": "0.76.0", "@react-spring/web": "9.3.1", - "d3-force": "^2.0.1", - "lodash": "^4.17.21" + "d3-force": "^2.0.1" }, "devDependencies": { "@nivo/core": "0.76.0" diff --git a/packages/network/src/Network.tsx b/packages/network/src/Network.tsx index b7a9b9d31f..3a96706322 100644 --- a/packages/network/src/Network.tsx +++ b/packages/network/src/Network.tsx @@ -6,15 +6,14 @@ import { useNetwork } from './hooks' import { NetworkLinks } from './NetworkLinks' import { NetworkNodes } from './NetworkNodes' import { NetworkNodeAnnotations } from './NetworkNodeAnnotations' +import { InputNode, LayerId, NodeTooltip, NetworkSvgProps, ComputedNode, InputLink } from './types' -import { InputNode, LayerId, NodeTooltip, NetworkSvgProps, ComputedNode } from './types' - -type InnerNetworkProps = Omit< - NetworkSvgProps, +type InnerNetworkProps = Omit< + NetworkSvgProps, 'animate' | 'motionConfig' | 'renderWrapper' | 'theme' > -const InnerNetwork = ({ +const InnerNetwork = ({ width, height, margin: partialMargin, @@ -30,7 +29,7 @@ const InnerNetwork = ({ layers = svgDefaultProps.layers, nodeComponent = svgDefaultProps.nodeComponent as NonNullable< - NetworkSvgProps['nodeComponent'] + NetworkSvgProps['nodeComponent'] >, nodeSize = svgDefaultProps.nodeSize, activeNodeSize = svgDefaultProps.activeNodeSize, @@ -41,13 +40,15 @@ const InnerNetwork = ({ nodeBorderColor = svgDefaultProps.nodeBorderColor, linkComponent = svgDefaultProps.linkComponent as NonNullable< - NetworkSvgProps['linkComponent'] + NetworkSvgProps['linkComponent'] >, linkThickness = svgDefaultProps.linkThickness, linkColor = svgDefaultProps.linkColor, linkBlendMode = svgDefaultProps.linkBlendMode, - annotations = svgDefaultProps.annotations as NonNullable['annotations']>, + annotations = svgDefaultProps.annotations as NonNullable< + NetworkSvgProps['annotations'] + >, isInteractive = svgDefaultProps.isInteractive, nodeTooltip = svgDefaultProps.nodeTooltip as NodeTooltip, @@ -57,14 +58,14 @@ const InnerNetwork = ({ ariaLabel, ariaLabelledBy, ariaDescribedBy, -}: InnerNetworkProps) => { +}: InnerNetworkProps) => { const { margin, innerWidth, innerHeight, outerWidth, outerHeight } = useDimensions( width, height, partialMargin ) - const { nodes, links, setActiveNodeIds } = useNetwork({ + const { nodes, links, setActiveNodeIds } = useNetwork({ center: [innerWidth / 2, innerHeight / 2], nodes: rawNodes, links: rawLinks, @@ -106,7 +107,7 @@ const InnerNetwork = ({ if (layers.includes('links') && links !== null) { layerById.links = ( - + key="links" links={links} linkComponent={linkComponent} @@ -117,7 +118,7 @@ const InnerNetwork = ({ if (layers.includes('nodes') && nodes !== null) { layerById.nodes = ( - + key="nodes" nodes={nodes} nodeComponent={nodeComponent} @@ -132,7 +133,7 @@ const InnerNetwork = ({ if (layers.includes('annotations') && nodes !== null) { layerById.annotations = ( - + key="annotations" nodes={nodes} annotations={annotations} @@ -171,14 +172,14 @@ const InnerNetwork = ({ ) } -export const Network = ({ +export const Network = ({ isInteractive = svgDefaultProps.isInteractive, animate = svgDefaultProps.animate, motionConfig = svgDefaultProps.motionConfig, theme, renderWrapper, ...otherProps -}: NetworkSvgProps) => ( +}: NetworkSvgProps) => ( ({ theme, }} > - isInteractive={isInteractive} {...otherProps} /> + isInteractive={isInteractive} {...otherProps} /> ) diff --git a/packages/network/src/NetworkCanvas.tsx b/packages/network/src/NetworkCanvas.tsx index dd9d224989..371d3b5f4a 100644 --- a/packages/network/src/NetworkCanvas.tsx +++ b/packages/network/src/NetworkCanvas.tsx @@ -3,14 +3,14 @@ import { getDistance, getRelativeCursor, Container, useDimensions, useTheme } fr import { useTooltip } from '@nivo/tooltip' import { canvasDefaultProps } from './defaults' import { useNetwork } from './hooks' -import { NetworkCanvasProps, InputNode, NodeTooltip } from './types' +import { NetworkCanvasProps, InputNode, NodeTooltip, InputLink } from './types' -type InnerNetworkCanvasProps = Omit< - NetworkCanvasProps, +type InnerNetworkCanvasProps = Omit< + NetworkCanvasProps, 'renderWrapper' | 'theme' > -const InnerNetworkCanvas = ({ +const InnerNetworkCanvas = ({ width, height, margin: partialMargin, @@ -28,6 +28,8 @@ const InnerNetworkCanvas = ({ renderNode = canvasDefaultProps.renderNode, nodeSize = canvasDefaultProps.nodeSize, + activeNodeSize = canvasDefaultProps.activeNodeSize, + inactiveNodeSize = canvasDefaultProps.inactiveNodeSize, nodeColor = canvasDefaultProps.nodeColor, nodeBorderWidth = canvasDefaultProps.nodeBorderWidth, nodeBorderColor = canvasDefaultProps.nodeBorderColor, @@ -39,7 +41,7 @@ const InnerNetworkCanvas = ({ isInteractive = canvasDefaultProps.isInteractive, nodeTooltip = canvasDefaultProps.nodeTooltip as NodeTooltip, onClick, -}: InnerNetworkCanvasProps) => { +}: InnerNetworkCanvasProps) => { const canvasEl = useRef(null) const { margin, innerWidth, innerHeight, outerWidth, outerHeight } = useDimensions( width, @@ -47,7 +49,7 @@ const InnerNetworkCanvas = ({ partialMargin ) - const { nodes, links, setActiveNodeIds } = useNetwork({ + const { nodes, links, setActiveNodeIds } = useNetwork({ center: [innerWidth / 2, innerHeight / 2], nodes: rawNodes, links: rawLinks, @@ -57,6 +59,8 @@ const InnerNetworkCanvas = ({ distanceMax, iterations, nodeSize, + activeNodeSize, + inactiveNodeSize, nodeColor, nodeBorderWidth, nodeBorderColor, @@ -178,15 +182,18 @@ const InnerNetworkCanvas = ({ ) } -export const NetworkCanvas = ({ +export const NetworkCanvas = < + Node extends InputNode = InputNode, + Link extends InputLink = InputLink +>({ theme, isInteractive = canvasDefaultProps.isInteractive, animate = canvasDefaultProps.animate, motionConfig = canvasDefaultProps.motionConfig, renderWrapper, ...otherProps -}: NetworkCanvasProps) => ( +}: NetworkCanvasProps) => ( - isInteractive={isInteractive} {...otherProps} /> + isInteractive={isInteractive} {...otherProps} /> ) diff --git a/packages/network/src/NetworkLink.tsx b/packages/network/src/NetworkLink.tsx index 0e0bab84b5..4ad40fd6ad 100644 --- a/packages/network/src/NetworkLink.tsx +++ b/packages/network/src/NetworkLink.tsx @@ -1,12 +1,12 @@ import { animated } from '@react-spring/web' -import { InputNode, LinkProps } from './types' +import { InputLink, InputNode, LinkProps } from './types' import { memo } from 'react' -const NonMemoizedNetworkLink = ({ +const NonMemoizedNetworkLink = ({ link, animated: animatedProps, blendMode, -}: LinkProps) => ( +}: LinkProps) => ( { - links: ComputedLink[] - linkComponent: LinkComponent - blendMode: NonNullable['linkBlendMode']> +interface NetworkLinksProps { + links: ComputedLink[] + linkComponent: LinkComponent + blendMode: NonNullable['linkBlendMode']> } const getEnterTransition = - () => - (link: ComputedLink) => ({ + () => + (link: ComputedLink) => ({ x1: link.source.x, y1: link.source.y, x2: link.source.x, @@ -21,8 +21,8 @@ const getEnterTransition = }) const getRegularTransition = - () => - (link: ComputedLink) => ({ + () => + (link: ComputedLink) => ({ x1: link.source.x, y1: link.source.y, x2: link.target.x, @@ -31,20 +31,20 @@ const getRegularTransition = opacity: 1, }) -export const NetworkLinks = ({ +export const NetworkLinks = ({ links, linkComponent, blendMode, -}: NetworkLinksProps) => { +}: NetworkLinksProps) => { const { animate, config: springConfig } = useMotionConfig() const [enterTransition, regularTransition] = useMemo( - () => [getEnterTransition(), getRegularTransition()], + () => [getEnterTransition(), getRegularTransition()], [] ) const transition = useTransition< - ComputedLink, + ComputedLink, { x1: number y1: number diff --git a/packages/network/src/NetworkNode.tsx b/packages/network/src/NetworkNode.tsx index 29645da463..b25285e1a6 100644 --- a/packages/network/src/NetworkNode.tsx +++ b/packages/network/src/NetworkNode.tsx @@ -1,8 +1,8 @@ import { memo } from 'react' import { animated, to } from '@react-spring/web' -import { InputNode, NodeProps } from './types' +import { InputLink, InputNode, NodeProps } from './types' -const NonMemoizedNetworkNode = ({ +const NonMemoizedNetworkNode = ({ node, animated: animatedProps, blendMode, @@ -10,7 +10,7 @@ const NonMemoizedNetworkNode = ({ onMouseEnter, onMouseMove, onMouseLeave, -}: NodeProps) => ( +}: NodeProps) => ( { @@ -21,6 +21,7 @@ const NonMemoizedNetworkNode = ({ style={{ mixBlendMode: blendMode }} strokeWidth={animatedProps.borderWidth} stroke={animatedProps.borderColor} + opacity={animatedProps.opacity} onClick={onClick ? event => onClick(node, event) : undefined} onMouseEnter={onMouseEnter ? event => onMouseEnter(node, event) : undefined} onMouseMove={onMouseMove ? event => onMouseMove(node, event) : undefined} diff --git a/packages/network/src/NetworkNodeAnnotations.tsx b/packages/network/src/NetworkNodeAnnotations.tsx index 07601ab8d0..69882a1bff 100644 --- a/packages/network/src/NetworkNodeAnnotations.tsx +++ b/packages/network/src/NetworkNodeAnnotations.tsx @@ -1,16 +1,16 @@ import { Annotation } from '@nivo/annotations' -import { ComputedNode, InputNode, NetworkSvgProps } from './types' +import { ComputedNode, InputLink, InputNode, NetworkSvgProps } from './types' import { useNodeAnnotations } from './hooks' -interface NetworkNodeAnnotationsProps { +interface NetworkNodeAnnotationsProps { nodes: ComputedNode[] - annotations: NonNullable['annotations']> + annotations: NonNullable['annotations']> } -export const NetworkNodeAnnotations = ({ +export const NetworkNodeAnnotations = ({ nodes, annotations, -}: NetworkNodeAnnotationsProps) => { +}: NetworkNodeAnnotationsProps) => { const boundAnnotations = useNodeAnnotations(nodes, annotations) return ( diff --git a/packages/network/src/NetworkNodes.tsx b/packages/network/src/NetworkNodes.tsx index 43f7eb1fc8..ade6ddc46b 100644 --- a/packages/network/src/NetworkNodes.tsx +++ b/packages/network/src/NetworkNodes.tsx @@ -1,12 +1,19 @@ import { createElement, MouseEvent, useMemo } from 'react' import { useTransition } from '@react-spring/web' import { useMotionConfig } from '@nivo/core' -import { InputNode, ComputedNode, NodeAnimatedProps, NodeComponent, NetworkSvgProps } from './types' +import { + InputNode, + ComputedNode, + NodeAnimatedProps, + NodeComponent, + NetworkSvgProps, + InputLink, +} from './types' -interface NetworkNodesProps { +interface NetworkNodesProps { nodes: ComputedNode[] - nodeComponent: NodeComponent - blendMode: NonNullable['nodeBlendMode']> + nodeComponent: NodeComponent + blendMode: NonNullable['nodeBlendMode']> onClick?: (node: ComputedNode, event: MouseEvent) => void onMouseEnter?: (node: ComputedNode, event: MouseEvent) => void onMouseMove?: (node: ComputedNode, event: MouseEvent) => void @@ -23,6 +30,7 @@ const getEnterTransition = borderWidth: node.borderWidth, borderColor: node.borderColor, scale: 0, + opacity: 0, }) const getRegularTransition = @@ -35,6 +43,7 @@ const getRegularTransition = borderWidth: node.borderWidth, borderColor: node.borderColor, scale: 1, + opacity: 1, }) const getExitTransition = @@ -47,9 +56,10 @@ const getExitTransition = borderWidth: node.borderWidth, borderColor: node.borderColor, scale: 0, + opacity: 0, }) -export const NetworkNodes = ({ +export const NetworkNodes = ({ nodes, nodeComponent, blendMode, @@ -57,7 +67,7 @@ export const NetworkNodes = ({ onMouseEnter, onMouseMove, onMouseLeave, -}: NetworkNodesProps) => { +}: NetworkNodesProps) => { const { animate, config: springConfig } = useMotionConfig() const [enterTransition, regularTransition, exitTransition] = useMemo( diff --git a/packages/network/src/ResponsiveNetwork.tsx b/packages/network/src/ResponsiveNetwork.tsx index e5693e8de6..69f4cbe5e6 100644 --- a/packages/network/src/ResponsiveNetwork.tsx +++ b/packages/network/src/ResponsiveNetwork.tsx @@ -1,11 +1,14 @@ import { ResponsiveWrapper } from '@nivo/core' -import { InputNode, NetworkSvgProps } from './types' +import { InputLink, InputNode, NetworkSvgProps } from './types' import { Network } from './Network' -export const ResponsiveNetwork = ( - props: Omit, 'height' | 'width'> +export const ResponsiveNetwork = < + Node extends InputNode = InputNode, + Link extends InputLink = InputLink +>( + props: Omit, 'height' | 'width'> ) => ( - {({ width, height }) => width={width} height={height} {...props} />} + {({ width, height }) => width={width} height={height} {...props} />} ) diff --git a/packages/network/src/ResponsiveNetworkCanvas.tsx b/packages/network/src/ResponsiveNetworkCanvas.tsx index 53a651cb11..ccbd5e65d1 100644 --- a/packages/network/src/ResponsiveNetworkCanvas.tsx +++ b/packages/network/src/ResponsiveNetworkCanvas.tsx @@ -1,11 +1,16 @@ import { ResponsiveWrapper } from '@nivo/core' -import { NetworkCanvasProps, InputNode } from './types' +import { NetworkCanvasProps, InputNode, InputLink } from './types' import { NetworkCanvas } from './NetworkCanvas' -export const ResponsiveNetworkCanvas = ( - props: Omit, 'height' | 'width'> +export const ResponsiveNetworkCanvas = < + Node extends InputNode = InputNode, + Link extends InputLink = InputLink +>( + props: Omit, 'height' | 'width'> ) => ( - {({ width, height }) => width={width} height={height} {...props} />} + {({ width, height }) => ( + width={width} height={height} {...props} /> + )} ) diff --git a/packages/network/src/defaults.ts b/packages/network/src/defaults.ts index a93b3fbee3..fc966d3d8f 100644 --- a/packages/network/src/defaults.ts +++ b/packages/network/src/defaults.ts @@ -1,4 +1,4 @@ -import { NetworkCommonProps, InputNode, LayerId, NetworkSvgProps } from './types' +import { NetworkCommonProps, InputNode, LayerId, NetworkSvgProps, InputLink } from './types' import { NetworkNode } from './NetworkNode' import { renderCanvasNode } from './renderCanvasNode' import { NetworkLink } from './NetworkLink' @@ -6,7 +6,7 @@ import { renderCanvasLink } from './renderCanvasLink' import { NetworkNodeTooltip } from './NetworkNodeTooltip' export const commonDefaultProps: Omit< - NetworkCommonProps, + NetworkCommonProps, | 'margin' | 'theme' | 'activeLinkThickness' @@ -22,10 +22,10 @@ export const commonDefaultProps: Omit< layers: ['links', 'nodes', 'annotations'], linkDistance: 30, - repulsivity: 10, + repulsivity: 3, distanceMin: 1, distanceMax: Infinity, - iterations: 90, + iterations: 160, nodeSize: 12, activeNodeSize: 18, @@ -50,10 +50,14 @@ export const commonDefaultProps: Omit< export const svgDefaultProps = { ...commonDefaultProps, - nodeComponent: NetworkNode as NonNullable['nodeComponent']>, - nodeBlendMode: 'normal' as NonNullable['nodeBlendMode']>, - linkComponent: NetworkLink as NonNullable['linkComponent']>, - linkBlendMode: 'normal' as NonNullable['linkBlendMode']>, + nodeComponent: NetworkNode as NonNullable< + NetworkSvgProps['nodeComponent'] + >, + nodeBlendMode: 'normal' as NonNullable['nodeBlendMode']>, + linkComponent: NetworkLink as NonNullable< + NetworkSvgProps['linkComponent'] + >, + linkBlendMode: 'normal' as NonNullable['linkBlendMode']>, } export const canvasDefaultProps = { diff --git a/packages/network/src/hooks.ts b/packages/network/src/hooks.ts index 10eb9d19c2..b3450fdb53 100644 --- a/packages/network/src/hooks.ts +++ b/packages/network/src/hooks.ts @@ -1,7 +1,4 @@ import { useState, useEffect, useMemo, useCallback } from 'react' -import get from 'lodash/get' -import isString from 'lodash/isString' -import isNumber from 'lodash/isNumber' import { forceSimulation, forceManyBody, forceCenter, forceLink } from 'd3-force' import { useTheme } from '@nivo/core' import { useInheritedColor } from '@nivo/colors' @@ -14,53 +11,53 @@ import { DerivedProp, ComputedNode, ComputedLink, + TransientNode, + TransientLink, } from './types' -const computeForces = ({ +const useDerivedProp = ( + instruction: DerivedProp +) => + useMemo(() => { + if (typeof instruction === 'function') return instruction + return () => instruction + }, [instruction]) + +const useComputeForces = ({ linkDistance, repulsivity, distanceMin, distanceMax, center, }: { - linkDistance: NetworkCommonProps['linkDistance'] - repulsivity: NetworkCommonProps['repulsivity'] - distanceMin: NetworkCommonProps['distanceMin'] - distanceMax: NetworkCommonProps['distanceMax'] + linkDistance: NetworkCommonProps['linkDistance'] + repulsivity: NetworkCommonProps['repulsivity'] + distanceMin: NetworkCommonProps['distanceMin'] + distanceMax: NetworkCommonProps['distanceMax'] center: [number, number] }) => { - let getLinkDistance - if (typeof linkDistance === 'function') { - getLinkDistance = linkDistance - } else if (isNumber(linkDistance)) { - getLinkDistance = linkDistance - } else if (isString(linkDistance)) { - getLinkDistance = (link: InputLink) => get(link, linkDistance) - } + const getLinkDistance = useDerivedProp(linkDistance) - const linkForce = forceLink() - .id((d: any) => d.id) - .distance(getLinkDistance as any) + const centerX = center[0] + const centerY = center[1] - const chargeForce = forceManyBody() - .strength(-repulsivity) - .distanceMin(distanceMin) - .distanceMax(distanceMax) + return useMemo(() => { + const linkForce = forceLink, TransientLink>().distance( + link => getLinkDistance(link.data) + ) - const centerForce = forceCenter(center[0], center[1]) + const chargeForce = forceManyBody() + .strength(-repulsivity) + .distanceMin(distanceMin) + .distanceMax(distanceMax) - return { link: linkForce, charge: chargeForce, center: centerForce } -} + const centerForce = forceCenter(centerX, centerY) -const useDerivedProp = ( - instruction: DerivedProp -) => - useMemo(() => { - if (typeof instruction === 'function') return instruction - return () => instruction - }, [instruction]) + return { link: linkForce, charge: chargeForce, center: centerForce } + }, [getLinkDistance, repulsivity, distanceMin, distanceMax, centerX, centerY]) +} -const useNodeStyle = ({ +const useNodeStyle = ({ size, activeSize, inactiveSize, @@ -70,17 +67,15 @@ const useNodeStyle = ({ isInteractive, activeNodeIds, }: { - size: NetworkCommonProps['nodeSize'] - activeSize: NetworkCommonProps['activeNodeSize'] - inactiveSize: NetworkCommonProps['inactiveNodeSize'] - color: NetworkCommonProps['nodeColor'] - borderWidth: NetworkCommonProps['nodeBorderWidth'] - borderColor: NetworkCommonProps['nodeBorderColor'] - isInteractive: NetworkCommonProps['isInteractive'] + size: NetworkCommonProps['nodeSize'] + activeSize: NetworkCommonProps['activeNodeSize'] + inactiveSize: NetworkCommonProps['inactiveNodeSize'] + color: NetworkCommonProps['nodeColor'] + borderWidth: NetworkCommonProps['nodeBorderWidth'] + borderColor: NetworkCommonProps['nodeBorderColor'] + isInteractive: NetworkCommonProps['isInteractive'] activeNodeIds: string[] }) => { - type IntermediateNode = Pick, 'id' | 'data' | 'index' | 'x' | 'y'> - const theme = useTheme() const getSize = useDerivedProp(size) @@ -88,7 +83,7 @@ const useNodeStyle = ({ const getBorderWidth = useDerivedProp(borderWidth) const getBorderColor = useInheritedColor(borderColor, theme) const getNormalStyle = useCallback( - (node: IntermediateNode) => { + (node: TransientNode) => { const color = getColor(node.data) return { @@ -103,7 +98,7 @@ const useNodeStyle = ({ const getActiveSize = useDerivedProp(activeSize) const getActiveStyle = useCallback( - (node: IntermediateNode) => { + (node: TransientNode) => { const color = getColor(node.data) return { @@ -118,7 +113,7 @@ const useNodeStyle = ({ const getInactiveSize = useDerivedProp(inactiveSize) const getInactiveStyle = useCallback( - (node: IntermediateNode) => { + (node: TransientNode) => { const color = getColor(node.data) return { @@ -132,7 +127,7 @@ const useNodeStyle = ({ ) return useCallback( - (node: IntermediateNode) => { + (node: TransientNode) => { if (!isInteractive || activeNodeIds.length === 0) return getNormalStyle(node) if (activeNodeIds.includes(node.id)) return getActiveStyle(node) return getInactiveStyle(node) @@ -141,7 +136,7 @@ const useNodeStyle = ({ ) } -export const useNetwork = ({ +export const useNetwork = ({ center, nodes, links, @@ -162,87 +157,78 @@ export const useNetwork = ({ }: { center: [number, number] nodes: Node[] - links: InputLink[] - linkDistance?: NetworkCommonProps['linkDistance'] - repulsivity?: NetworkCommonProps['repulsivity'] - distanceMin?: NetworkCommonProps['distanceMin'] - distanceMax?: NetworkCommonProps['distanceMax'] - iterations?: NetworkCommonProps['iterations'] - nodeSize?: NetworkCommonProps['nodeSize'] - activeNodeSize?: NetworkCommonProps['activeNodeSize'] - inactiveNodeSize?: NetworkCommonProps['inactiveNodeSize'] - nodeColor?: NetworkCommonProps['nodeColor'] - nodeBorderWidth?: NetworkCommonProps['nodeBorderWidth'] - nodeBorderColor?: NetworkCommonProps['nodeBorderColor'] - linkThickness?: NetworkCommonProps['linkThickness'] - linkColor?: NetworkCommonProps['linkColor'] - isInteractive?: NetworkCommonProps['isInteractive'] + links: Link[] + linkDistance?: NetworkCommonProps['linkDistance'] + repulsivity?: NetworkCommonProps['repulsivity'] + distanceMin?: NetworkCommonProps['distanceMin'] + distanceMax?: NetworkCommonProps['distanceMax'] + iterations?: NetworkCommonProps['iterations'] + nodeSize?: NetworkCommonProps['nodeSize'] + activeNodeSize?: NetworkCommonProps['activeNodeSize'] + inactiveNodeSize?: NetworkCommonProps['inactiveNodeSize'] + nodeColor?: NetworkCommonProps['nodeColor'] + nodeBorderWidth?: NetworkCommonProps['nodeBorderWidth'] + nodeBorderColor?: NetworkCommonProps['nodeBorderColor'] + linkThickness?: NetworkCommonProps['linkThickness'] + linkColor?: NetworkCommonProps['linkColor'] + isInteractive?: NetworkCommonProps['isInteractive'] }) => { // we're using `null` instead of empty array so that we can dissociate // initial rendering from updates when using transitions. - const [currentNodes, setCurrentNodes] = useState< - null | Pick, 'id' | 'data' | 'index' | 'x' | 'y'>[] - >(null) - const [currentLinks, setCurrentLinks] = useState[]>(null) + const [transientNodes, setTransientNodes] = useState[]>(null) + const [transientLinks, setTransientLinks] = useState[]>(null) - const centerX = center[0] - const centerY = center[1] + const forces = useComputeForces({ + linkDistance, + repulsivity, + distanceMin, + distanceMax, + center, + }) useEffect(() => { - const forces = computeForces({ - linkDistance, - repulsivity, - distanceMin, - distanceMax, - center: [centerX, centerY], - }) - - const nodesCopy: Pick, 'id' | 'data' | 'index' | 'x' | 'y'>[] = - nodes.map(node => ({ - id: node.id, - data: { ...node }, - // populated later by D3, mutation - index: 0, - x: 0, - y: 0, - })) - const linksCopy: InputLink[] = links.map(link => ({ - // generate a unique id for each link - id: `${link.source}.${link.target}`, - ...link, + // copy the nodes & links to avoid mutating the original ones. + const _transientNodes: TransientNode[] = nodes.map(node => ({ + id: node.id, + data: { ...node }, + // the properties below are populated by D3, via mutations + index: 0, + x: 0, + y: 0, + vx: 0, + vy: 0, + })) + const _transientLinks: TransientLink[] = links.map(link => ({ + data: { ...link }, + // populated by D3, via mutation + index: 0, + // replace ids with objects, otherwise D3 does this automatically + // which is a bit annoying with typings because then `source` & `target` + // can be either strings (before the simulation) or an objects (after). + source: _transientNodes.find(node => node.id === link.source)!, + target: _transientNodes.find(node => node.id === link.target)!, })) - const simulation = forceSimulation(nodesCopy as any[]) - .force('link', forces.link.links(linksCopy)) + const simulation = forceSimulation(_transientNodes) + .force('link', forces.link.links(_transientLinks)) .force('charge', forces.charge) .force('center', forces.center) .stop() + // this will set `index`, `x`, `y`, `vx`, `vy` for each node. simulation.tick(iterations) - // d3 mutates data, hence the castings - setCurrentNodes(nodesCopy) - setCurrentLinks(linksCopy as unknown as ComputedLink[]) + setTransientNodes(_transientNodes) + setTransientLinks(_transientLinks) return () => { - // prevent the simulation from continuing in case the data is updated. simulation.stop() } - }, [ - centerX, - centerY, - nodes, - links, - linkDistance, - repulsivity, - distanceMin, - distanceMax, - iterations, - ]) + }, [nodes, links, forces, iterations, setTransientNodes, setTransientLinks]) const [activeNodeIds, setActiveNodeIds] = useState([]) - const getNodeStyle = useNodeStyle({ + const getNodeStyle = useNodeStyle({ size: nodeSize, activeSize: activeNodeSize, inactiveSize: inactiveNodeSize, @@ -252,39 +238,42 @@ export const useNetwork = ({ isInteractive, activeNodeIds, }) - const enhancedNodes: ComputedNode[] | null = useMemo(() => { - if (currentNodes === null) return null + const computedNodes: ComputedNode[] | null = useMemo(() => { + if (transientNodes === null) return null - return currentNodes.map(node => ({ + return transientNodes.map(node => ({ ...node, ...getNodeStyle(node), })) - }, [currentNodes, getNodeStyle]) + }, [transientNodes, getNodeStyle]) const theme = useTheme() const getLinkThickness = useDerivedProp(linkThickness) const getLinkColor = useInheritedColor(linkColor, theme) - const enhancedLinks: ComputedLink[] | null = useMemo(() => { - if (currentLinks === null || enhancedNodes === null) return null - - return currentLinks.map(link => { - const linkWithEnhancedNodes = { - ...link, - source: enhancedNodes.find(node => node.id === link.source.id)!, - target: enhancedNodes.find(node => node.id === link.target.id)!, + + const computedLinks: ComputedLink[] | null = useMemo(() => { + if (transientLinks === null || computedNodes === null) return null + + return transientLinks.map(({ index, ...link }) => { + const linkWithComputedNodes: Omit, 'color' | 'thickness'> = { + id: `${link.source.id}.${link.target.id}`, + data: link.data, + index, + source: computedNodes.find(node => node.id === link.source.id)!, + target: computedNodes.find(node => node.id === link.target.id)!, } return { - ...linkWithEnhancedNodes, - thickness: getLinkThickness(linkWithEnhancedNodes), - color: getLinkColor(linkWithEnhancedNodes), + ...linkWithComputedNodes, + thickness: getLinkThickness(linkWithComputedNodes), + color: getLinkColor(linkWithComputedNodes), } }) - }, [currentLinks, getLinkThickness, getLinkColor, enhancedNodes]) + }, [transientLinks, computedNodes, getLinkThickness, getLinkColor]) return { - nodes: enhancedNodes, - links: enhancedLinks, + nodes: computedNodes, + links: computedLinks, setActiveNodeIds, } } diff --git a/packages/network/src/renderCanvasLink.ts b/packages/network/src/renderCanvasLink.ts index 3f019632a8..940a378dcd 100644 --- a/packages/network/src/renderCanvasLink.ts +++ b/packages/network/src/renderCanvasLink.ts @@ -1,8 +1,8 @@ -import { InputNode, ComputedLink } from './types' +import { InputNode, ComputedLink, InputLink } from './types' -export const renderCanvasLink = ( +export const renderCanvasLink = ( ctx: CanvasRenderingContext2D, - link: ComputedLink + link: ComputedLink ) => { ctx.strokeStyle = link.color ctx.lineWidth = link.thickness diff --git a/packages/network/src/types.ts b/packages/network/src/types.ts index a3de758b81..16aac38a44 100644 --- a/packages/network/src/types.ts +++ b/packages/network/src/types.ts @@ -4,6 +4,7 @@ import { Box, Theme, Dimensions, ModernMotionProps, CssMixBlendMode } from '@niv import { InheritedColorConfig } from '@nivo/colors' import { AnnotationMatcher } from '@nivo/annotations' +// minimal node data export interface InputNode { id: string } @@ -11,15 +12,26 @@ export interface InputNode { export interface ComputedNode { id: string data: Node + // computed by D3 + index: number x: number y: number - index: number + vx: number + vy: number + // styles computed by nivo size: number color: string borderWidth: number borderColor: string } +// intermediate type for D3 as it mutates nodes, +// `ComputedNode` is used for the final data structure. +export type TransientNode = Omit< + ComputedNode, + 'size' | 'color' | 'borderWidth' | 'borderColor' +> + export interface NodeAnimatedProps { x: number y: number @@ -31,16 +43,18 @@ export interface NodeAnimatedProps { scale: number } -export interface NodeProps { +export interface NodeProps { node: ComputedNode animated: AnimatedProps - blendMode: NonNullable['nodeBlendMode']> + blendMode: NonNullable['nodeBlendMode']> onClick?: (node: ComputedNode, event: MouseEvent) => void onMouseEnter?: (node: ComputedNode, event: MouseEvent) => void onMouseMove?: (node: ComputedNode, event: MouseEvent) => void onMouseLeave?: (node: ComputedNode, event: MouseEvent) => void } -export type NodeComponent = FunctionComponent> +export type NodeComponent = FunctionComponent< + NodeProps +> export type NodeCanvasRenderer = ( ctx: CanvasRenderingContext2D, node: ComputedNode @@ -51,16 +65,25 @@ export interface InputLink { target: string } -export interface ComputedLink { +export interface ComputedLink { id: string + data: Link + index: number source: ComputedNode - previousSource?: ComputedNode target: ComputedNode - previousTarget?: ComputedNode thickness: number color: string } +// intermediate type for D3 as it mutates links, +// `ComputedLink` is used for the final data structure. +export interface TransientLink { + data: Link + index: number + source: TransientNode + target: TransientNode +} + export interface LinkAnimatedProps { x1: number y1: number @@ -70,33 +93,37 @@ export interface LinkAnimatedProps { opacity: number } -export interface LinkProps { - link: ComputedLink +export interface LinkProps { + link: ComputedLink animated: AnimatedProps - blendMode: NonNullable['linkBlendMode']> + blendMode: NonNullable['linkBlendMode']> } -export type LinkComponent = FunctionComponent> -export type LinkCanvasRenderer = ( +export type LinkComponent = FunctionComponent< + LinkProps +> +export type LinkCanvasRenderer = ( ctx: CanvasRenderingContext2D, - node: ComputedLink + node: ComputedLink ) => void -export interface NetworkDataProps { +export interface NetworkDataProps { data: { nodes: Node[] - links: InputLink[] + links: Link[] } } export type LayerId = 'links' | 'nodes' | 'annotations' -export interface CustomLayerProps { +export interface CustomLayerProps { nodes: ComputedNode[] - links: ComputedLink[] + links: ComputedLink[] } -export type CustomLayer = FunctionComponent> -export type CustomCanvasLayer = ( +export type CustomLayer = FunctionComponent< + CustomLayerProps +> +export type CustomCanvasLayer = ( ctx: CanvasRenderingContext2D, - props: CustomLayerProps + props: CustomLayerProps ) => void export interface NodeTooltipProps { @@ -108,10 +135,10 @@ export type DerivedProp = | Output | ((target: Target) => Output) -export type NetworkCommonProps = { +export type NetworkCommonProps = { margin: Box - linkDistance: DerivedProp, number> + linkDistance: DerivedProp repulsivity: number distanceMin: number distanceMax: number @@ -128,9 +155,9 @@ export type NetworkCommonProps = { Omit, 'size' | 'borderWidth' | 'borderColor'> > - linkThickness: DerivedProp, number> - activeLinkThickness: DerivedProp, number> - linkColor: InheritedColorConfig> + linkThickness: DerivedProp, 'color' | 'thickness'>, number> + activeLinkThickness: DerivedProp, 'color' | 'thickness'>, number> + linkColor: InheritedColorConfig, 'color' | 'thickness'>> annotations: AnnotationMatcher>[] @@ -147,21 +174,25 @@ export type NetworkCommonProps = { ariaDescribedBy: AriaAttributes['aria-describedby'] } & Required -export type NetworkSvgProps = Partial> & - NetworkDataProps & +export type NetworkSvgProps = Partial< + NetworkCommonProps +> & + NetworkDataProps & Dimensions & { - layers?: (LayerId | CustomLayer)[] - nodeComponent?: NodeComponent + layers?: (LayerId | CustomLayer)[] + nodeComponent?: NodeComponent nodeBlendMode?: CssMixBlendMode - linkComponent?: LinkComponent + linkComponent?: LinkComponent linkBlendMode?: CssMixBlendMode } -export type NetworkCanvasProps = Partial> & - NetworkDataProps & +export type NetworkCanvasProps = Partial< + NetworkCommonProps +> & + NetworkDataProps & Dimensions & { - layers?: (LayerId | CustomCanvasLayer)[] + layers?: (LayerId | CustomCanvasLayer)[] renderNode?: NodeCanvasRenderer - renderLink?: LinkCanvasRenderer + renderLink?: LinkCanvasRenderer pixelRatio?: number } diff --git a/website/src/pages/network/index.tsx b/website/src/pages/network/index.tsx index 1832cc9903..cd1a881f2c 100644 --- a/website/src/pages/network/index.tsx +++ b/website/src/pages/network/index.tsx @@ -11,6 +11,9 @@ import mapper, { } from '../../data/components/network/mapper' import { groups } from '../../data/components/network/props' +type Node = ReturnType['nodes'][number] +type Link = ReturnType['links'][number] + const initialProperties = Object.freeze({ margin: { top: 0, @@ -19,9 +22,10 @@ const initialProperties = Object.freeze({ left: 0, }, - linkDistance: 30, - repulsivity: 3, - iterations: 60, + linkDistance: (link: Link) => link.distance, + distanceMax: 50, + repulsivity: defaults.repulsivity, + iterations: defaults.iterations, nodeSize: dynamicNodeSizeValue, activeNodeSize: dynamicActiveNodeSizeValue, @@ -80,7 +84,7 @@ const Network = () => { > {(properties, data, theme, logAction) => { return ( - data={data} {...properties} theme={theme}