Skip to content

Commit

Permalink
Merge pull request #412 from VEuPathDB/add-basic-network-components
Browse files Browse the repository at this point in the history
Add basic network components
  • Loading branch information
asizemore committed Aug 10, 2023
2 parents e5396e5 + addc7cd commit cc66423
Show file tree
Hide file tree
Showing 4 changed files with 331 additions and 0 deletions.
106 changes: 106 additions & 0 deletions packages/libs/components/src/plots/Network.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { DefaultNode } from '@visx/network';
import { Text } from '@visx/text';
import { LinkData, NodeData } from '../types/plots/network';

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';
/** Font size for the label. Ex. "1em" */
fontSize?: string;
/** Font weight for the label */
fontWeight?: number;
/** Color for the label */
labelColor?: string;
}

// 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_STROKE_WIDTH = 1;

const {
node,
onClick,
labelPosition = 'right',
fontSize = '1em',
fontWeight = 200,
labelColor = '#000',
} = props;

const { color, label, stroke, strokeWidth } = node;

const nodeRadius = node.r ?? DEFAULT_NODE_RADIUS;

// Calculate where the label should be posiitoned based on
// total size of the node.
let textXOffset: number;
let textAnchor: 'start' | 'end';

if (labelPosition === 'right') {
textXOffset = 4 + nodeRadius;
if (strokeWidth) textXOffset = textXOffset + strokeWidth;
textAnchor = 'start';
} else {
textXOffset = -4 - nodeRadius;
if (strokeWidth) textXOffset = textXOffset - strokeWidth;
textAnchor = 'end';
}

return (
<>
<DefaultNode
r={nodeRadius}
fill={color ?? DEFAULT_NODE_COLOR}
onClick={onClick}
stroke={stroke}
strokeWidth={strokeWidth ?? DEFAULT_STROKE_WIDTH}
/>
{/* Note that Text becomes a tspan */}
<Text
x={textXOffset}
textAnchor={textAnchor}
fontSize={fontSize}
verticalAnchor="middle"
onClick={onClick}
fontWeight={fontWeight}
fill={labelColor}
style={{ cursor: 'pointer' }}
>
{label}
</Text>
</>
);
}

export interface LinkProps {
link: LinkData;
// onClick?: () => void; To add in the future, maybe also some hover action
}

// Link component draws a linear edge between two nodes.
// Eventually can grow into drawing directed edges (edges with arrows) when the time comes.
export function Link(props: LinkProps) {
const DEFAULT_LINK_WIDTH = 1;
const DEFAULT_COLOR = '#222';
const DEFAULT_OPACITY = 0.95;

const { link } = props;

return (
<line
x1={link.source.x}
y1={link.source.y}
x2={link.target.x}
y2={link.target.y}
strokeWidth={link.strokeWidth ?? DEFAULT_LINK_WIDTH}
stroke={link.color ?? DEFAULT_COLOR}
strokeOpacity={link.opacity ?? DEFAULT_OPACITY}
/>
);
}
90 changes: 90 additions & 0 deletions packages/libs/components/src/stories/plots/Network.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Story, Meta } from '@storybook/react/types-6-0';
import { Graph } from '@visx/network';
import { NodeData, LinkData, NetworkData } from '../../types/plots/network';
import { Link, NodeWithLabel } from '../../plots/Network';

export default {
title: 'Plots/Network',
component: NodeWithLabel,
} as Meta;

// For simplicity, make square svgs with the following height and width
const DEFAULT_PLOT_SIZE = 500;

interface TemplateProps {
data: NetworkData;
}

// This template is a simple network that highlights our NodeWithLabel and Link components.
const Template: Story<TemplateProps> = (args) => {
return (
<svg width={DEFAULT_PLOT_SIZE} height={DEFAULT_PLOT_SIZE}>
<Graph
graph={args.data}
// Our Link component has nice defaults and in the future can
// carry more complex events.
linkComponent={({ link }) => <Link link={link} />}
// The node components are already transformed using x and y.
// So inside the node component all coords should be relative to this
// initial transform.
nodeComponent={({ node }) => {
const nodeWithLabelProps = {
node: node,
};
return <NodeWithLabel {...nodeWithLabelProps} />;
}}
/>
</svg>
);
};

/**
* Stories
*/

