diff --git a/packages/libs/components/src/plots/Network.tsx b/packages/libs/components/src/plots/Network.tsx new file mode 100755 index 0000000000..c9e2702f18 --- /dev/null +++ b/packages/libs/components/src/plots/Network.tsx @@ -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 ( + <> + + {/* Note that Text becomes a tspan */} + + {label} + + + ); +} + +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 ( + + ); +} diff --git a/packages/libs/components/src/stories/plots/Network.stories.tsx b/packages/libs/components/src/stories/plots/Network.stories.tsx new file mode 100755 index 0000000000..fa4d41c9b8 --- /dev/null +++ b/packages/libs/components/src/stories/plots/Network.stories.tsx @@ -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 = (args) => { + return ( + + } + // 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 ; + }} + /> + + ); +}; + +/** + * 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; +} diff --git a/packages/libs/components/src/stories/plots/NodeWithLabel.stories.tsx b/packages/libs/components/src/stories/plots/NodeWithLabel.stories.tsx new file mode 100755 index 0000000000..f69368b550 --- /dev/null +++ b/packages/libs/components/src/stories/plots/NodeWithLabel.stories.tsx @@ -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 = (args) => { + const { data, labelPosition, fontWeight, labelColor, onClick } = args; + + const nodeWithLabelProps = { + node: data, + onClick: onClick, + labelPosition: labelPosition, + fontWeight: fontWeight, + labelColor: labelColor, + }; + + return ( + + + + + + ); +}; + +/** + * 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!'); + }, +}; diff --git a/packages/libs/components/src/types/plots/network.ts b/packages/libs/components/src/types/plots/network.ts new file mode 100755 index 0000000000..fadf279582 --- /dev/null +++ b/packages/libs/components/src/types/plots/network.ts @@ -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;