diff --git a/frontend/src/components/Network.module.css b/frontend/src/components/Network.module.css index 3cc9a4f..d72592d 100644 --- a/frontend/src/components/Network.module.css +++ b/frontend/src/components/Network.module.css @@ -26,7 +26,6 @@ .node-symbol { width: 20px; height: 20px; - border-radius: 999px; } .edge-symbol { diff --git a/frontend/src/components/Network.tsx b/frontend/src/components/Network.tsx index 6dd4241..0e98238 100644 --- a/frontend/src/components/Network.tsx +++ b/frontend/src/components/Network.tsx @@ -51,7 +51,7 @@ 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, @@ -59,7 +59,9 @@ import { 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"; @@ -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, @@ -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); @@ -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(null); + const container = useRef(null); const graph = useRef(null); const layout = useRef(null); + /** reactive CSS vars */ + const theme = useTheme(); + /** selected nodes/edges */ const [selectedItems, setSelectedItems] = useState<(Node | Edge)[]>([]); @@ -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 ?? [])), @@ -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]; @@ -424,6 +379,7 @@ 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; @@ -431,12 +387,8 @@ const Network = ({ nodes: _nodes, edges: _edges }: Props) => { /** 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 */ @@ -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; @@ -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 */ @@ -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"], ); } @@ -570,6 +619,7 @@ const Network = ({ nodes: _nodes, edges: _edges }: Props) => { return (
@@ -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 */ @@ -601,6 +652,7 @@ const Network = ({ nodes: _nodes, edges: _edges }: Props) => { "strength", "size", "color", + "shape", ]), ).map(([key, value]) => ( @@ -626,10 +678,16 @@ const Network = ({ nodes: _nodes, edges: _edges }: Props) => { {Object.entries(nodeColors).map(([key, value]) => ( -
+ style={{ color: value }} + > + +
{startCase(key) || "none"}
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 9a61afe..56dc2cf 100755 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -87,7 +87,7 @@ const Home = () => { Examples -

+

See what MolEvolvR results look like without inputting anything

diff --git a/frontend/src/pages/Testbed.tsx b/frontend/src/pages/Testbed.tsx index 54a3af3..3c58467 100755 --- a/frontend/src/pages/Testbed.tsx +++ b/frontend/src/pages/Testbed.tsx @@ -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]), diff --git a/frontend/src/util/color.ts b/frontend/src/util/color.ts index d0bfe28..ddd1613 100644 --- a/frontend/src/util/color.ts +++ b/frontend/src/util/color.ts @@ -4,22 +4,23 @@ * https://tailwindcss.com/docs/customizing-colors * https://github.com/tailwindlabs/tailwindcss/blob/main/src/public/colors.js */ + const palette = [ "#90a4ae", "#e57373", - "#f06292", - "#ba68c8", "#9575cd", - "#7986cb", - "#64b5f6", "#4fc3f7", - "#4dd0e1", - "#4db6ac", "#81c784", - "#aed581", - "#ffd54f", "#ffb74d", + "#f06292", + "#7986cb", + "#4dd0e1", + "#aed581", "#ff8a65", + "#ba68c8", + "#64b5f6", + "#4db6ac", + "#ffd54f", ]; /** map enumerated values to colors */ @@ -32,6 +33,34 @@ export const getColorMap = (values: Value[]) => { for (const value of values) if (value.trim()) /** add value to color map (if not already defined) */ - map[value] ??= colors[(colorIndex++ * 3) % colors.length]!; + map[value] ??= colors[colorIndex++ % colors.length]!; return map; }; + +/** + * mix colors by particular amount in desired color space + * https://stackoverflow.com/a/56348573/2180570 + */ +export const mixColors = ( + colorA: string, + colorB: string, + mix = 0.5, + space = "srgb", +) => { + const style = `color-mix(in ${space}, ${colorA}, ${colorB} ${100 * mix}%)`; + const div = document.createElement("div"); + if (!window.matchMedia(`@supports (color: ${style}`)) return colorA; + div.style.color = style; + document.body.append(div); + const [r = 0, g = 0, b = 0] = window + .getComputedStyle(div) + .color.split(/\s/) + .map(parseFloat) + .filter((value) => !Number.isNaN(value)); + div.remove(); + const floatToHex = (value: number) => + Math.round(255 * value) + .toString(16) + .padStart(2, "0"); + return "#" + [r, g, b].map(floatToHex).join(""); +}; diff --git a/frontend/src/util/shapes.ts b/frontend/src/util/shapes.ts new file mode 100644 index 0000000..42ba6ad --- /dev/null +++ b/frontend/src/util/shapes.ts @@ -0,0 +1,51 @@ +import { cos, sin } from "@/util/math"; + +/** make regular polygon or star */ +const makePolygon = (sides: number, starInset = 1) => + Array(sides) + .fill(0) + .map((_, index) => { + const angle = -90 + 360 * (index / sides); + const radius = index % 2 === 0 ? 1 : starInset; + return [cos(angle) * radius, sin(angle) * radius]; + }) + .flat(); + +/** shape options */ +const palette = [ + /** circle */ + makePolygon(50), + /** square */ + [-0.8, -0.8, 0.8, -0.8, 0.8, 0.8, -0.8, 0.8], + /** diamond */ + makePolygon(4), + /** triangle */ + makePolygon(3), + /** pentagon */ + makePolygon(5), + /** hexagon */ + makePolygon(6), + /** four pointed star */ + makePolygon(8, 0.5), + /** + * five pointed star + * https://www.jdawiseman.com/papers/easymath/surds_star_inner_radius.html + */ + makePolygon(10, 0.38196601125010515), + /** rhombus */ + [-0.5, -0.75, 1, -0.75, 0.5, 0.75, -1, 0.75], +]; + +/** map enumerated values to shapes */ +export const getShapeMap = (values: Value[]) => { + /** get first (neutral) shape and remaining shapes */ + const [neutral = "", ...shapes] = palette; + let index = 0; + /** make blank value a neutral shape */ + const map = { "": neutral } as Record; + for (const value of values) + if (value.trim()) + /** add value to shape map (if not already defined) */ + map[value] ??= shapes[index++ % shapes.length]!; + return map; +};