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

Network shapes #41

Merged
merged 3 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion frontend/src/components/Network.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
.node-symbol {
width: 20px;
height: 20px;
border-radius: 999px;
}

.edge-symbol {
Expand Down
224 changes: 141 additions & 83 deletions frontend/src/components/Network.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,17 @@ import Popover from "@/components/Popover";
import type { Option } from "@/components/SelectSingle";
import SelectSingle from "@/components/SelectSingle";
import Slider from "@/components/Slider";
import { getColorMap } from "@/util/color";
import { getColorMap, mixColors } from "@/util/color";
import {
downloadCsv,
downloadJpg,
downloadJson,
downloadPng,
downloadTsv,
} from "@/util/download";
import { useTheme } from "@/util/hooks";
import { lerp } from "@/util/math";
import { getShapeMap } from "@/util/shapes";
import { formatNumber } from "@/util/string";
import classes from "./Network.module.css";

Expand Down Expand Up @@ -108,7 +110,8 @@ const maxEdgeSize = 3;
const edgeLength = maxNodeSize * 1.5;
const fontSize = 10;
const padding = 10;
const selectedColor = "black";
const minZoom = 0.2;
const maxZoom = 5;
const aspectRatio = 16 / 9;
const boundingBox = {
x1: 8 * -minNodeSize * aspectRatio,
Expand All @@ -117,71 +120,6 @@ const boundingBox = {
y2: 8 * minNodeSize,
};

/** style accessors, extracted to avoid repetition */
const getNodeLabel = (node: NodeSingular) => node.data().label;
const getNodeSize = (node: NodeSingular) => node.data().size;
const getNodeColor = (node: NodeSingular) =>
node.selected() ? selectedColor : node.data().color;
const getNodeOpacity = (node: NodeSingular) => (node.active() ? 0.1 : 0);
const getEdgeLabel = (edge: EdgeSingular) =>
truncate(edge.data().label, { length: 10 });
const getEdgeSize = (edge: EdgeSingular) => edge.data().size;
const getEdgeArrowSize = () => 1;
const getEdgeColor = (edge: EdgeSingular) =>
edge.selected() ? selectedColor : edge.data().color;
const getEdgeArrow =
(directions: Edge["direction"][]) => (edge: EdgeSingular) =>
directions.includes(edge.data().direction) ? "triangle" : "none";
const getEdgeOpacity = (node: NodeSingular) => (node.active() ? 0.1 : 0);

/** node style options */
const nodeStyle: Css.Node | Css.Core | Css.Overlay = {
width: getNodeSize,
height: getNodeSize,
backgroundColor: getNodeColor,
label: getNodeLabel,
"font-size": fontSize,
color: "white",
"text-outline-color": "black",
"text-outline-opacity": 1,
"text-outline-width": fontSize / 15,
"text-halign": "center",
"text-valign": "center",
"text-max-width": getNodeSize,
"text-wrap": "wrap",
// @ts-expect-error no type defs
"underlay-padding": minNodeSize / 4,
"underlay-opacity": getNodeOpacity,
"underlay-shape": "ellipse",
"overlay-opacity": 0,
};

/** edge style options */
const edgeStyle: Css.Edge | Css.Core | Css.Overlay = {
width: getEdgeSize,
"curve-style": "bezier",
"control-point-step-size": maxNodeSize,
"line-color": getEdgeColor,
"source-arrow-color": getEdgeColor,
"target-arrow-color": getEdgeColor,
"source-arrow-shape": getEdgeArrow([0, 1]),
"target-arrow-shape": getEdgeArrow([0, -1]),
"arrow-scale": getEdgeArrowSize,
label: getEdgeLabel,
"font-size": fontSize,
color: "white",
"text-outline-color": "black",
"text-outline-opacity": 1,
"text-outline-width": fontSize / 15,
"text-rotation": "autorotate",
// @ts-expect-error no type defs
"underlay-padding": minEdgeSize / 2,
"underlay-opacity": getEdgeOpacity,
"underlay-shape": "ellipse",
"overlay-opacity": 0,
"loop-direction": "0",
};

/** import non-built-in layout algorithms */
cytoscape.use(fcose);
cytoscape.use(dagre);
Expand Down Expand Up @@ -332,10 +270,14 @@ const layoutOptions = layouts.map(({ name, label }) => ({
})) satisfies Option[];

const Network = ({ nodes: _nodes, edges: _edges }: Props) => {
const container = useRef(null);
const root = useRef<HTMLDivElement | null>(null);
const container = useRef<HTMLDivElement | null>(null);
const graph = useRef<Core | null>(null);
const layout = useRef<Layouts | null>(null);

/** reactive CSS vars */
const theme = useTheme();

/** selected nodes/edges */
const [selectedItems, setSelectedItems] = useState<(Node | Edge)[]>([]);

Expand All @@ -359,6 +301,11 @@ const Network = ({ nodes: _nodes, edges: _edges }: Props) => {
() => getColorMap(_nodes.map((node) => node.type ?? "")),
[_nodes],
);
/** map of node types to shapes */
const nodeShapes = useMemo(
() => getShapeMap(_nodes.map((node) => node.type ?? "")),
[_nodes],
);
/** range of node strengths */
const [minNodeStrength = 0, maxNodeStrength = 1] = useMemo(
() => extent(_nodes.flatMap((node) => node.strength ?? [])),
Expand All @@ -381,8 +328,16 @@ const Network = ({ nodes: _nodes, edges: _edges }: Props) => {
maxNodeSize,
),
color: nodeColors[node.type ?? ""]!,
shape: nodeShapes[node.type ?? ""]!,
})),
[_nodes, maxNodes, minNodeStrength, maxNodeStrength, nodeColors],
[
_nodes,
maxNodes,
minNodeStrength,
maxNodeStrength,
nodeColors,
nodeShapes,
],
);

