Skip to content

Commit

Permalink
Merge pull request #517 from VEuPathDB/bipartite-network-component
Browse files Browse the repository at this point in the history
Add bipartite network component
  • Loading branch information
asizemore authored Sep 26, 2023
2 parents 81b3dae + 56ad38d commit 117371c
Show file tree
Hide file tree
Showing 6 changed files with 289 additions and 12 deletions.
158 changes: 158 additions & 0 deletions packages/libs/components/src/plots/BipartiteNetwork.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={containerClass}
style={{ ...containerStyles, position: 'relative' }}
>
<svg
width={width ?? DEFAULT_WIDTH}
height={
Math.max(data.column1NodeIDs.length, data.column2NodeIDs.length) *
DEFAULT_NODE_VERTICAL_SPACE +
DEFAULT_TOP_PADDING
}
>
{/* Draw names of node colums if they exist */}
{column1Name && (
<Text
x={DEFAULT_COLUMN1_X}
y={DEFAULT_TOP_PADDING / 2}
textAnchor="end"
>
{column1Name}
</Text>
)}
{column2Name && (
<Text
x={DEFAULT_COLUMN2_X}
y={DEFAULT_TOP_PADDING / 2}
textAnchor="start"
>
{column2Name}
</Text>
)}

<Graph
graph={{
nodes: nodesByColumnWithCoordinates[0].concat(
nodesByColumnWithCoordinates[1]
),
links: linksWithCoordinates,
}}
// Using our Link component so that it uses our nice defaults and
// can better expand to handle more complex events (hover and such).
linkComponent={({ link }) => <Link link={link} />}
nodeComponent={({ node }) => {
const nodeWithLabelProps = {
node: node,
labelPosition: node.labelPosition,
};
return <NodeWithLabel {...nodeWithLabelProps} />;
}}
/>
</svg>
{showSpinner && <Spinner />}
</div>
);
}
11 changes: 7 additions & 4 deletions packages/libs/components/src/plots/Network.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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,
Expand Down Expand Up @@ -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 */}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TemplateProps> = (args) => {
const bipartiteNetworkProps: BipartiteNetworkProps = {
data: args.data,
column1Name: args.column1Name,
column2Name: args.column2Name,
showSpinner: args.loading,
width: 500,
};
return <BipartiteNetwork {...bipartiteNetworkProps} />;
};

/**
* 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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
3 changes: 3 additions & 0 deletions packages/libs/components/src/types/plots/addOns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
11 changes: 4 additions & 7 deletions packages/libs/components/src/types/plots/network.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down

0 comments on commit 117371c

Please sign in to comment.