// A simple network with node labels
const simpleData = genNetwork(20, true, DEFAULT_PLOT_SIZE, DEFAULT_PLOT_SIZE);
export const Simple = Template.bind({});
Simple.args = {
data: simpleData,
};

// A network with lots and lots of points!
const manyPointsData = genNetwork(
100,
false,
DEFAULT_PLOT_SIZE,
DEFAULT_PLOT_SIZE
);
export const ManyPoints = Template.bind({});
ManyPoints.args = {
data: manyPointsData,
};

// Gerenate a network with a given number of nodes and random edges
function genNetwork(
nNodes: number,
addNodeLabel: boolean,
height: number,
width: number
) {
// Create nodes with random positioning, an id, and optionally a label
const nodes: NodeData[] = [...Array(nNodes).keys()].map((i) => {
return {
x: Math.floor(Math.random() * width),
y: Math.floor(Math.random() * height),
id: String(i),
label: addNodeLabel ? 'Node ' + String(i) : undefined,
};
});

// Create {nNodes} links. Just basic links no weighting or colors for now.
const links: LinkData[] = [...Array(nNodes).keys()].map(() => {
return {
source: nodes[Math.floor(Math.random() * nNodes)],
target: nodes[Math.floor(Math.random() * nNodes)],
};
});

return { nodes, links } as NetworkData;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Story, Meta } from '@storybook/react/types-6-0';
import { NodeData } from '../../types/plots/network';
import { NodeWithLabel } from '../../plots/Network';
import { Group } from '@visx/group';

export default {
title: 'Plots/Network',
component: NodeWithLabel,
} as Meta;

interface TemplateProps {
data: NodeData;
onClick: () => void;
labelPosition?: 'right' | 'left';
fontWeight?: number;
labelColor?: string;
}

// Simply draw a node!
const Template: Story<TemplateProps> = (args) => {
const { data, labelPosition, fontWeight, labelColor, onClick } = args;

const nodeWithLabelProps = {
node: data,
onClick: onClick,
labelPosition: labelPosition,
fontWeight: fontWeight,
labelColor: labelColor,
};

return (
<svg width={400} height={400}>
<Group transform={'translate(' + data.x + ', ' + data.y + ')'}>
<NodeWithLabel {...nodeWithLabelProps} />
</Group>
</svg>
);
};

/**
* Stories
*/

// Basic node with a label
const myNode = {
x: 100,
y: 100,
id: 'id',
label: 'label',
};

export const NodeWithALabel = Template.bind({});
NodeWithALabel.args = {
data: myNode,
labelPosition: 'left',
};

const myFancyNode = {
x: 100,
y: 100,
id: 'id',
label: 'a fancy long label',
r: 9,
color: '#118899',
stroke: '#000',
strokeWidth: 3,
};

export const FancyNodeWithLabel = Template.bind({});
FancyNodeWithLabel.args = {
data: myFancyNode,
labelPosition: 'right',
labelColor: '#008822',
fontWeight: 600,
};

export const ClickNodeOrLabel = Template.bind({});
ClickNodeOrLabel.args = {
data: myNode,
labelPosition: 'right',
labelColor: '#008822',
fontWeight: 600,
onClick: () => {
console.log('clicked!');
},
};
49 changes: 49 additions & 0 deletions packages/libs/components/src/types/plots/network.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// 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;
/** Node color */
color?: string;
/** Node radius */
r?: number;
/** User-friendly node label */
label?: string;
/** Color for the stroke of the node */
stroke?: string;
/** Width of node stroke */
strokeWidth?: number;
};

export type LinkData = {
/** The beginning node of the link */
source: NodeData;
/** The ending node of the link */
target: NodeData;
/** Link stroke width */
strokeWidth?: number;
/** Link color */
color?: string;
/** Link opacity. Must be between 0 and 1 */
opacity?: number;
};

/** NetworkData is the same format accepted by visx's Graph component. */
export type NetworkData = {
nodes: NodeData[];
links: LinkData[];
};

/** Bipartite network data is a regular network with addiitonal declarations of
* nodes in each of the two columns. IDs in columnXNodeIDs must match node ids exactly.
*/
export type BipartiteNetworkData = {
column1NodeIDs: string[];
column2NodeIDs: string[];
} & NetworkData;

0 comments on commit cc66423

Please sign in to comment.