type Node = (typeof nodes)[number];
Expand Down Expand Up @@ -424,19 +379,16 @@ const Network = ({ nodes: _nodes, edges: _edges }: Props) => {
[_edges, nodes, minEdgeStrength, maxEdgeStrength, edgeColors],
);

/** init cytoscape graph and attach event listeners */
useEffect(() => {
if (!container.current) return;
if (graph.current) return;

/** init graph */
graph.current = cytoscape({
container: container.current,
minZoom: 0.2,
maxZoom: 5,
style: [
{ selector: "node", style: nodeStyle },
{ selector: "edge", style: edgeStyle },
],
minZoom,
maxZoom,
});

/** reset view */
Expand Down Expand Up @@ -486,8 +438,101 @@ const Network = ({ nodes: _nodes, edges: _edges }: Props) => {
/** adjust pan */
graph.current.pan(pan);
});
}, [nodes, edges]);

/** indicate hover-ability */
const over = () => {
if (!container.current) return;
container.current.style.cursor = "pointer";
};
const out = () => {
if (!container.current) return;
container.current.style.cursor = "";
};
graph.current.on("mouseover", "node", over);
graph.current.on("mouseout", "node", out);
graph.current.on("mouseover", "edge", over);
graph.current.on("mouseout", "edge", out);
}, []);

/** update node/edge styles */
useEffect(() => {
if (!graph.current) return;

/** style accessors, extracted to avoid repetition */
const getNodeLabel = (node: NodeSingular) => node.data().label;
const getNodeSize = (node: NodeSingular) => node.data().size;
const getNodeColor = (node: NodeSingular) =>
mixColors(
node.selected() ? (theme["--black"] ?? "") : node.data().color,
theme["--white"] ?? "",
);
const getNodeShape = (node: NodeSingular) => node.data().shape;
const getNodeOpacity = (node: NodeSingular) => (node.active() ? 0.1 : 0);
const getEdgeLabel = (edge: EdgeSingular) =>
truncate(edge.data().label, { length: 10 });
const getEdgeSize = (edge: EdgeSingular) => edge.data().size;
const getEdgeArrowSize = () => 1;
const getEdgeColor = (edge: EdgeSingular) =>
mixColors(
edge.selected() ? (theme["--black"] ?? "") : edge.data().color,
theme["--white"] ?? "",
);
const getEdgeArrow =
(directions: Edge["direction"][]) => (edge: EdgeSingular) =>
directions.includes(edge.data().direction) ? "triangle" : "none";
const getEdgeOpacity = (node: NodeSingular) => (node.active() ? 0.1 : 0);

/** node style options */
const nodeStyle: Css.Node | Css.Core | Css.Overlay = {
width: getNodeSize,
height: getNodeSize,
backgroundColor: getNodeColor,
shape: "polygon",
"shape-polygon-points": getNodeShape,
label: getNodeLabel,
"font-size": fontSize,
color: theme["--black"],
"text-halign": "center",
"text-valign": "center",
"text-max-width": getNodeSize,
"text-wrap": "wrap",
// @ts-expect-error no type defs
"underlay-padding": minNodeSize / 4,
"underlay-opacity": getNodeOpacity,
"underlay-shape": "ellipse",
"overlay-opacity": 0,
};

/** edge style options */
const edgeStyle: Css.Edge | Css.Core | Css.Overlay = {
width: getEdgeSize,
"curve-style": "bezier",
"control-point-step-size": maxNodeSize,
"line-color": getEdgeColor,
"source-arrow-color": getEdgeColor,
"target-arrow-color": getEdgeColor,
"source-arrow-shape": getEdgeArrow([0, 1]),
"target-arrow-shape": getEdgeArrow([0, -1]),
"arrow-scale": getEdgeArrowSize,
label: getEdgeLabel,
"font-size": fontSize,
color: theme["--black"],
"text-rotation": "autorotate",
// @ts-expect-error no type defs
"underlay-padding": minEdgeSize / 2,
"underlay-opacity": getEdgeOpacity,
"underlay-shape": "ellipse",
"overlay-opacity": 0,
"loop-direction": "0",
};

graph.current.style([
{ selector: "node", style: nodeStyle },
{ selector: "edge", style: edgeStyle },
]);
}, [theme]);

