diff --git a/packages/libs/components/src/plots/BipartiteNetwork.tsx b/packages/libs/components/src/plots/BipartiteNetwork.tsx new file mode 100755 index 0000000000..577099871d --- /dev/null +++ b/packages/libs/components/src/plots/BipartiteNetwork.tsx @@ -0,0 +1,158 @@ +import { BipartiteNetworkData, NodeData } from '../types/plots/network'; +import { partition } from 'lodash'; +import { LabelPosition, Link, NodeWithLabel } from './Network'; +import { Graph } from '@visx/network'; +import { Text } from '@visx/text'; +import { CSSProperties } from 'react'; +import { DEFAULT_CONTAINER_HEIGHT } from './PlotlyPlot'; +import Spinner from '../components/Spinner'; + +export interface BipartiteNetworkProps { + /** Bipartite network data */ + data: BipartiteNetworkData; + /** Name of column 1 */ + column1Name?: string; + /** Name of column 2 */ + column2Name?: string; + /** styling for the plot's container */ + containerStyles?: CSSProperties; + /** container name */ + containerClass?: string; + /** shall we show the loading spinner? */ + showSpinner?: boolean; + /** plot width */ + width?: number; +} + +// The BipartiteNetwork function draws a two-column network using visx. This component handles +// the positioning of each column, and consequently the positioning of nodes and links. +export function BipartiteNetwork(props: BipartiteNetworkProps) { + const { + data, + column1Name, + column2Name, + containerStyles = { width: '100%', height: DEFAULT_CONTAINER_HEIGHT }, + containerClass = 'web-components-plot', + showSpinner = false, + width, + } = props; + + // Defaults + // Many of the below can get optional props in the future as we figure out optimal layouts + const DEFAULT_WIDTH = 400; + const DEFAULT_NODE_VERTICAL_SPACE = 30; + const DEFAULT_TOP_PADDING = 40; + const DEFAULT_COLUMN1_X = 100; + const DEFAULT_COLUMN2_X = (width ?? DEFAULT_WIDTH) - DEFAULT_COLUMN1_X; + + // In order to assign coordinates to each node, we'll separate the + // nodes based on their column, then will use their order in the column + // (given by columnXNodeIDs) to finally assign the coordinates. + const nodesByColumn: NodeData[][] = partition(data.nodes, (node) => { + return data.column1NodeIDs.includes(node.id); + }); + + const nodesByColumnWithCoordinates = nodesByColumn.map( + (column, columnIndex) => { + const columnWithCoordinates = column.map((node) => { + // Find the index of the node in the column + type ColumnName = keyof typeof data; + const columnName = ('column' + + (columnIndex + 1) + + 'NodeIDs') as ColumnName; + const indexInColumn = data[columnName].findIndex( + (id) => id === node.id + ); + + return { + // columnIndex of 0 refers to the left-column nodes whereas 1 refers to right-column nodes + x: columnIndex === 0 ? DEFAULT_COLUMN1_X : DEFAULT_COLUMN2_X, + y: DEFAULT_TOP_PADDING + DEFAULT_NODE_VERTICAL_SPACE * indexInColumn, + labelPosition: + columnIndex === 0 ? 'left' : ('right' as LabelPosition), + ...node, + }; + }); + return columnWithCoordinates; + } + ); + + // Assign coordinates to links based on the newly created node coordinates + const linksWithCoordinates = data.links.map((link) => { + const sourceNode = nodesByColumnWithCoordinates[0].find( + (node) => node.id === link.source.id + ); + const targetNode = nodesByColumnWithCoordinates[1].find( + (node) => node.id === link.target.id + ); + return { + ...link, + source: { + x: sourceNode?.x, + y: sourceNode?.y, + ...link.source, + }, + target: { + x: targetNode?.x, + y: targetNode?.y, + ...link.target, + }, + }; + }); + + return ( +
+ + {/* Draw names of node colums if they exist */} + {column1Name && ( + + {column1Name} + + )} + {column2Name && ( + + {column2Name} + + )} + + } + nodeComponent={({ node }) => { + const nodeWithLabelProps = { + node: node, + labelPosition: node.labelPosition, + }; + return ; + }} + /> + + {showSpinner && } +
+ ); +} diff --git a/packages/libs/components/src/plots/Network.tsx b/packages/libs/components/src/plots/Network.tsx index c9e2702f18..66243bb1cf 100755 --- a/packages/libs/components/src/plots/Network.tsx +++ b/packages/libs/components/src/plots/Network.tsx @@ -2,13 +2,15 @@ import { DefaultNode } from '@visx/network'; import { Text } from '@visx/text'; import { LinkData, NodeData } from '../types/plots/network'; +export type LabelPosition = 'right' | 'left'; + interface NodeWithLabelProps { /** Network node */ node: NodeData; /** Function to run when a user clicks either the node or label */ onClick?: () => void; /** Should the label be drawn to the left or right of the node? */ - labelPosition?: 'right' | 'left'; + labelPosition?: LabelPosition; /** Font size for the label. Ex. "1em" */ fontSize?: string; /** Font weight for the label */ @@ -20,9 +22,10 @@ interface NodeWithLabelProps { // NodeWithLabel draws one node and an optional label for the node. Both the node and // label can be styled. export function NodeWithLabel(props: NodeWithLabelProps) { - const DEFAULT_NODE_RADIUS = 4; - const DEFAULT_NODE_COLOR = '#aaa'; + const DEFAULT_NODE_RADIUS = 6; + const DEFAULT_NODE_COLOR = '#fff'; const DEFAULT_STROKE_WIDTH = 1; + const DEFAULT_STROKE = '#111'; const { node, @@ -58,7 +61,7 @@ export function NodeWithLabel(props: NodeWithLabelProps) { r={nodeRadius} fill={color ?? DEFAULT_NODE_COLOR} onClick={onClick} - stroke={stroke} + stroke={stroke ?? DEFAULT_STROKE} strokeWidth={strokeWidth ?? DEFAULT_STROKE_WIDTH} /> {/* Note that Text becomes a tspan */} diff --git a/packages/libs/components/src/stories/plots/BipartiteNetwork.stories.tsx b/packages/libs/components/src/stories/plots/BipartiteNetwork.stories.tsx new file mode 100755 index 0000000000..2fdbb6afe6 --- /dev/null +++ b/packages/libs/components/src/stories/plots/BipartiteNetwork.stories.tsx @@ -0,0 +1,116 @@ +import { Story, Meta } from '@storybook/react/types-6-0'; +import { + NodeData, + LinkData, + BipartiteNetworkData, +} from '../../types/plots/network'; +import { + BipartiteNetwork, + BipartiteNetworkProps, +} from '../../plots/BipartiteNetwork'; +import { twoColorPalette } from '../../types/plots'; + +export default { + title: 'Plots/Network/BipartiteNetwork', + component: BipartiteNetwork, +} as Meta; + +interface TemplateProps { + data: BipartiteNetworkData; + column1Name?: string; + column2Name?: string; + loading?: boolean; +} + +// Template for showcasing our BipartiteNetwork component. +const Template: Story = (args) => { + const bipartiteNetworkProps: BipartiteNetworkProps = { + data: args.data, + column1Name: args.column1Name, + column2Name: args.column2Name, + showSpinner: args.loading, + width: 500, + }; + return ; +}; + +/** + * Stories + */ + +// A basic bipartite network +const simpleData = genBipartiteNetwork(20, 10); +export const Simple = Template.bind({}); +Simple.args = { + data: simpleData, +}; + +// A network with lots and lots of points! +const manyPointsData = genBipartiteNetwork(1000, 100); +export const ManyPoints = Template.bind({}); +ManyPoints.args = { + data: manyPointsData, +}; + +// With column names +export const WithColumnNames = Template.bind({}); +WithColumnNames.args = { + data: simpleData, + column1Name: 'Column 1', + column2Name: 'Column 2', +}; + +// Loading with a spinner +export const Loading = Template.bind({}); +Loading.args = { + data: simpleData, + column1Name: 'Column 1', + column2Name: 'Column 2', + loading: true, +}; + +// Gerenate a bipartite network with a given number of nodes and random edges +function genBipartiteNetwork( + column1nNodes: number, + column2nNodes: number +): BipartiteNetworkData { + // Create the first column of nodes + const column1Nodes: NodeData[] = [...Array(column1nNodes).keys()].map((i) => { + return { + id: String(i), + label: 'Node ' + String(i), + }; + }); + + // Create the second column of nodes + const column2Nodes: NodeData[] = [...Array(column2nNodes).keys()].map((i) => { + return { + id: String(i + column1nNodes), + label: 'Node ' + String(i + column1nNodes), + }; + }); + + // Create links + // Not worried about exactly how many edges we're adding just yet since this is + // used for stories only. Adding color here to mimic what the visualization + // will do. + const links: LinkData[] = [...Array(column1nNodes * 2).keys()].map(() => { + return { + source: column1Nodes[Math.floor(Math.random() * column1nNodes)], + target: column2Nodes[Math.floor(Math.random() * column2nNodes)], + strokeWidth: Math.random() * 2, + color: Math.random() > 0.5 ? twoColorPalette[0] : twoColorPalette[1], + }; + }); + + const nodes = column1Nodes.concat(column2Nodes); + const column1NodeIDs = column1Nodes.map((node) => node.id); + const column2NodeIDs = column2Nodes.map((node) => node.id); + + return { + nodes, + links, + column1NodeIDs, + column2NodeIDs, + }; +} diff --git a/packages/libs/components/src/stories/plots/NodeWithLabel.stories.tsx b/packages/libs/components/src/stories/plots/NodeWithLabel.stories.tsx index f69368b550..5581465b64 100755 --- a/packages/libs/components/src/stories/plots/NodeWithLabel.stories.tsx +++ b/packages/libs/components/src/stories/plots/NodeWithLabel.stories.tsx @@ -4,7 +4,7 @@ import { NodeWithLabel } from '../../plots/Network'; import { Group } from '@visx/group'; export default { - title: 'Plots/Network', + title: 'Plots/Network/NodeWithLabel', component: NodeWithLabel, } as Meta; diff --git a/packages/libs/components/src/types/plots/addOns.ts b/packages/libs/components/src/types/plots/addOns.ts index 00480ff4bb..27b97bdb3f 100644 --- a/packages/libs/components/src/types/plots/addOns.ts +++ b/packages/libs/components/src/types/plots/addOns.ts @@ -339,6 +339,9 @@ export const significanceColors: SignificanceColors = { low: '#007F5C', }; +// Color palette optimized for two colors +export const twoColorPalette: string[] = ['#0EADA5', '#AD3C00']; + /** truncated axis flags */ export type AxisTruncationAddon = { /** truncation config (flags) to show truncated axis (true) or not (false) */ diff --git a/packages/libs/components/src/types/plots/network.ts b/packages/libs/components/src/types/plots/network.ts index fadf279582..a8cfb8a5c4 100755 --- a/packages/libs/components/src/types/plots/network.ts +++ b/packages/libs/components/src/types/plots/network.ts @@ -1,14 +1,11 @@ // Types required for creating networks export type NodeData = { - /** For now x and y are required. Eventually the network should have a default layout so that - * these become unnecessary in certain situations. - */ - /** The x coordinate of the node */ - x: number; - /** The y coordinate of the node */ - y: number; /** Node ID. Must be unique in the network! */ id: string; + /** The x coordinate of the node */ + x?: number; + /** The y coordinate of the node */ + y?: number; /** Node color */ color?: string; /** Node radius */