Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add bipartite network component #517

Merged
merged 11 commits into from
Sep 26, 2023
151 changes: 151 additions & 0 deletions packages/libs/components/src/plots/BipartiteNetwork.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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;
}

// 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,
} = props;

// Defaults
const DEFAULT_COLUMN1_X = 100;
const DEFAULT_COLUMN2_X = 300;
const DEFAULT_NODE_VERTICAL_SPACE = 30;
const DEFAULT_TOP_PADDING = 40;

// 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 {
asizemore marked this conversation as resolved.
Show resolved Hide resolved
x: columnIndex ? DEFAULT_COLUMN2_X : DEFAULT_COLUMN1_X,
y: DEFAULT_TOP_PADDING + DEFAULT_NODE_VERTICAL_SPACE * indexInColumn,
labelPosition: columnIndex ? 'right' : ('left' 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={400}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like a magic number. Should it be defined by an optional prop with a default value?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes definitely!

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,115 @@
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,
};
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
Loading