/** update nodes/edges and layout */
useEffect(() => {
if (!graph.current) return;

Expand Down Expand Up @@ -527,9 +572,9 @@ const Network = ({ nodes: _nodes, edges: _edges }: Props) => {
}, [nodes, edges, layoutParams]);

/** on resize */
useResizeObserver(container, () => {
useResizeObserver(root, () => {
graph.current?.resize();
graph.current?.fit(undefined, padding);
// graph.current?.fit(undefined, padding);
});

/** download network */
Expand All @@ -550,11 +595,15 @@ const Network = ({ nodes: _nodes, edges: _edges }: Props) => {
if (format === "csv" || format === "tsv") {
const download = format === "csv" ? downloadCsv : downloadTsv;
download(
nodes.map((node) => omit(node, ["color", "size", "strength"])),
nodes.map((node) =>
omit(node, ["color", "shape", "size", "strength"]),
),
["network", "nodes"],
);
download(
edges.map((edge) => omit(edge, ["color", "size", "strength"])),
edges.map((edge) =>
omit(edge, ["color", "shape", "size", "strength"]),
),
["network", "edges"],
);
}
Expand All @@ -570,6 +619,7 @@ const Network = ({ nodes: _nodes, edges: _edges }: Props) => {
return (
<Flex direction="column" full>
<div
ref={root}
className={clsx(classes.network, expanded && classes.expanded)}
style={{ aspectRatio }}
>
Expand All @@ -579,6 +629,7 @@ const Network = ({ nodes: _nodes, edges: _edges }: Props) => {
hAlign="left"
vAlign="top"
className={classes.legend}
tabIndex={0}
>
{selectedItems.length ? (
/** show info about selected nodes/edges */
Expand All @@ -601,6 +652,7 @@ const Network = ({ nodes: _nodes, edges: _edges }: Props) => {
"strength",
"size",
"color",
"shape",
]),
).map(([key, value]) => (
<Fragment key={key}>
Expand All @@ -626,10 +678,16 @@ const Network = ({ nodes: _nodes, edges: _edges }: Props) => {

{Object.entries(nodeColors).map(([key, value]) => (
<Flex key={key} gap="sm" wrap={false}>
<div
<svg
viewBox="-1 -1 2 2"
className={classes["node-symbol"]}
style={{ background: value }}
/>
style={{ color: value }}
>
<polygon
fill="currentColor"
points={nodeShapes[key]?.join(" ")}
/>
</svg>
<div className={clsx(!key && "secondary")}>
{startCase(key) || "none"}
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const Home = () => {
Examples
</Heading>

<p className="primary">
<p className="primary center">
See what MolEvolvR results look like without inputting anything
</p>

Expand Down
4 changes: 4 additions & 0 deletions frontend/src/pages/Testbed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ const nodes = Array(200)
"compound",
"anatomy",
"phenotype",
"symptom",
"genotype",
"variant",
"pathway",
undefined,
]),
strength: sample([0, 0.1, 0.02, 0.003, 0.0004, 0.00005, undefined]),
Expand Down
Loading