From 07a1e33376afb59eeb8ad57e3498ab475753e285 Mon Sep 17 00:00:00 2001 From: plouc Date: Thu, 30 Dec 2021 17:42:42 +0900 Subject: [PATCH] feat(network): add basic tests --- packages/network/src/Network.tsx | 6 +- packages/network/src/NetworkLink.tsx | 27 ++-- packages/network/src/NetworkNode.tsx | 38 +++-- packages/network/src/hooks.ts | 138 ++++++++++-------- packages/network/src/types.ts | 28 ++-- packages/network/tests/Network.test.tsx | 106 ++++++++++++-- website/src/data/components/network/mapper.ts | 4 +- website/src/data/components/network/meta.yml | 1 - website/src/data/components/network/props.ts | 30 ++-- website/src/pages/network/index.tsx | 24 +-- website/src/theming/theme.ts | 18 ++- 11 files changed, 258 insertions(+), 162 deletions(-) diff --git a/packages/network/src/Network.tsx b/packages/network/src/Network.tsx index f0aa41eb0b..b7a9b9d31f 100644 --- a/packages/network/src/Network.tsx +++ b/packages/network/src/Network.tsx @@ -132,7 +132,11 @@ const InnerNetwork = ({ if (layers.includes('annotations') && nodes !== null) { layerById.annotations = ( - nodes={nodes} annotations={annotations} /> + + key="annotations" + nodes={nodes} + annotations={annotations} + /> ) } diff --git a/packages/network/src/NetworkLink.tsx b/packages/network/src/NetworkLink.tsx index 56bfdd835d..5c8ca9ea23 100644 --- a/packages/network/src/NetworkLink.tsx +++ b/packages/network/src/NetworkLink.tsx @@ -5,17 +5,16 @@ export const NetworkLink = ({ link, animated: animatedProps, blendMode, -}: LinkProps) => { - return ( - - ) -} +}: LinkProps) => ( + +) diff --git a/packages/network/src/NetworkNode.tsx b/packages/network/src/NetworkNode.tsx index 94dac8a2c3..4f2d425823 100644 --- a/packages/network/src/NetworkNode.tsx +++ b/packages/network/src/NetworkNode.tsx @@ -9,24 +9,20 @@ export const NetworkNode = ({ onMouseEnter, onMouseMove, onMouseLeave, -}: NodeProps) => { - return ( - { - return `translate(${x},${y}) scale(${scale})` - } - )} - r={to([animatedProps.size], size => size / 2)} - fill={animatedProps.color} - style={{ mixBlendMode: blendMode }} - strokeWidth={animatedProps.borderWidth} - stroke={animatedProps.borderColor} - onClick={onClick ? event => onClick(node, event) : undefined} - onMouseEnter={onMouseEnter ? event => onMouseEnter(node, event) : undefined} - onMouseMove={onMouseMove ? event => onMouseMove(node, event) : undefined} - onMouseLeave={onMouseLeave ? event => onMouseLeave(node, event) : undefined} - /> - ) -} +}: NodeProps) => ( + { + return `translate(${x},${y}) scale(${scale})` + })} + r={to([animatedProps.size], size => size / 2)} + fill={animatedProps.color} + style={{ mixBlendMode: blendMode }} + strokeWidth={animatedProps.borderWidth} + stroke={animatedProps.borderColor} + onClick={onClick ? event => onClick(node, event) : undefined} + onMouseEnter={onMouseEnter ? event => onMouseEnter(node, event) : undefined} + onMouseMove={onMouseMove ? event => onMouseMove(node, event) : undefined} + onMouseLeave={onMouseLeave ? event => onMouseLeave(node, event) : undefined} + /> +) diff --git a/packages/network/src/hooks.ts b/packages/network/src/hooks.ts index 4e0d3feed5..755944b0c1 100644 --- a/packages/network/src/hooks.ts +++ b/packages/network/src/hooks.ts @@ -11,8 +11,7 @@ import { InputLink, InputNode, NetworkCommonProps, - NodeDerivedProp, - LinkDerivedProp, + DerivedProp, ComputedNode, ComputedLink, } from './types' @@ -53,16 +52,8 @@ const computeForces = ({ return { link: linkForce, charge: chargeForce, center: centerForce } } -const useNodeDerivedProp = ( - instruction: NodeDerivedProp -) => - useMemo(() => { - if (typeof instruction === 'function') return instruction - return () => instruction - }, [instruction]) - -const useLinkDerivedProp = ( - instruction: LinkDerivedProp +const useDerivedProp = ( + instruction: DerivedProp ) => useMemo(() => { if (typeof instruction === 'function') return instruction @@ -88,46 +79,60 @@ const useNodeStyle = ({ isInteractive: NetworkCommonProps['isInteractive'] activeNodeIds: string[] }) => { + type IntermediateNode = Pick, 'id' | 'data' | 'index' | 'x' | 'y'> + const theme = useTheme() - const getSize = useNodeDerivedProp(size) - const getColor = useNodeDerivedProp(color) - const getBorderWidth = useNodeDerivedProp(borderWidth) + const getSize = useDerivedProp(size) + const getColor = useDerivedProp(color) + const getBorderWidth = useDerivedProp(borderWidth) const getBorderColor = useInheritedColor(borderColor, theme) const getNormalStyle = useCallback( - (node: ComputedNode) => ({ - size: getSize(node), - color: getColor(node), - borderWidth: getBorderWidth(node), - borderColor: getBorderColor(node), - }), + (node: IntermediateNode) => { + const color = getColor(node.data) + + return { + size: getSize(node.data), + color, + borderWidth: getBorderWidth(node.data), + borderColor: getBorderColor({ ...node, color }), + } + }, [getSize, getColor, getBorderWidth, getBorderColor] ) - const getActiveSize = useNodeDerivedProp(activeSize) + const getActiveSize = useDerivedProp(activeSize) const getActiveStyle = useCallback( - (node: ComputedNode) => ({ - size: getActiveSize(node), - color: getColor(node), - borderWidth: getBorderWidth(node), - borderColor: getBorderColor(node), - }), + (node: IntermediateNode) => { + const color = getColor(node.data) + + return { + size: getActiveSize(node.data), + color, + borderWidth: getBorderWidth(node.data), + borderColor: getBorderColor({ ...node, color }), + } + }, [getActiveSize, getColor, getBorderWidth, getBorderColor] ) - const getInactiveSize = useNodeDerivedProp(inactiveSize) + const getInactiveSize = useDerivedProp(inactiveSize) const getInactiveStyle = useCallback( - (node: ComputedNode) => ({ - size: getInactiveSize(node), - color: getColor(node), - borderWidth: getBorderWidth(node), - borderColor: getBorderColor(node), - }), + (node: IntermediateNode) => { + const color = getColor(node.data) + + return { + size: getInactiveSize(node.data), + color, + borderWidth: getBorderWidth(node.data), + borderColor: getBorderColor({ ...node, color }), + } + }, [getInactiveSize, getColor, getBorderWidth, getBorderColor] ) return useCallback( - (node: ComputedNode) => { + (node: IntermediateNode) => { if (!isInteractive || activeNodeIds.length === 0) return getNormalStyle(node) if (activeNodeIds.includes(node.id)) return getActiveStyle(node) return getInactiveStyle(node) @@ -175,7 +180,9 @@ export const useNetwork = ({ }) => { // 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) + const [currentNodes, setCurrentNodes] = useState< + null | Pick, 'id' | 'data' | 'index' | 'x' | 'y'>[] + >(null) const [currentLinks, setCurrentLinks] = useState[]>(null) const centerX = center[0] @@ -190,7 +197,15 @@ export const useNetwork = ({ center: [centerX, centerY], }) - const nodesCopy: Node[] = nodes.map(node => ({ ...node })) + 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}`, @@ -206,15 +221,22 @@ export const useNetwork = ({ simulation.tick(iterations) // d3 mutates data, hence the castings - setCurrentNodes(nodesCopy as unknown as ComputedNode[]) + setCurrentNodes(nodesCopy) setCurrentLinks( (linksCopy as unknown as ComputedLink[]).map(link => { - link.previousSource = currentNodes + const previousSource = currentNodes ? currentNodes.find(n => n.id === link.source.id) : undefined - link.previousTarget = currentNodes + link.previousSource = previousSource + ? (previousSource as ComputedNode) + : undefined + + const previousTarget = currentNodes ? currentNodes.find(n => n.id === link.target.id) : undefined + link.previousTarget = previousTarget + ? (previousTarget as ComputedNode) + : undefined return link }) @@ -238,10 +260,6 @@ export const useNetwork = ({ const [activeNodeIds, setActiveNodeIds] = useState([]) - const theme = useTheme() - const getLinkThickness = useLinkDerivedProp(linkThickness) - const getLinkColor = useInheritedColor(linkColor, theme) - const getNodeStyle = useNodeStyle({ size: nodeSize, activeSize: activeNodeSize, @@ -252,29 +270,35 @@ export const useNetwork = ({ isInteractive, activeNodeIds, }) - const enhancedNodes: ComputedNode[] | null = useMemo(() => { if (currentNodes === null) return null - return currentNodes.map(node => { - return { - ...node, - ...getNodeStyle(node), - } - }) + return currentNodes.map(node => ({ + ...node, + ...getNodeStyle(node), + })) }, [currentNodes, getNodeStyle]) + const theme = useTheme() + const getLinkThickness = useDerivedProp(linkThickness) + const getLinkColor = useInheritedColor(linkColor, theme) const enhancedLinks: ComputedLink[] | null = useMemo(() => { - if (currentLinks === null) return null + if (currentLinks === null || enhancedNodes === null) return null return currentLinks.map(link => { - return { + const linkWithEnhancedNodes = { ...link, - thickness: getLinkThickness(link), - color: getLinkColor(link), + source: enhancedNodes.find(node => node.id === link.source.id)!, + target: enhancedNodes.find(node => node.id === link.target.id)!, + } + + return { + ...linkWithEnhancedNodes, + thickness: getLinkThickness(linkWithEnhancedNodes), + color: getLinkColor(linkWithEnhancedNodes), } }) - }, [currentLinks, getLinkThickness, getLinkColor]) + }, [currentLinks, getLinkThickness, getLinkColor, enhancedNodes]) return { nodes: enhancedNodes, diff --git a/packages/network/src/types.ts b/packages/network/src/types.ts index a6a0b0ab3b..a3de758b81 100644 --- a/packages/network/src/types.ts +++ b/packages/network/src/types.ts @@ -13,6 +13,7 @@ export interface ComputedNode { data: Node x: number y: number + index: number size: number color: string borderWidth: number @@ -103,13 +104,14 @@ export interface NodeTooltipProps { } export type NodeTooltip = FunctionComponent> -export type NodeDerivedProp = T | ((node: ComputedNode) => T) -export type LinkDerivedProp = T | ((link: ComputedLink) => T) +export type DerivedProp = + | Output + | ((target: Target) => Output) export type NetworkCommonProps = { margin: Box - linkDistance: LinkDerivedProp + linkDistance: DerivedProp, number> repulsivity: number distanceMin: number distanceMax: number @@ -117,15 +119,17 @@ export type NetworkCommonProps = { theme: Theme - nodeSize: NodeDerivedProp - activeNodeSize: NodeDerivedProp - inactiveNodeSize: NodeDerivedProp - nodeColor: NodeDerivedProp - nodeBorderWidth: NodeDerivedProp - nodeBorderColor: InheritedColorConfig> - - linkThickness: LinkDerivedProp - activeLinkThickness: LinkDerivedProp + nodeSize: DerivedProp + activeNodeSize: DerivedProp + inactiveNodeSize: DerivedProp + nodeColor: DerivedProp + nodeBorderWidth: DerivedProp + nodeBorderColor: InheritedColorConfig< + Omit, 'size' | 'borderWidth' | 'borderColor'> + > + + linkThickness: DerivedProp, number> + activeLinkThickness: DerivedProp, number> linkColor: InheritedColorConfig> annotations: AnnotationMatcher>[] diff --git a/packages/network/tests/Network.test.tsx b/packages/network/tests/Network.test.tsx index d4ebdaaaaa..8b90b16159 100644 --- a/packages/network/tests/Network.test.tsx +++ b/packages/network/tests/Network.test.tsx @@ -1,19 +1,103 @@ import { mount } from 'enzyme' // @ts-ignore -import { Network, NetworkSvgProps, NetworkInputNode } from '../src' +import { Network, NetworkSvgProps, NetworkInputNode, svgDefaultProps } from '../src' +import { InputNode } from '../dist/types' + +const sampleData: NetworkSvgProps['data'] = { + nodes: [{ id: 'A' }, { id: 'B' }, { id: 'C' }, { id: 'D' }, { id: 'E' }], + links: [ + { source: 'A', target: 'B' }, + { source: 'B', target: 'C' }, + { source: 'C', target: 'D' }, + { source: 'D', target: 'E' }, + { source: 'E', target: 'A' }, + ], +} const baseProps: NetworkSvgProps = { width: 600, height: 600, - data: { - nodes: [{ id: 'A' }, { id: 'B' }, { id: 'C' }, { id: 'D' }, { id: 'E' }], - links: [ - { source: 'A', target: 'B' }, - { source: 'B', target: 'C' }, - { source: 'C', target: 'D' }, - { source: 'D', target: 'E' }, - { source: 'E', target: 'A' }, - ], - }, + data: sampleData, animate: false, } + +it('should render a basic network chart', () => { + const wrapper = mount() + + sampleData.nodes.forEach(node => { + const nodeElement = wrapper.find(`circle[data-testid='node.${node.id}']`) + expect(nodeElement.exists()).toBeTruthy() + }) + + sampleData.links.forEach(link => { + const linkElement = wrapper.find(`line[data-testid='link.${link.source}.${link.target}']`) + expect(linkElement.exists()).toBeTruthy() + }) +}) + +describe('nodes', () => { + it('static node color', () => { + const color = 'rgba(255, 0, 255, 1)' + const wrapper = mount() + + sampleData.nodes.forEach(node => { + expect(wrapper.find(`circle[data-testid='node.${node.id}']`).prop('fill')).toEqual( + color + ) + }) + }) + + it('static node size', () => { + const size = 32 + const wrapper = mount() + + sampleData.nodes.forEach(node => { + expect(wrapper.find(`circle[data-testid='node.${node.id}']`).prop('r')).toEqual( + size / 2 + ) + }) + }) + + it('dynamic node size', () => { + const computeSize = (node: { id: string; index: number }) => 10 + node.index * 2 + const nodesWithIndex = sampleData.nodes.map((node, index) => ({ + ...node, + index, + })) + const wrapper = mount( + + {...baseProps} + data={{ + nodes: nodesWithIndex, + links: sampleData.links, + }} + nodeSize={computeSize} + /> + ) + + nodesWithIndex.forEach(node => { + expect(wrapper.find(`circle[data-testid='node.${node.id}']`).prop('r')).toEqual( + computeSize(node) / 2 + ) + }) + }) +}) + +describe('tooltip', () => { + it('default node tooltip', () => { + const wrapper = mount() + + sampleData.nodes.forEach(node => { + const nodeElement = wrapper.find(`circle[data-testid='node.${node.id}']`) + + nodeElement.simulate('mouseenter') + + const tooltip = wrapper.find(svgDefaultProps.nodeTooltip).childAt(0).childAt(0) + expect(tooltip.exists()).toBeTruthy() + expect(tooltip.text()).toEqual(node.id) + + nodeElement.simulate('mouseleave') + expect(wrapper.find(svgDefaultProps.nodeTooltip).children()).toHaveLength(0) + }) + }) +}) diff --git a/website/src/data/components/network/mapper.ts b/website/src/data/components/network/mapper.ts index e4028a4ba3..e5ba69174e 100644 --- a/website/src/data/components/network/mapper.ts +++ b/website/src/data/components/network/mapper.ts @@ -2,7 +2,7 @@ import { settingsMapper } from '../../../lib/settings' export const dynamicNodeSizeValue = 'dynamic: (node: InputNode) => node.radius * 2' export const dynamicLinkThicknessValue = - 'dynamic: (link: ComputedLink) => (2 - link.source.depth) * 2' + 'dynamic: (link: ComputedLink) => (2 - link.source.data.depth) * 2' export default settingsMapper({ nodeSize: (value: number | typeof dynamicNodeSizeValue) => { @@ -14,7 +14,7 @@ export default settingsMapper({ }, linkThickness: (value: number | typeof dynamicLinkThicknessValue) => { if (value === dynamicLinkThicknessValue) { - return (link: any) => (2 - link.source.depth) * 3 + return (link: any) => (2 - link.source.data.depth) * 3 } return value diff --git a/website/src/data/components/network/meta.yml b/website/src/data/components/network/meta.yml index 9af90efebf..9098ba5d9b 100644 --- a/website/src/data/components/network/meta.yml +++ b/website/src/data/components/network/meta.yml @@ -9,7 +9,6 @@ Network: tags: - svg - isomorphic - - experimental stories: - label: Custom node tooltip link: network--custom-node-tooltip diff --git a/website/src/data/components/network/props.ts b/website/src/data/components/network/props.ts index eeec408db1..7faa225525 100644 --- a/website/src/data/components/network/props.ts +++ b/website/src/data/components/network/props.ts @@ -107,7 +107,7 @@ const props: ChartProperty[] = [ defaultValue: defaults.iterations, control: { type: 'range', - min: 60, + min: 30, max: 260, }, }, @@ -236,20 +236,6 @@ const props: ChartProperty[] = [ flavors: ['svg'], defaultValue: defaults.linkBlendMode, }), - annotations({ - target: 'nodes', - flavors: allFlavors, - newDefaults: { - type: 'circle', - match: { id: '0' }, - note: 'New annotation', - noteX: 160, - noteY: 36, - offset: 6, - noteTextOffset: 5, - borderRadius: 3, - }, - }), isInteractive({ flavors: allFlavors, defaultValue: defaults.isInteractive }), { key: 'nodeTooltip', @@ -296,6 +282,20 @@ const props: ChartProperty[] = [ required: false, flavors: allFlavors, }, + annotations({ + target: 'nodes', + flavors: allFlavors, + newDefaults: { + type: 'circle', + match: { id: '0' }, + note: 'New annotation', + noteX: 160, + noteY: 36, + offset: 6, + noteTextOffset: 5, + borderRadius: 3, + }, + }), { key: 'layers', type: `('links' | 'nodes')[] | FunctionComponent`, diff --git a/website/src/pages/network/index.tsx b/website/src/pages/network/index.tsx index 1c72866aea..f639017543 100644 --- a/website/src/pages/network/index.tsx +++ b/website/src/pages/network/index.tsx @@ -1,6 +1,6 @@ import React from 'react' import { graphql, useStaticQuery } from 'gatsby' -import { ResponsiveNetwork, svgDefaultProps as defaults } from '@nivo/network' +import { ComputedNode, ResponsiveNetwork, svgDefaultProps as defaults } from '@nivo/network' import { generateNetworkData } from '@nivo/generators' import { ComponentTemplate } from '../../components/components/ComponentTemplate' import meta from '../../data/components/network/meta.yml' @@ -19,7 +19,7 @@ const initialProperties = Object.freeze({ }, linkDistance: 30, - repulsivity: 6, + repulsivity: 3, iterations: 60, nodeSize: dynamicNodeSizeValue, @@ -78,26 +78,10 @@ const Network = () => { {(properties, data, theme, logAction) => { return ( { + onClick={(node: ComputedNode) => { logAction({ type: 'click', label: `[node] id: ${node.id}, index: ${node.index}`, diff --git a/website/src/theming/theme.ts b/website/src/theming/theme.ts index ef35481c6b..cdeb5fde9e 100644 --- a/website/src/theming/theme.ts +++ b/website/src/theming/theme.ts @@ -94,21 +94,22 @@ const lightTheme: DefaultTheme = { annotations: { text: { fill: '#333333', - outlineWidth: 3, + outlineWidth: 1.5, outlineColor: '#ffffff', }, link: { - stroke: '#333333', + stroke: '#6c6363', outlineWidth: 2, outlineColor: '#ffffff', }, outline: { - stroke: '#333333', + stroke: '#6c6363', + strokeWidth: 1.5, outlineWidth: 2, outlineColor: '#ffffff', }, symbol: { - fill: '#333333', + fill: '#6c6363', outlineWidth: 2, outlineColor: '#ffffff', }, @@ -216,21 +217,22 @@ const darkTheme: DefaultTheme = { annotations: { text: { fill: '#dddddd', - outlineWidth: 3, + outlineWidth: 1.5, outlineColor: '#0e1317', }, link: { - stroke: '#ffffff', + stroke: '#8093a4', outlineWidth: 2, outlineColor: '#0e1317', }, outline: { - stroke: '#ffffff', + stroke: '#8093a4', + strokeWidth: 1.5, outlineWidth: 2, outlineColor: '#0e1317', }, symbol: { - fill: '#ffffff', + fill: '#8093a4', outlineWidth: 2, outlineColor: '#0e1317', },