diff --git a/.vscode/settings.json b/.vscode/settings.json index c19a7ef..125221f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cSpell.words": [ "Catmull", "chakra", + "cubehelix", "dagre", "drei", "edgesep", @@ -20,5 +21,5 @@ "visx", "webm", "Wireframe" - ] + ], } \ No newline at end of file diff --git a/example/src/data.ts b/example/src/data.ts index bbe79a5..40b2bff 100644 --- a/example/src/data.ts +++ b/example/src/data.ts @@ -1,5 +1,5 @@ -import { GraphEdge, GraphNode } from "@diagrams/graph"; -import { uniq } from "lodash"; +import { SimpleEdge, SimpleNode, HierarchicalNode } from "@diagrams/graph"; +import { isArray, uniq } from "lodash"; interface Tree { [index: string]: Tree | string[]; @@ -28,34 +28,68 @@ const nodeTree = { }, } as Tree; -function red(t: Tree, parent: string | null): GraphNode[] { - return Object.keys(t).reduce((p, c) => { - const e = t[c]; - const parentNode: GraphNode = { name: c, parent }; - if (Array.isArray(e)) { - const children = e.map((child) => ({ name: child, parent: c })); - return [...p, parentNode, ...children]; - } else { - const children = red(e, c); - return [...p, parentNode, ...children]; - } - }, []); +const styleMap: Record> = { + Compute: { + backgroundColor: "yellow", + }, + Network: { + backgroundColor: "#9090f0", + }, + Data: { + backgroundColor: "#90f090", + }, +}; + +// function treeToNodeArray(tree: Tree) { +// return Object.keys(tree).flatMap((node) => branchToNodeArray(Array.isArray(tree[node]) ? null : tree[node], node)); +// } + +/** + * When parentNode is null, just add child branches. + * Or sub-branch of tree, parentNode is the node created and added + * Or string leaf branch, return node + */ +function branchToNodeArray( + tree: Tree | string[] | null, + branchName?: string, + parentNode?: HierarchicalNode +): HierarchicalNode[] { + const node: HierarchicalNode | undefined = + (branchName && { + ...(parentNode ?? {}), + ...(styleMap[branchName] ?? {}), + name: branchName!, + parent: parentNode?.name ?? null, + }) || + undefined; + // is leaf node then branchName is a node, just return the leaf node + if (tree === null) return [node!]; + if (isArray(tree)) return [node!, ...tree.flatMap((leaf) => branchToNodeArray(null, leaf, node))]; + // otherwise we're in a tree + const t = [ + node!, + ...Object.entries(tree || {}).flatMap(([childBranchName, childBranch]) => + branchToNodeArray(childBranch, childBranchName, node) + ), + ].filter(Boolean); + return t; } -export const edges: GraphEdge[] = [ - { from: "Compute on Demand", to: "Virtual Compute & Containers", label: "Uses" }, - { from: "Virtual Compute & Containers", to: "Physical Compute", label: "Uses" }, - { from: "Database", to: "Virtual Compute & Containers", label: "Uses" }, - { from: "Database", to: "Load Balancing", label: "Uses" }, +export const edges: SimpleEdge[] = [ + { from: "Compute on Demand", to: "Virtual Compute & Containers", label: "Comp Uses VMs" }, + { from: "Virtual Compute & Containers", to: "Physical Compute", label: "VMs on HyperVisor" }, + { from: "Database", to: "Virtual Compute & Containers", label: "DBs on VMs" }, + { from: "Database", to: "Load Balancing", label: "DB Scale using LB" }, ]; const connectedNodes = edges.reduce((p, c) => [...p, c.from, c.to], []); -export const nodesL3: GraphNode[] = red(nodeTree, null); +export const nodesL3: HierarchicalNode[] = branchToNodeArray(nodeTree); const roots = nodesL3.filter((node) => !node.parent).map((node) => node.name); const parents = uniq(nodesL3.map((node) => node.parent).filter(Boolean)); -export const nodesL2: GraphNode[] = red(nodeTree, null) +export const nodesL2: HierarchicalNode[] = branchToNodeArray(nodeTree) .filter((node) => !!node.parent) .map((node) => ({ ...node, parent: node.parent && roots.includes(node.parent) ? null : node.parent })); -export const nodesLeaf = red(nodeTree, null).filter( - (n) => !parents.includes(n.name) && connectedNodes.includes(n.name) -); +export const nodesLeaf: SimpleNode[] = branchToNodeArray(nodeTree) + .filter((n) => !parents.includes(n.name) && connectedNodes.includes(n.name)) + .map((n, i) => ({ ...n, size: { width: ((i * 20) % 100) + 100, height: ((i * 20) % 100) + 100 } })); +console.log("SIMPLE", nodesLeaf); diff --git a/example/src/demo-graph2D.tsx b/example/src/demo-graph2D.tsx index 199b237..3bc058c 100644 --- a/example/src/demo-graph2D.tsx +++ b/example/src/demo-graph2D.tsx @@ -1,40 +1,76 @@ import { Box } from "@chakra-ui/react"; -import { Graph, GraphProps, useNgraph } from "@diagrams/graph"; +import { ExpandableGraph, GraphOptions, SimpleGraph } from "@diagrams/graph"; +import { useDefaultOptions } from "@diagrams/graph/lib/use-ngraph-simple"; import * as React from "react"; -import { FC, useState } from "react"; -import { edges, nodesL3, nodesL2, nodesLeaf } from "./data"; +import { FC, useCallback, useMemo, useState } from "react"; +import { edges, nodesL2, nodesLeaf } from "./data"; -export const DemoGraphSimple: FC<{ options: GraphProps["options"] }> = ({ options }) => { - const graph = useNgraph(nodesLeaf, edges, null, { iterations: 500 }); +export const DemoGraphSimple: FC<{ + options: GraphOptions; +}> = ({ options: _options }) => { + const [selected, setSelected] = useState(); + const options = useDefaultOptions(_options); + const expand = useCallback(({ name }: { name: string }) => { + console.log("Name", name); + setSelected(name); + }, []); + const largeNodes = useMemo( + () => + nodesLeaf.map((node) => ({ + ...node, + size: + node.name === selected + ? { + width: (node.size ?? options.defaultSize).width * 2, + height: (node.size ?? options.defaultSize).height * 2, + } + : node.size, + border: node.name === selected ? "red" : "black", + })), + [options.defaultSize, selected] + ); return ( - ; + ; ); }; -export const DemoGraphLevel2: FC<{ options: GraphProps["options"] }> = ({ options }) => { +export const DemoGraphLevel2: FC<{ + options: GraphOptions; +}> = ({ options: _options }) => { const [expanded, setExpanded] = useState([]); - const graph = useNgraph(nodesL2, edges, expanded, { iterations: 500 }); - const onSelectNode = React.useCallback((args: { name: string }) => { + const options = useDefaultOptions(_options); + + const onSelectNode = useCallback((args: { name: string }) => { setExpanded((exp) => (exp.includes(args.name) ? exp.filter((e) => e !== args.name) : [...exp, args.name])); }, []); return ( - ; + ); }; -export const DemoGraphLevel3: FC<{ options: GraphProps["options"] }> = ({ options }) => { +export const DemoGraphLevel3: FC<{ + options: GraphOptions; +}> = ({ options }) => { const [expanded, setExpanded] = useState([]); - const graph = useNgraph(nodesL3, edges, expanded, { iterations: 500 }); - const onSelectNode = React.useCallback((args: { name: string }) => { - setExpanded((exp) => (exp.includes(args.name) ? exp.filter((e) => e !== args.name) : [...exp, args.name])); - }, []); - return ( - - ; - - ); + return ; + // const graph = useNgraph(nodesL3, edges, expanded, { ...options, physics }); + // const onSelectNode = React.useCallback((args: { name: string }) => { + // setExpanded((exp) => (exp.includes(args.name) ? exp.filter((e) => e !== args.name) : [...exp, args.name])); + // }, []); + // return ( + // + // ; + // + // ); }; diff --git a/example/src/index.tsx b/example/src/index.tsx index 73a28bd..40fedd3 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -1,18 +1,23 @@ import { Box, + Center, ChakraProvider, Flex, + FormControl, + FormLabel, Heading, + Link, List, ListItem, - Link, - theme, - Center, - FormControl, - FormLabel, + Slider, + SliderFilledTrack, + SliderThumb, + SliderTrack, Switch, + theme, + Tooltip, } from "@chakra-ui/react"; -import { GraphProps } from "@diagrams/graph"; +import { RequiredGraphOptions, GraphOptions } from "@diagrams/graph"; import { mapValues } from "lodash"; import * as React from "react"; import { FC, useMemo, useState } from "react"; @@ -27,75 +32,215 @@ const Hello: FC = () => ( ); -type NoEmpty = T extends null | undefined ? never : T; +// type NoEmpty = T extends null | undefined ? never : T; +// type GraphPropsOptionType = NoEmpty>; -type GraphPropsOptionType = NoEmpty; +type NumericOpts = "gravity" | "springCoefficient" | "springLength" | "dragCoefficient" | "theta" | "textSize"; -const opts: Record = { +const opts: Record, string> = { debugMassNode: "Mass of Node", - debugSpringLengths: "Spring Lengths", - debugHierarchicalSprings: "Hidden Hierarchical Edges", + // debugSpringLengths: "Spring Lengths", + // debugHierarchicalSprings: "Hidden Hierarchical Edges", +}; + +const navWidth = "300px"; +const navHeight = "40px"; + +// type GraphOpts = Omit; +// type PhysicsSettings = Required["physics"] & { textSize: number }; +type PhysicsSettingsBag = { + [Property in keyof Pick]: { + name: string; + description: string; + default: RequiredGraphOptions[Property]; + minVal: RequiredGraphOptions[Property]; + maxVal: RequiredGraphOptions[Property]; + }; +}; + +const physicsMeta: PhysicsSettingsBag = { + gravity: { + name: "Gravity - Coulomb's law coefficient", + description: + "It's used to repel nodes thus should be negative if you make it positive nodes start attract each other", + minVal: -1500, + maxVal: 0, + default: -12, + }, + springCoefficient: { + name: "Hook's law coefficient", + description: "1 - solid spring.", + minVal: 0, + maxVal: 1, + default: 0.8, + }, + springLength: { + name: "Ideal length for links", + description: "Ideal length for links (springs in physical model).", + minVal: 2, + maxVal: 500, + default: 10, + }, + theta: { + name: "Theta coefficient from Barnes Hut simulation", + description: + "The closer it's to 1 the more nodes algorithm will have to go through. Setting it to one makes Barnes Hut simulation no different from brute-force forces calculation (each node is considered)", + minVal: 0, + maxVal: 1, + default: 0.8, + }, + dragCoefficient: { + name: "Drag force coefficient", + description: + "Used to slow down system, thus should be less than 1. The closer it is to 0 the less tight system will be.", + minVal: 0, + maxVal: 1, + default: 0.9, + }, + textSize: { + name: "Size of text", + description: "Default font size", + minVal: 1, + maxVal: 20, + default: 10, + }, }; const AppA: FC = () => { - const [options, setOptions] = useState({}); + const [options, setOptions] = useState({ + ...mapValues(physicsMeta, (k) => k.default), + dimensions: 2, + timeStep: 0.5, + adaptiveTimeStepWeight: 0, + debug: false, + }); + const [showTooltip, setShowTooltip] = useState(); + const demos = useMemo( () => [ - { name: "Simple Graph", path: "/dag2d", component: }, - { name: "2-Level Hierarchical Graph", path: "/dag2dl2", component: }, - { name: "3-Level Hierarchical Graph", path: "/dag2dl3", component: }, + { + name: "Simple Graph", + path: "/dag2d", + component: , + }, + { + name: "2-Level Hierarchical Graph", + path: "/dag2dl2", + component: , + }, + { + name: "3-Level Hierarchical Graph", + path: "/dag2dl3", + component: , + }, ], [options] ); return ( - - + + Graph 2D - - + + {Object.values( + mapValues(opts, (v, k: keyof GraphOptions) => ( + + setOptions((o) => ({ ...o, [k]: x.currentTarget.checked }))} + /> + + {v} + + + )) + )} + {Object.values( - mapValues(opts, (v, k: keyof GraphPropsOptionType) => ( - - setOptions((o) => ({ ...o, [k]: x.currentTarget.checked }))} - /> - - {v} + mapValues(physicsMeta, (v, key: NumericOpts) => ( + + + {v.name} + setOptions((state) => ({ ...state, [key]: value }))} + onMouseEnter={() => setShowTooltip(key)} + onMouseLeave={() => setShowTooltip(undefined)} + > + + + + + + + )) )} - li": { fontWeight: "bold", mb: 3, p: 2, _hover: { bg: "#404040" } } }}> - {demos.map((d) => ( - - - {d.name} - - - ))} - - - - - {demos.map((d) => ( - - ))} - } /> - + li": { fontWeight: "bold", mb: 3, p: 2, _hover: { bg: "#404040" } } }}> + {demos.map((d) => ( + + + {d.name} + + + ))} + + + + + {demos.map((d) => ( + + ))} + } /> + ); diff --git a/graph/package.json b/graph/package.json index a607e72..cc458c9 100644 --- a/graph/package.json +++ b/graph/package.json @@ -16,8 +16,14 @@ "@types/react-dom": "latest" }, "dependencies": { + "@types/chroma-js": "^2.1.3", + "@types/d3-color": "^3.0.2", "@visx/text": "^2.3.0", + "chroma-js": "^2.1.2", + "colorjs.io": "^0.0.4", + "d3-color": "^3.0.1", "framer-motion": "^5.5.5", + "js-quadtree": "^3.3.6", "lodash-es": "latest", "ngraph.forcelayout": "^3.3.0", "ngraph.graph": "^20.0.0" @@ -34,7 +40,9 @@ "eslintConfig": { "extends": "react-app" }, - "files": ["lib"], + "files": [ + "lib" + ], "prettier": { "tabWidth": 4, "printWidth": 120 diff --git a/graph/src/edges.tsx b/graph/src/edges.tsx new file mode 100644 index 0000000..f42f998 --- /dev/null +++ b/graph/src/edges.tsx @@ -0,0 +1,109 @@ +import { motion } from "framer-motion"; +import { keyBy } from "lodash"; +import * as React from "react"; +import { memo, useMemo } from "react"; +import { + getAnchor, + getMidPoint, + Point, + PositionedEdge, + PositionedNode, + RequiredGraphOptions, + Size, + transition, +} from "./model"; + +interface Props { + nodes: Record; + edges: PositionedEdge[]; + targetSize: Size; + targetOffset?: Point; + name: string; + options: Pick; +} + +export const Edges = memo(({ edges, nodes, targetSize: targetArea, options }) => { + // get the containing rectangle + // const [virtualTopLeft, virtualSize] = useContainingRect(targetArea, nodes, options.textSize); + // adjust the position of the nodes to fit within the targetArea + const nodesDict = useMemo(() => keyBy(nodes, (n) => n.name), [nodes]); + const layoutEdges = useMemo( + () => + edges + .map((e) => { + const ndFrom = nodesDict[e.from]; + const ndTo = nodesDict[e.to]; + if (!ndFrom || !ndTo) { + console.warn("cannot find nodes from edge", e); + return null; + } + const _fromPoint = (ndFrom as unknown as any).absolutePosition; + const _toPoint = (ndTo as unknown as any).absolutePosition; + // const fromPos = adjustPosition(e.fromNode.position, virtualTopLeft, virtualSize, targetArea); + // const toPos = adjustPosition(e.toNode.position, virtualTopLeft, virtualSize, targetArea); + const midPoint = { + x: getMidPoint(_fromPoint.x, _toPoint.x, 0.5), + y: getMidPoint(_fromPoint.y, _toPoint.y, 0.5), + }; + const fromPoint = getAnchor(_fromPoint, nodesDict[e.from].size ?? options.defaultSize, _toPoint); + const toPoint = getAnchor(_toPoint, nodesDict[e.to].size ?? options.defaultSize, _fromPoint); + return { ...e, points: [fromPoint, midPoint, toPoint] }; + }) + .filter((e) => e !== null), + + [edges, nodesDict, options.defaultSize] + ); + + return ( + <> + {layoutEdges.map( + (edge) => + edge && ( + + ) + )} + {layoutEdges.map( + (edge) => + edge && ( + + {edge.label} + + ) + )} + + ); +}); diff --git a/graph/src/expandable-graph.tsx b/graph/src/expandable-graph.tsx new file mode 100644 index 0000000..485f9b0 --- /dev/null +++ b/graph/src/expandable-graph.tsx @@ -0,0 +1,132 @@ +import { mix } from "chroma-js"; +import { keyBy, mapValues } from "lodash"; +import * as React from "react"; +import { FC, useCallback, useMemo } from "react"; +import { HierarchicalNode } from "."; +import { Edges } from "./edges"; +import { MiniGraph, useChanged, useEdges } from "./mini-graph"; +import { GraphOptions, HierarchicalEdge, PositionedNode, SimpleNode, zeroPoint } from "./model"; +import { SvgContainer } from "./svg-container"; +import { useDimensions } from "./use-dimensions"; +import { useDefaultOptions } from "./use-ngraph-simple"; +import { useChildrenNodesByParent } from "./use-ngraph-structure"; + +interface GraphProps { + nodes: HierarchicalNode[]; + edges: HierarchicalEdge[]; + onSelectNode?: (args: { name: string }) => void; + selectedNode?: string | null; + expanded: string[]; + options?: GraphOptions; +} + +function getVisibleNode( + node: HierarchicalNode, + leafNodes: Record, + nodesDict: Record, + expanded: string[] +) { + while (!!node && node.parent !== null && !(leafNodes[node.name] || expanded.includes(node.parent))) { + node = nodesDict[node.parent]; + } + return node; +} + +export const ExpandableGraph: FC = ({ edges, nodes, onSelectNode, expanded, options: _options = {} }) => { + useChanged("edges", edges); + useChanged("nodes", nodes); + useChanged("onSelectNode", onSelectNode); + useChanged("expanded", expanded); + useChanged("options", _options); + + const options = useDefaultOptions(_options); + const [ref, { size: targetSize }] = useDimensions(); + + const leafNodes = useMemo(() => nodes.filter((n) => n.parent === null), [nodes]); + const leafNodesDict = useMemo(() => keyBy(leafNodes, (l) => l.name), [leafNodes]); + const routedEdges = useMemo(() => { + const nodesDict = keyBy(nodes, (n) => n.name); + const reroutedNodesDict = mapValues(nodesDict, (n) => getVisibleNode(n, leafNodesDict, nodesDict, expanded)); + const routedEdges = edges.map((edge) => ({ + ...edge, + to: reroutedNodesDict[edge.to].name, + from: reroutedNodesDict[edge.from].name, + })); + return routedEdges; + }, [edges, expanded, leafNodesDict, nodes]); + + const insideNodes = useMemo( + () => nodes.filter((n) => n.parent !== null && expanded.includes(n.parent)), + [expanded, nodes] + ); + const [nodesByParent] = useChildrenNodesByParent(insideNodes); + const largeNodes = useMemo( + () => + leafNodes.map((node) => ({ + ...node, + size: expanded.includes(node.name) + ? { + width: (node.size ?? options.defaultSize).width * 4, + height: (node.size ?? options.defaultSize).height * 4, + } + : node.size, + border: expanded.includes(node.name) ? "red" : "black", + backgroundColor: mix(node.backgroundColor ?? "gray", "rgba(255,255,255,0)", 0.3).css(), + })), + [expanded, leafNodes, options.defaultSize] + ); + const [posNodesDict, posEdges, onNodesMoved] = useEdges(); + const renderNode = useCallback( + (node: PositionedNode) => + (nodesByParent[node.name] && ( + ({ + ...p, + initialSize: node.size, + initialPosition: node.position, + }))} + edges={[]} + name={node.name + "-graph"} + onSelectNode={onSelectNode} + targetArea={node.size} + onNodesPositioned={onNodesMoved} + targetOffset={{ + x: node.position.x - node.size.width / 2, + y: node.position.y - node.size.height / 2, + }} + options={options} + /> + )) || + null, + [nodesByParent, onNodesMoved, onSelectNode, options] + ); + + return ( +
+ + + + +
+ ); +}; diff --git a/graph/src/graph.tsx b/graph/src/graph.tsx deleted file mode 100644 index 6ead65d..0000000 --- a/graph/src/graph.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { motion } from "framer-motion"; -import { groupBy, keyBy, mapValues } from "lodash"; -import * as React from "react"; -import { FC } from "react"; -import { getContainingRect, Layout, transition } from "./model"; -import { RectIt } from "./svg-react"; - -export interface GraphProps { - graph: Layout; - onSelectNode?: (args: { name: string }) => void; - selectedNode?: string | null; - options?: { - /** display the length of springs between bodies */ - debugSpringLengths?: boolean; - /** display the hidden edges that group hierarchical nodes */ - debugHierarchicalSprings?: boolean; - /** display Mass of the node */ - debugMassNode?: boolean; - }; -} - -// type FeedType = { [nodeName: string]: { count: number; messages: MessageArrived[] | undefined } | undefined }; -export const Graph: FC = ({ - graph: { edges, nodes, minPoint, maxPoint, textSize }, - onSelectNode, - options = {}, -}) => { - - return ( - - - - - - - {nodes.map((node) => ( - - ))} - {edges - .filter((e) => options.debugHierarchicalSprings || !e.hide) - .map((edge) => ( - - ))} - {edges - .filter((e) => options.debugHierarchicalSprings || (!e.hide && !!e.label)) - .map((edge) => ( - - {options.debugSpringLengths ? `${edge.label} S:${edge.score}` : edge.label} - - ))} - - ); -}; diff --git a/graph/src/index.tsx b/graph/src/index.tsx index c710b02..116a593 100644 --- a/graph/src/index.tsx +++ b/graph/src/index.tsx @@ -1,4 +1,5 @@ -export { Graph } from "./graph"; -export type { GraphProps } from "./graph" -export type { GraphNode, GraphEdge } from "./model"; -export { useNgraph } from "./use-ngraph"; +export { SimpleGraph } from "./simple-graph"; +export { ExpandableGraph } from "./expandable-graph"; +export type { SimpleEdge, SimpleNode, HierarchicalNode, GraphOptions, RequiredGraphOptions } from "./model"; +export type { UseNGraphOptions } from "./use-ngraph"; +export { useSimpleGraph } from "./use-ngraph-simple"; diff --git a/graph/src/mini-graph.tsx b/graph/src/mini-graph.tsx new file mode 100644 index 0000000..adc9b5b --- /dev/null +++ b/graph/src/mini-graph.tsx @@ -0,0 +1,146 @@ +import { motion } from "framer-motion"; +import { keyBy } from "lodash"; +import * as React from "react"; +import { FC, memo, useCallback, useEffect, useMemo, useState } from "react"; +import { + adjustPosition, + Point, + PositionedEdge, + PositionedNode, + RequiredGraphOptions, + SimpleEdge, + SimpleNode, + Size, + transition, + zeroPoint, +} from "./model"; +import { TextBox } from "./svg-react"; +import { useContainingRect, useSimpleGraph } from "./use-ngraph-simple"; + +interface MiniGraphProps { + nodes: SimpleNode[]; + edges: SimpleEdge[]; + targetArea: Size; + targetOffset?: Point; + onSelectNode?: (args: { name: string }) => void; + selectedNode?: string | null; + name: string; + onNodesPositioned?: (edges: PositionedEdge[], nodes: Record) => void; + renderNode?: ( + node: PositionedNode, + onSelectNode: MiniGraphProps["onSelectNode"], + options: Pick + ) => JSX.Element; + options: Pick; +} + +export const PaintNode: FC<{ + node: PositionedNode; + onSelectNode: MiniGraphProps["onSelectNode"]; + options: MiniGraphProps["options"]; +}> = ({ node, onSelectNode, options: { textSize } }) => ( + +); + +export function useChanged(name: string, x: T) { + useEffect(() => console.log(`${name} changed.`), [x, name]); +} + +export function useEdges() { + const [posEdges, setEdges] = useState([]); + const [posNodes, setNodes] = useState>({}); + const onNodesMoved = useCallback((edges: PositionedEdge[], nodes: Record) => { + setEdges(edges); + setNodes((nd) => { + const x = { ...nd, ...nodes }; + console.log("Nodes", x); + return x; + }); + }, []); + return [posNodes, posEdges, onNodesMoved] as [ + Record, + PositionedEdge[], + MiniGraphProps["onNodesPositioned"] + ]; +} + +export const MiniGraph = memo( + ({ + edges, + nodes, + onSelectNode, + targetOffset = zeroPoint, + renderNode, + name, + targetArea, + onNodesPositioned, + options, + }) => { + useChanged("edges", edges); + useChanged("onSelectNode", nodes); + useChanged("targetOffset", targetOffset); + useChanged("renderNode", renderNode); + useChanged("name", name); + useChanged("targetArea", targetArea); + useChanged("onNodesPositioned", onNodesPositioned); + useChanged("options", options); + + // get the virtual positions of the nodes in a graph + const [positionedNodes, positionedEdges] = useSimpleGraph(nodes, edges, options); + + // get the containing rectangle + const [virtualTopLeft, virtualSize] = useContainingRect(targetArea, positionedNodes, options.textSize); + // adjust the position of the nodes to fit within the targetArea + const layoutNodes = useMemo( + () => + positionedNodes.map((node) => ({ + ...node, + position: adjustPosition(node.position, virtualTopLeft, virtualSize, targetArea), + absolutePosition: adjustPosition(node.position, virtualTopLeft, virtualSize, targetArea,targetOffset), + containerPosition: virtualTopLeft, + })), + [positionedNodes, virtualTopLeft, virtualSize, targetArea, targetOffset] + ); + useEffect( + () => + onNodesPositioned?.( + positionedEdges, + keyBy(layoutNodes, (n) => n.name) + ), + [layoutNodes, onNodesPositioned, positionedEdges] + ); + const customRenderNodes = ( + (renderNode && layoutNodes.map((node) => renderNode(node, onSelectNode, options))) || + [] + ).filter(Boolean); + + return ( + + {layoutNodes.map((node) => ( + + ))} + {customRenderNodes} + + ); + } +); diff --git a/graph/src/model.ts b/graph/src/model.ts index be15f72..0bf31c1 100644 --- a/graph/src/model.ts +++ b/graph/src/model.ts @@ -1,6 +1,6 @@ import { intersection } from "lodash"; -import { Body } from "ngraph.forcelayout"; -import { Link } from "ngraph.graph"; +import { Body, Layout as NLayout, PhysicsSettings } from "ngraph.forcelayout"; +import { Graph, Link } from "ngraph.graph"; export interface Size { width: number; @@ -12,64 +12,117 @@ export interface Point { y: number; } -/** Node model */ -export interface GraphNode { +export const zeroPoint: Point = { x: 0, y: 0 }; + +export interface SimpleNode { /** Must be unique */ name: string; /** hinted start position */ positionHint?: Point; - type?: string; /** non-default size of node */ size?: Size; + /** Hierarchical level hint. If no children then level: 1 */ + backgroundColor?: string; + border?: string; + shadow?: boolean; +} + +/** Node model */ +export interface HierarchicalNode extends SimpleNode { /** Name of parent node if hierarchical */ parent: string | null; - /** Hierarchical level hint. If no children then level: 1 */ - // level?: number; } -export interface GraphEdge { +export interface PositionedNode extends SimpleNode { + position: Point; + body: Body; + size: Size; + initialPosition?: Point; + initialSize?: Size; + containerPosition: Point; +} + +export interface PositionedHierarchicalNode extends HierarchicalNode, PositionedNode { + parentNode: PositionedHierarchicalNode | null; + size: Size; + level: number; +} + +export interface SimpleEdge { from: string; to: string; label?: string; - weight?: number; - hierarchical?: boolean; - score?: number; + color?: string; + thickness?: number; + labelColor?: string; } -export type MinMax = [number, number]; - -export interface LayoutNode extends GraphNode { +export interface PositionedEdge extends SimpleEdge { + fromNode: PositionedNode; + toNode: PositionedNode; name: string; - size: Size; - position: Point; - body?: Body; - levelNumber?: number; + link: Link; } -export interface Visible { - visible: boolean; +export interface HierarchicalEdge extends SimpleEdge { + hierarchical?: boolean; + score?: number; } -export type GraphNodeVisible = GraphNode & Visible; -export type LayoutNodeVisible = LayoutNode & Visible; +export interface PositionedHierarchicalEdge extends HierarchicalEdge, PositionedEdge {} -export interface LayoutEdge extends GraphEdge { - name: string; - points: Point[]; - link: Link; - hide?: boolean; -} +export type NGraph = Graph; +export type NGraphLayout = NLayout; + +export type MinMax = [number, number]; -export interface Layout { - nodes: (LayoutNode & Visible)[]; - edges: LayoutEdge[]; - tree: { [index: string]: LayoutNodeVisible[] }; - minPoint: Point; - maxPoint: Point; - // expanded?: string[]; - textSize: number; +export interface GraphOptions extends Partial { + /** display the length of springs between bodies */ + // debugSpringLengths?: boolean; + /** display Mass of the node */ + debugMassNode?: boolean; + defaultSize?: Size; + textSize?: number; + iterations?: number; } +export type RequiredGraphOptions = Required; + +// export interface LayoutNode extends GraphNode { +// name: string; +// size: Size; +// position: Point; +// body?: Body; +// levelNumber: number; +// isLeaf: boolean; +// parentNode?: LayoutNode | null; +// } + +// export interface Visible { +// visible: boolean; +// } + +// export type GraphNodeVisible = GraphNode & Visible; +// export type LayoutNodeVisible = LayoutNode & Visible; + +// export interface LayoutEdge extends GraphEdge { +// name: string; +// points: Point[]; +// link: Link; +// fromNode?: LayoutNode; +// toNode?: LayoutNode; +// hide?: boolean; +// } + +// export interface Layout { +// nodes: (LayoutNode & Visible)[]; +// edges: LayoutEdge[]; +// tree: { [index: string]: LayoutNodeVisible[] }; +// minPoint: Point; +// maxPoint: Point; +// textSize: number; +// } + const abs = Math.abs; /** For a given Node's position and size, provide a good anchor point when joining from a point */ @@ -89,13 +142,40 @@ export function getAnchor(nodePosition: Point, nodeSize: Size, fromPoint: Point) } /** Calculates the containing rectangle of a set of Nodes */ -export function getContainingRect(nodes: LayoutNode[], padding = 0): { position: Point; size: Size } { +export function getContainingRect(nodes: (PositionedNode & SimpleNode)[], fitSize: Size, padding = 0) { + const estimate = nodes.reduce( + (acc, node) => ({ + x1: Math.min(acc.x1, node.position.x * 1.1), + y1: Math.min(acc.y1, node.position.y * 1.1), + x2: Math.max(acc.x2, node.position.x * 1.1), + y2: Math.max(acc.y2, node.position.y * 1.1), + }), + { + x1: Number.MAX_SAFE_INTEGER, + x2: Number.MIN_SAFE_INTEGER, + y1: Number.MAX_SAFE_INTEGER, + y2: Number.MIN_SAFE_INTEGER, + } + ); + const estimateSize = { width: estimate.x2 - estimate.x1, height: estimate.y2 - estimate.y1 }; const minMax = nodes.reduce( - (p, c) => ({ - x1: Math.min(p.x1, c.position.x - c.size.width / 2 - padding), - y1: Math.min(p.y1, c.position.y - c.size.height / 2 - padding), - x2: Math.max(p.x2, c.position.x + c.size.width / 2 + padding), - y2: Math.max(p.y2, c.position.y + c.size.height / 2 + padding), + (acc, node) => ({ + x1: Math.min( + acc.x1, + node.position.x - ((node.size.width / 2 + padding) / fitSize.width) * estimateSize.width + ), + y1: Math.min( + acc.y1, + node.position.y - ((node.size.height / 2 + padding) / fitSize.height) * estimateSize.height + ), + x2: Math.max( + acc.x2, + node.position.x + ((node.size.width / 2 + padding) / fitSize.width) * estimateSize.width + ), + y2: Math.max( + acc.y2, + node.position.y + ((node.size.height / 2 + padding) / fitSize.height) * estimateSize.height + ), }), { x1: Number.MAX_SAFE_INTEGER, @@ -104,10 +184,10 @@ export function getContainingRect(nodes: LayoutNode[], padding = 0): { position: y2: Number.MIN_SAFE_INTEGER, } ); - return { - position: { x: minMax.x1, y: minMax.y1 }, - size: { width: minMax.x2 - minMax.x1, height: minMax.y2 - minMax.y1 }, - }; + return [ + { x: minMax.x1, y: minMax.y1 }, + { width: minMax.x2 - minMax.x1, height: minMax.y2 - minMax.y1 }, + ] as [Point, Size]; } export function getMidPoint(from: number, to: number, delta: number) { @@ -115,12 +195,13 @@ export function getMidPoint(from: number, to: number, delta: number) { } export function calculateDistance( - edge: GraphEdge, - nodeDict: Record, - node1: GraphNode, - node2: GraphNode + edge: HierarchicalEdge, + nodeDict: Record, + node1: HierarchicalNode, + node2: HierarchicalNode, + defaultSize: Size ): number { - if (edge.hierarchical) return 5; + if (edge.hierarchical) return defaultSize.width; const path1: string[] = []; while (!!node1?.parent) { path1.push(node1.parent); @@ -132,10 +213,23 @@ export function calculateDistance( node2 = nodeDict[node2.parent]; } const distance = path1.length + path2.length - intersection(path1, path2).length; - return Math.max(Math.pow(2, distance) * 10, 20); + return Math.max(Math.pow((distance * defaultSize.width) / 1, 1), defaultSize.width * 2); } export const transition = { type: "easeInOut", duration: 0.6, }; + +export function adjustPosition( + virtualPoint: Point, + virtualTopLeft: Point, + virtualSize: Size, + targetSize: Size, + targetPosition?: Point +) { + return { + x: ((virtualPoint.x - virtualTopLeft.x) / virtualSize.width) * targetSize.width + (targetPosition?.x ?? 0), + y: ((virtualPoint.y - virtualTopLeft.y) / virtualSize.height) * targetSize.height + (targetPosition?.y ?? 0), + }; +} diff --git a/graph/src/simple-graph.tsx b/graph/src/simple-graph.tsx new file mode 100644 index 0000000..87dd34e --- /dev/null +++ b/graph/src/simple-graph.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { FC, useCallback, useState } from "react"; +import { Edges } from "./edges"; +import { MiniGraph, useChanged, useEdges } from "./mini-graph"; +import { GraphOptions, PositionedEdge, PositionedNode, SimpleEdge, SimpleNode, zeroPoint } from "./model"; +import { SvgContainer } from "./svg-container"; +import { useDimensions } from "./use-dimensions"; +import { useDefaultOptions } from "./use-ngraph-simple"; + +interface SimpleGraphProps { + nodes: SimpleNode[]; + edges: SimpleEdge[]; + onSelectNode?: (args: { name: string }) => void; + selectedNode?: string | null; + options?: GraphOptions; +} + +export const SimpleGraph: FC = ({ edges, nodes, onSelectNode, options: _options = {} }) => { + useChanged("edges", edges); + useChanged("onSelectNode", nodes); + useChanged("_options", _options); + + const [ref, { size: targetArea }] = useDimensions(); + const options = useDefaultOptions(_options); + const [posNodes, posEdges, onNodesMoved] = useEdges(); + return ( +
+ + + + +
+ ); +}; diff --git a/graph/src/svg-container.tsx b/graph/src/svg-container.tsx new file mode 100644 index 0000000..1c78882 --- /dev/null +++ b/graph/src/svg-container.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import { FC } from "react"; +import { RequiredGraphOptions } from "./model"; + +export const SvgContainer: FC> = ({ children, textSize }) => ( + + + + + + + + + + + + + + + {children} + +); diff --git a/graph/src/svg-react.tsx b/graph/src/svg-react.tsx index b65b276..480717e 100644 --- a/graph/src/svg-react.tsx +++ b/graph/src/svg-react.tsx @@ -13,29 +13,34 @@ interface RectProps { fillColor?: string; borderColor?: string; textColor?: string; - textSize?: number; - text?:string; + textSize?: number | string; + text?: string; verticalAnchor?: "start" | "end" | "middle"; + filter?: string; onSelectNode?: (args: { name: string }) => void; } -export const RectIt: FC = ({ +export const TextBox: FC = ({ name, initialPosition, initialSize, position, size, - fillColor = "rgba(0,0,255,0.2)", + fillColor = "transparent", borderColor = "white", - textColor = "white", + textColor = "black", verticalAnchor = "middle", textSize, text, + filter, onSelectNode, }) => { const onClick = useCallback(() => { if (onSelectNode) onSelectNode({ name }); }, [onSelectNode, name]); + const textSizeDefaulted = textSize ?? size.height / 4; + const initialSizeDefaulted = initialSize ?? size; + const initialPositionDefaulted = initialPosition ?? position; return ( <> = ({ layoutId={name} initial={{ opacity: 0, - width: initialSize?.width ?? size.width, - height: initialSize?.height ?? size.height, - x: initialPosition?.x ?? position.x, - y: initialPosition?.y ?? position.y, + width: initialSizeDefaulted.width, + height: initialSizeDefaulted.height, + x: initialPositionDefaulted.x - initialSizeDefaulted.width / 2, + y: initialPositionDefaulted.y - initialSizeDefaulted.height / 2, fill: fillColor, stroke: borderColor, }} animate={{ opacity: 1, - width: size.width, - height: size.height, - x: position.x, - y: position.y, + x: position.x - size.width / 2, + y: position.y - size.height / 2, fill: fillColor, stroke: borderColor, }} @@ -65,42 +68,42 @@ export const RectIt: FC = ({ layoutId={name + "-rect"} initial={{ opacity: 0, - width: size.width, - height: size.height, + width: initialSizeDefaulted.width, + height: initialSizeDefaulted.height, fill: fillColor, - x: size.width * -0.5, - y: size.height * -0.5, stroke: borderColor, - rx: size.width / 20, - ry: size.height / 20, + rx: initialSizeDefaulted.width / 20, + ry: initialSizeDefaulted.height / 20, }} animate={{ opacity: 1, width: size.width, height: size.height, fill: fillColor, - x: size.width * -0.5, - y: size.height * -0.5, stroke: borderColor, rx: size.width / 20, ry: size.height / 20, }} + x={0} + y={0} transition={transition} fill={fillColor} + filter={filter} stroke={borderColor} - strokeWidth={0.2} + strokeWidth={1} onClick={onClick} /> {text ?? name} diff --git a/graph/src/use-dimensions.tsx b/graph/src/use-dimensions.tsx new file mode 100644 index 0000000..4852e15 --- /dev/null +++ b/graph/src/use-dimensions.tsx @@ -0,0 +1,28 @@ +import { Ref, useLayoutEffect, useRef, useState } from "react"; +import { Point, Size, zeroPoint } from "./model"; + +export function useDimensions(): [ + Ref, + { + position: Point; + size: Size; + } +] { + const [{ position, size }, setDimensions] = useState<{ position: Point; size: Size }>({ + position: zeroPoint, + size: { + width: 200, + height: 200, + }, + }); + const ref = useRef(null); + useLayoutEffect(() => { + const size = ref.current?.getBoundingClientRect()?.toJSON() ?? undefined; + if (size) + setDimensions({ + position: { x: size.left, y: size.top }, + size: { width: size.width, height: size.height }, + }); + }, []); + return [ref, { position, size }]; +} diff --git a/graph/src/use-ngraph-simple.ts b/graph/src/use-ngraph-simple.ts new file mode 100644 index 0000000..32ea85d --- /dev/null +++ b/graph/src/use-ngraph-simple.ts @@ -0,0 +1,235 @@ +import { keyBy } from "lodash"; +import createLayout, { Vector } from "ngraph.forcelayout"; +import createGraph, { Link } from "ngraph.graph"; +import { useMemo } from "react"; +import { + getContainingRect, + GraphOptions, + NGraph, + NGraphLayout, + Point, + PositionedEdge, + PositionedNode, + RequiredGraphOptions, + SimpleEdge, + SimpleNode, + Size, + zeroPoint, +} from "./model"; + +function rectanglesOverlap(topLeft1: Point, bottomRight1: Point, topLeft2: Point, bottomRight2: Point) { + if (topLeft1.x > bottomRight2.x || topLeft2.x > bottomRight1.x) { + return false; + } + if (topLeft1.y > bottomRight2.y || topLeft2.y > bottomRight1.y) { + return false; + } + return true; +} + +function useLayout(graph: NGraph, options: RequiredGraphOptions) { + return useMemo(() => { + // Do the LAYOUT + const layout = createLayout(graph, options); + layout.forEachBody( + (body, id) => (body.mass = 50 * (graph.getNode(id)?.data?.size ?? options.defaultSize).width) + ); + // const qt = new QuadTree(new Box(0, 0, 1000, 1000)); + // graph.forEachNode((n) => { + // const body = layout.getBody(n.id); + // if (body) qt.insert(getPointsFromBox(body.pos.x, body.pos.y, n.data.size.width, n.data.size.height)); + // }); + for (let i = 0; i < options.iterations; ++i) { + const oldPos: Record = {}; + graph.forEachNode((n) => { + const body = layout.getBody(n.id); + if (!body) return; + oldPos[n.id] = body?.pos; + }); + layout.step(); + graph.forEachNode((node1) => { + const body1 = layout.getBody(node1.id); + if (!body1 || !node1 || !node1.data || !node1.data.size) return; + const staticRect = { + x: body1?.pos.x, + y: body1?.pos.y, + width: node1?.data?.size.width, + height: node1?.data?.size.height, + }; + graph.forEachNode((testNode) => { + const body2 = layout.getBody(testNode.id); + if (!body2 || !testNode || !testNode.data || !testNode.data.size) return; + const testRect = { + x: body2?.pos.x, + y: body2?.pos.y, + width: testNode?.data?.size.width, + height: testNode?.data?.size.height, + }; + if ( + rectanglesOverlap( + { x: staticRect.x, y: staticRect.y }, + { x: staticRect.x + staticRect.width, y: staticRect.y + staticRect.height }, + { x: testRect.x, y: testRect.y }, + { x: testRect.x + testRect.width, y: testRect.y + testRect.height } + ) + ) { + const newPos = { x: testRect.x, y: testRect.y }; + if ( + testRect.x + testRect.width > staticRect.x && + testRect.x + testRect.width < staticRect.x + staticRect.width + ) { + newPos.x = oldPos[testNode.id].x; + body2.velocity = { x: 0, y: body2.velocity.y }; + } + if ( + testRect.y + testRect.height > staticRect.y && + testRect.y + testRect.height < staticRect.y + staticRect.height + ) { + newPos.y = oldPos[testNode.id].y; + body2.velocity = { x: body2.velocity.x, y: 0 }; + } + testRect.x = newPos.x; + testRect.y = newPos.y; + } + }); + }); + } + return layout; + }, [graph, options]); +} + +function getNodesFromLayout( + nodesDict: Record, + layout: NGraphLayout, + options: Pick +) { + const layoutNodes: PositionedNode[] = []; + layout.forEachBody((body, key) => { + const simpleNode = nodesDict[key]; + if (!simpleNode) { + console.warn(`Found ${key} but not in dict ${nodesDict}`); + return; + } + layoutNodes.push({ + ...simpleNode, + body, + size: simpleNode.size ?? options.defaultSize, + position: layout.getNodePosition(key), + backgroundColor: simpleNode.backgroundColor, + containerPosition: zeroPoint, + }); + }); + return layoutNodes; +} + +/** Iterate over edges and create line structures */ +function getEdgesFromLayout(graph: NGraph, nodesDict: Record) { + const layoutEdges: PositionedEdge[] = []; + graph.forEachLink((link) => { + layoutEdges.push({ + ...link.data, + name: `${link.data.from} -> ${link.data.to}`, + from: link.fromId as string, + to: link.toId as string, + fromNode: nodesDict[link.fromId], + toNode: nodesDict[link.toId], + link, + }); + }); + return layoutEdges; +} + +export function useContainingRect(targetArea: Size, positionedNodes: PositionedNode[], textSize: number) { + // get the containing rectangle + return useMemo( + () => getContainingRect(positionedNodes, targetArea, textSize * 2), + [targetArea, positionedNodes, textSize] + ); +} + +function useCreateGraph(nodes: SimpleNode[], edges: SimpleEdge[]) { + const { graph, allLinks } = useMemo(() => { + console.log("Creating Graph"); + const graph = createGraph({ multigraph: true }); + nodes.forEach((node) => { + graph.addNode(node.name, node); + }); + const allLinks: Link[] = []; + // Add links between nodes, using the aliases from above, which covers which nodes are expanded + for (const edge of edges) { + if (edge.from !== edge.to) { + allLinks.push(graph.addLink(edge.from, edge.to, edge)); + } + } + return { graph, allLinks }; + }, [edges, nodes]); + return { graph, allLinks }; +} + +export function useDefaultOptions({ + defaultSize, + iterations = 100, + debugMassNode = false, + textSize, + gravity = -12, + springCoefficient = 0.8, + springLength = 10, + theta = 0.8, + dragCoefficient = 0.9, + dimensions = 2, + timeStep = 0.5, + debug = false, + adaptiveTimeStepWeight = 0, +}: GraphOptions) { + return useMemo( + () => ({ + defaultSize: defaultSize ?? { width: 100, height: 80 }, + iterations, + debugMassNode, + gravity, + springCoefficient, + springLength, + theta, + dragCoefficient, + dimensions, + timeStep, + adaptiveTimeStepWeight, + debug, + textSize: textSize ?? (defaultSize?.width ?? 100) / 12, + }), + [ + adaptiveTimeStepWeight, + debug, + debugMassNode, + defaultSize, + dimensions, + dragCoefficient, + gravity, + iterations, + springCoefficient, + springLength, + textSize, + theta, + timeStep, + ] + ); +} + +export function useSimpleGraph( + nodes: SimpleNode[], + edges: SimpleEdge[], + options: Pick, "defaultSize" | "iterations"> +): [PositionedNode[], PositionedEdge[]] { + const { graph } = useCreateGraph(nodes, edges); + const _options = useDefaultOptions(options); + const layout = useLayout(graph, _options); + const nodesDict = useMemo(() => keyBy(nodes, (n) => n.name), [nodes]); + return useMemo<[PositionedNode[], PositionedEdge[]]>(() => { + const positionedNodes = getNodesFromLayout(nodesDict, layout, options); + const positionedEdges = getEdgesFromLayout( + graph, + keyBy(positionedNodes, (node) => node.name) + ); + return [positionedNodes, positionedEdges]; + }, [graph, layout, options, nodesDict]); +} diff --git a/graph/src/use-ngraph-structure.tsx b/graph/src/use-ngraph-structure.tsx index 9893ff1..11380a4 100644 --- a/graph/src/use-ngraph-structure.tsx +++ b/graph/src/use-ngraph-structure.tsx @@ -2,16 +2,16 @@ import { groupBy, keyBy } from "lodash"; import createLayout from "ngraph.forcelayout"; import createGraph, { Link } from "ngraph.graph"; import { useCallback, useMemo } from "react"; -import { calculateDistance, GraphEdge, GraphNode, GraphNodeVisible } from "./model"; +import { calculateDistance, HierarchicalEdge, HierarchicalNode, GraphNodeVisible, Size } from "./model"; /** Simply group nodes by their parent, null means no parent */ -export function useChildrenNodesByParent(nodes: GraphNode[]) { - const childrenNodesByParent = useMemo>( +export function useChildrenNodesByParent(nodes: HierarchicalNode[]) { + const childrenNodesByParent = useMemo>( () => groupBy(nodes, (node) => node.parent), [nodes] ); const nodesDict = keyBy(nodes, (n) => n.name); - return { childrenNodesByParent, nodesDict }; + return [childrenNodesByParent, nodesDict ] as [Record,Record]; } /** Properties of the visible Graph */ @@ -28,8 +28,8 @@ interface UseVisibleNodesResult { /** Return the graph structure that is dependent on what is hierarchically visible */ export function useVisibleNodes( - nodes: GraphNode[], - nodeChildrenByParent: Record, + nodes: HierarchicalNode[], + nodeChildrenByParent: Record, expanded: string[] | null ): UseVisibleNodesResult { const visibleNodes: GraphNodeVisible[] = useMemo( @@ -67,7 +67,7 @@ export function useVisibleNodes( /** Returns the node names of all the children in the tree */ export function getAllChildren( - childrenNodesByParent: Record, + childrenNodesByParent: Record, visibleNodesDict: Record, nodeName: string ): string[] { @@ -82,7 +82,7 @@ export function getAllChildren( export function trickleUpMass( visibleNodesDict: Record, layout: ReturnType, - node: GraphNode + node: HierarchicalNode ) { // Given a leaf node, trickly the mass up through the visible nodes if (!node.parent) return; @@ -95,16 +95,17 @@ export function trickleUpMass( export function useGraph( visibleNodes: GraphNodeVisible[], - childrenNodesByParent: Record, + childrenNodesByParent: Record, getVisibleNode: (name: string) => GraphNodeVisible, - edges: GraphEdge[], - visibleNodesDict: Record + edges: HierarchicalEdge[], + visibleNodesDict: Record, + defaultSize: Size ) { const { graph, allLinks } = useMemo(() => { - const graph = createGraph({ multigraph: true }); + const graph = createGraph({ multigraph: true }); visibleNodes.forEach((node) => graph.addNode(node.name, node)); - const allLinks: Link[] = []; + const allLinks: Link[] = []; // Add links between nodes, using the aliases from above, which covers which nodes are expanded for (const edge of edges) { if (getVisibleNode(edge.from) !== getVisibleNode(edge.to)) { @@ -112,14 +113,10 @@ export function useGraph( edge, visibleNodesDict, getVisibleNode(edge.from), - getVisibleNode(edge.to) + getVisibleNode(edge.to), + defaultSize ); allLinks.push(graph.addLink(getVisibleNode(edge.from)?.name, getVisibleNode(edge.to)?.name, edge)); - // const parent = visibleNodesDict[edge.from]?.parent; - // if sibling with the same parent, then remove the from node from the nodeParentToChildren list - // if (parent && childrenNodesByParent[parent] && parent === visibleNodesDict[edge.to]?.parent) { - // childrenNodesByParent[parent] = childrenNodesByParent[parent].filter((p) => p.name !== edge.from); - // } } } for (const parent of Object.keys(childrenNodesByParent)) { @@ -127,17 +124,17 @@ export function useGraph( for (const child of childrenNodesByParent[parent]) { if (!visibleNodesDict[child.name] || !visibleNodesDict[child.name].visible) continue; const c = visibleNodesDict[child.name]; - const edge: GraphEdge = { + const edge: HierarchicalEdge = { from: child.name, to: parent, label: `${parent} to ${c.name}`, hierarchical: true, }; - edge.score = calculateDistance(edge, visibleNodesDict, child, visibleNodesDict[parent]); + edge.score = calculateDistance(edge, visibleNodesDict, child, visibleNodesDict[parent], defaultSize); allLinks.push(graph.addLink(child.name, parent, edge)); } } return { graph, allLinks }; - }, [childrenNodesByParent, edges, getVisibleNode, visibleNodes, visibleNodesDict]); + }, [childrenNodesByParent, defaultSize, edges, getVisibleNode, visibleNodes, visibleNodesDict]); return { graph, allLinks }; } diff --git a/graph/src/use-ngraph.ts b/graph/src/use-ngraph.ts index aa47e85..d2cb8bf 100644 --- a/graph/src/use-ngraph.ts +++ b/graph/src/use-ngraph.ts @@ -1,21 +1,9 @@ -import { groupBy, uniq } from "lodash"; -import createLayout from "ngraph.forcelayout"; +import { groupBy, keyBy } from "lodash"; +import createLayout, { PhysicsSettings, Vector, Layout as NGraphLayout } from "ngraph.forcelayout"; import { Graph, Link } from "ngraph.graph"; -import { useCallback, useMemo } from "react"; -import { - getAnchor, - getContainingRect, - getMidPoint, - GraphEdge, - GraphNode, - GraphNodeVisible, - Layout, - LayoutEdge, - LayoutNode, - LayoutNodeVisible, - Size, - Visible, -} from "./model"; +import { useMemo } from "react"; +import { useSimpleGraph } from "."; +import { getAnchor, getContainingRect, getMidPoint, HierarchicalEdge, HierarchicalNode, Size } from "./model"; import { getAllChildren, trickleUpMass, @@ -24,193 +12,256 @@ import { useVisibleNodes, } from "./use-ngraph-structure"; -interface UseNGraphOptions { +export interface UseNGraphOptions { /** Default size of all nodes */ defaultSize?: Size; /** Number of iterations */ iterations?: number; textSize?: number; + physics?: Partial; } -function useLayout( - graph: T, - allLinks: Link[], - leafNodes: GraphNodeVisible[], - visibleNodesDict: Record, - iterations: number -) { - return useMemo(() => { - // Do the LAYOUT - const layout = createLayout(graph, { - dimensions: 2, - gravity: -40, - springLength: 35, - }); - for (const link of allLinks) { - const spring = layout.getSpring(link); - const edge = link.data; - if (spring && edge && link.data.score) { - spring.length = link.data.score; - } - } - layout.forEachBody((body, key, dict) => { - body.mass = 5; - }); - leafNodes.forEach((n) => trickleUpMass(visibleNodesDict, layout, n)); - for (let i = 0; i < iterations; ++i) layout.step(); - return layout; - }, [allLinks, graph, iterations, leafNodes, visibleNodesDict]); -} -function resizeNodeTree( - leafNodes: GraphNodeVisible[], - visibleNodesDict: Record, - layoutNodesDict: Record, - childrenNodesByParent: Record, - options: Required -) { - let treeLayer = leafNodes.map((l) => l.name); - let levelNumber = 1; - while (treeLayer.length > 0) { - treeLayer = treeLayer - .filter((l) => visibleNodesDict[l].visible) - .map((l) => visibleNodesDict[l].parent) - .filter(Boolean) as string[]; - for (const nodeName of treeLayer) { - // console.log( - // getAllChildren(childrenNodesByParent, visibleNodesDict, nodeName).map((v) => layoutNodesDict[v]) - // ); - const newPosSize = getContainingRect( - getAllChildren(childrenNodesByParent, visibleNodesDict, nodeName).map((v) => layoutNodesDict[v]), - options.textSize - ); - layoutNodesDict[nodeName].position = { - x: newPosSize.position.x + newPosSize.size.width / 2, - y: newPosSize.position.y + newPosSize.size.height / 2, - }; - layoutNodesDict[nodeName].size = newPosSize.size; - layoutNodesDict[nodeName].levelNumber = levelNumber; - } - levelNumber++; - } -} +// function useLayout( +// graph: Graph, +// allLinks: Link[], +// leafNodes: GraphNodeVisible[], +// visibleNodesDict: Record, +// iterations: number, +// options: Required +// ) { +// return useMemo(() => { +// // Do the LAYOUT +// // console.log("Physics", options.physics) +// const layout = createLayout(graph, { +// ...(options.physics || {}), +// gravity: -1200, +// dimensions: 2, +// }); +// // for (const link of allLinks) { +// // const spring = layout.getSpring(link); +// // const edge = link.data; +// // if (spring && edge && link.data.score) { +// // spring.length = link.data.score; +// // } +// // } +// layout.forEachBody((body) => (body.mass = 10 * options.defaultSize.width)); +// leafNodes.forEach((n) => trickleUpMass(visibleNodesDict, layout, n)); +// // const qt = new QuadTree(new Box(0, 0, 1000, 1000)); +// // graph.forEachNode((n) => { +// // const body = layout.getBody(n.id); +// // if (body) qt.insert(getPointsFromBox(body.pos.x, body.pos.y, n.data.size.width, n.data.size.height)); +// // }); +// for (let i = 0; i < iterations; ++i) { +// const oldPos: Record = {}; +// graph.forEachNode((n) => { +// const body1 = layout.getBody(n.id); +// if (!body1) return; +// oldPos[n.id] = body1?.pos; +// }); +// graph.forEachNode((node1) => { +// const body1 = layout.getBody(node1.id); +// if (!body1 || !node1 || !node1.data || !node1.data.size) return; +// const rect1 = { +// x: body1?.pos.x, +// y: body1?.pos.y, +// width: node1?.data?.size.width, +// height: node1?.data?.size.height, +// }; +// graph.forEachNode((node2) => { +// const body2 = layout.getBody(node2.id); +// if (!body2 || !node2 || !node2.data || !node2.data.size) return; +// const rect2 = { +// x: body2?.pos.x, +// y: body2?.pos.y, +// width: node2?.data?.size.width, +// height: node2?.data?.size.height, +// }; +// }); +// }); +// layout.step(); +// } +// return layout; +// }, [graph, iterations, leafNodes, options.defaultSize.width, options.physics, visibleNodesDict]); +// } -function loadUpNodes( - visibleNodesDict: Record, - layout: ReturnType, - options: Required -) { - const layoutNodes: (LayoutNode & Visible)[] = []; +// function resizeNodeTree( +// leafNodes: GraphNodeVisible[], +// visibleNodesDict: Record, +// layoutNodesDict: Record, +// childrenNodesByParent: Record, +// options: Required +// ) { +// // start off with the leaf Node names +// let treeLevel = leafNodes.map((node) => node.name); +// let levelNumber = 1; +// // while more leaf nodes to do +// while (treeLevel.length > 0) { +// for (const nodeName of treeLevel) { +// layoutNodesDict[nodeName].levelNumber = levelNumber; +// } +// // get all the parent node names of the current level +// treeLevel = treeLevel +// .filter((nodeName) => visibleNodesDict[nodeName].visible) +// .map((nodeName) => visibleNodesDict[nodeName].parent) +// .filter(Boolean) as string[]; +// for (const nodeName of treeLevel) { +// const newPosSize = getContainingRect( +// getAllChildren(childrenNodesByParent, visibleNodesDict, nodeName).map((v) => layoutNodesDict[v]), +// options.textSize +// ); +// if (!!layoutNodesDict[nodeName]) { +// layoutNodesDict[nodeName].position = { +// x: newPosSize.position.x + newPosSize.size.width / 2, +// y: newPosSize.position.y + newPosSize.size.height / 2, +// }; +// layoutNodesDict[nodeName].size = newPosSize.size; +// } +// } +// levelNumber++; +// } +// } - // DONE LAYOUT - NOW LOAD UP - const layoutNodesDict: Record = {}; - layout.forEachBody((body, key, dict) => { - if (visibleNodesDict[key].visible) { - const v: LayoutNodeVisible = { - ...visibleNodesDict[key], - body, - size: visibleNodesDict[key].size ?? options.defaultSize, - position: layout.getNodePosition(key), - }; - layoutNodes.push(v); - layoutNodesDict[key] = v; - } - }); - return { layoutNodes, layoutNodesDict }; -} +// function getNodesFromLayout( +// visibleNodesDict: Record, +// leafNodes: GraphNodeVisible[], +// layout: ReturnType, +// options: Required +// ) { +// const layoutNodes: (LayoutNode & Visible)[] = []; +// const leafDict = keyBy(leafNodes, (n) => n.name); +// // DONE LAYOUT - NOW LOAD UP +// const layoutNodesDict: Record = {}; +// layout.forEachBody((body, key) => { +// if (visibleNodesDict[key].visible) { +// const visibleNode = visibleNodesDict[key]; +// const v: LayoutNodeVisible = { +// ...visibleNode, +// body, +// levelNumber: 0, +// size: visibleNodesDict[key].size ?? options.defaultSize, +// position: layout.getNodePosition(key), +// backgroundColor: visibleNodesDict[key].backgroundColor, +// isLeaf: !!leafDict[key], +// }; +// layoutNodes.push(v); +// layoutNodesDict[key] = v; +// } +// }); +// for (const node of layoutNodes) { +// if (node.parent) node.parentNode = layoutNodesDict[node.parent]; +// } +// return { layoutNodes, layoutNodesDict }; +// } -function loadUpEdges( - graph: T, - layout: ReturnType, - options: Required -) { - const layoutEdges: LayoutEdge[] = []; - graph.forEachLink((link) => { - // if (link.data.hierarchical) return; - const fromPos = layout.getNodePosition(link.fromId); - const toPos = layout.getNodePosition(link.toId); - const fromNode = graph.getNode(link.fromId)?.data!; - if (!fromNode) { - console.error("Cannot find FROM node for ", link.fromId); - return; - } - const toNode = graph.getNode(link.toId)?.data!; - if (!toNode) { - console.error("Cannot find TO node for ", link.toId); - return; - } - const midPoint = { x: getMidPoint(fromPos.x, toPos.x, 0.5), y: getMidPoint(fromPos.y, toPos.y, 0.5) }; - const fromPoint = getAnchor(fromPos, fromNode.size ?? options.defaultSize, toPos); - const toPoint = getAnchor(toPos, toNode.size ?? options.defaultSize, fromPos); - layoutEdges.push({ - ...link.data, - name: `${link.data.from} -> ${link.data.to}`, - from: link.fromId as string, - to: link.toId as string, - points: [fromPoint, midPoint, toPoint], - hide: link.data.hierarchical, - link, - }); - }); - return layoutEdges; -} +/** Iterate over edges and create line structures */ +// function getEdgesFromLayout( +// graph: Graph, +// layout: NGraphLayout>, +// options: Required +// ) { +// const layoutEdges: LayoutEdge[] = []; +// graph.forEachLink((link) => { +// // if (link.data.hierarchical) return; +// const fromPos = layout.getNodePosition(link.fromId); +// const toPos = layout.getNodePosition(link.toId); +// const fromNode = graph.getNode(link.fromId)?.data! as LayoutNode; +// if (!fromNode) { +// console.error("Cannot find FROM node for ", link.fromId); +// return; +// } +// const toNode = graph.getNode(link.toId)?.data! as LayoutNode; +// if (!toNode) { +// console.error("Cannot find TO node for ", link.toId); +// return; +// } +// const midPoint = { x: getMidPoint(fromPos.x, toPos.x, 0.5), y: getMidPoint(fromPos.y, toPos.y, 0.5) }; +// const fromPoint = getAnchor(fromPos, fromNode.size ?? options.defaultSize, toPos); +// const toPoint = getAnchor(toPos, toNode.size ?? options.defaultSize, fromPos); +// layoutEdges.push({ +// ...link.data, +// name: `${link.data?.from} -> ${link.data?.to ?? "NULL"}`, +// from: link.fromId as string, +// to: link.toId as string, +// fromNode, +// toNode, +// points: [fromPoint, midPoint, toPoint], +// hide: link.data.hierarchical, +// link, +// }); +// }); +// return layoutEdges; +// } -function useLoadUp( - graph: T, - leafNodes: GraphNodeVisible[], - layout: ReturnType, - visibleNodesDict: Record, - childrenNodesByParent: Record, - options: Required -) { - return useMemo<[LayoutNodeVisible[], LayoutEdge[]]>(() => { - const { layoutNodes, layoutNodesDict } = loadUpNodes(visibleNodesDict, layout, options); - resizeNodeTree(leafNodes, visibleNodesDict, layoutNodesDict, childrenNodesByParent, options); - const layoutEdges = loadUpEdges(graph, layout, options); - return [layoutNodes, layoutEdges]; - }, [childrenNodesByParent, graph, layout, leafNodes, options, visibleNodesDict]); -} +/** The Layout in NGraph needs to be iterated. We also need to resize the hierarchical parent nodes so that they + * contain all their children. */ +// function useGraphStructureFromLayout( +// graph: T, +// leafNodes: GraphNodeVisible[], +// layout: ReturnType, +// visibleNodesDict: Record, +// childrenNodesByParent: Record, +// options: Required +// ) { +// return useMemo<[LayoutNodeVisible[], LayoutEdge[]]>(() => { +// const { layoutNodes, layoutNodesDict } = getNodesFromLayout(visibleNodesDict, leafNodes, layout, options); +// resizeNodeTree(leafNodes, visibleNodesDict, layoutNodesDict, childrenNodesByParent, options); +// const layoutEdges = getEdgesFromLayout(graph, layout, options); +// return [layoutNodes, layoutEdges]; +// }, [childrenNodesByParent, graph, layout, leafNodes, options, visibleNodesDict]); +// } -export function useNgraph( - nodes: GraphNode[], - edges: GraphEdge[], - expanded: string[] | null = null, - { defaultSize = { width: 12, height: 8 }, iterations = 100, textSize = 2, ...other }: UseNGraphOptions -): Layout { - const options = useMemo>( - () => ({ ...other, defaultSize, iterations, textSize }), - [defaultSize, iterations, textSize, other] - ); - const { childrenNodesByParent } = useChildrenNodesByParent(nodes); - const { visibleNodes, visibleNodesDict, getVisibleNode, leafNodes } = useVisibleNodes( - nodes, - childrenNodesByParent, - expanded - ); - const { graph, allLinks } = useGraph(visibleNodes, childrenNodesByParent, getVisibleNode, edges, visibleNodesDict); - const layout = useLayout(graph, allLinks, leafNodes, visibleNodesDict, iterations); - const [layoutNodes, layoutEdges] = useLoadUp( - graph, - leafNodes, - layout, - visibleNodesDict, - childrenNodesByParent, - options - ); - const positioned = useMemo(() => { - // TODO - raise issue the type is wrong in ngraph.layout - const { position, size } = getContainingRect(layoutNodes, options.textSize); - const width = Math.max(50, size.width); - const height = Math.max(40, size.height); - return { - nodes: layoutNodes, - edges: layoutEdges, - minPoint: { x: position.x - options.textSize, y: position.y - options.textSize }, - maxPoint: { x: position.x + width + options.textSize, y: position.y + height + options.textSize }, - tree: groupBy(layoutNodes, (n) => n.parent || ""), - expanded, - textSize: options.textSize, - }; - }, [layoutNodes, options.textSize, layoutEdges, expanded]); - return positioned; -} +// export function useNgraph( +// nodes: HierarchicalNode[], +// edges: HierarchicalEdge[], +// expanded: string[] = [], +// options:GraphOptions +// ) { + +// const visibleNodes = nodes.filter( n=> n.parent===null || expanded.includes(n.parent)) +// const leafNodes =visibleNodes.filter( n=> n.parent===null) +// const { childrenNodesByParent } = useChildrenNodesByParent(visibleNodes); + +// // const { visibleNodes, visibleNodesDict, getVisibleNode, leafNodes } = useVisibleNodes( +// // nodes, +// // childrenNodesByParent, +// // expanded +// // ); +// const [ positionedLeafNodes, positionedLeafEdges ] = useSimpleGraph( +// leafNodes, +// edges, +// options +// ); +// const positionedNodeDict = keyBy(positionedLeafNodes,n=>n.name) +// const positionedParents = Object.keys(childrenNodesByParent).map( k => { + + +// }) + +// } + +// const [layoutNodes, layoutEdges] = useGraphStructureFromLayout( +// graph, +// leafNodes, +// layout, +// visibleNodesDict, +// childrenNodesByParent, +// options +// ); +// const positioned = useMemo(() => { +// // TODO - raise issue the type is wrong in ngraph.layout +// const { position, size } = getContainingRect(layoutNodes, options.textSize); +// const width = Math.max(2 * options.defaultSize.width, size.width); +// const height = Math.max(2 * options.defaultSize.height, size.height); +// return { +// nodes: layoutNodes, +// edges: layoutEdges, +// minPoint: { x: position.x - options.textSize, y: position.y - options.textSize }, +// maxPoint: { x: position.x + width + options.textSize, y: position.y + height + options.textSize }, +// tree: groupBy(layoutNodes, (n) => n.parent || ""), +// expanded, +// textSize: options.textSize, +// }; +// }, [layoutNodes, options.textSize, options.defaultSize.width, options.defaultSize.height, layoutEdges, expanded]); +// return positioned; +// } diff --git a/packages/example/src/data.ts b/packages/example/src/data.ts deleted file mode 100644 index f864e89..0000000 --- a/packages/example/src/data.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { GraphEdge } from "@diagrams/graph"; - -const nodeTree = { - Network: { - children: [ - "Data Network", - "Voice Network", - "Internet Connectivity", - "Virtual Private Network", - "Domain Services", - "Load Balancing", - ], - }, - Compute: { - children: ["Physical Compute", "Virtual Compute & Containers", "Compute on Demand"], - }, - Data: { - children: ["Database", "Distributed Cache", "Data Warehouse"], - }, -} as { [index: string]: { children: string[] } }; - -export const nodes = Object.keys(nodeTree).reduce( - (p, parent) => [ - ...p, - { name: parent, parent: null }, - ...nodeTree[parent].children.map((child) => ({ name: child, parent })), - ], - [] as any[] -); - -export const edges: GraphEdge[] = [ - { from: "Compute on Demand", to: "Virtual Compute & Containers", label: "Uses" }, - { from: "Virtual Compute & Containers", to: "Physical Compute", label: "Uses" }, - { from: "Database", to: "Virtual Compute & Containers", label: "Uses" }, - { from: "Database", to: "Load Balancing", label: "Uses" }, -]; diff --git a/packages/example/src/demo-graph2D.tsx b/packages/example/src/demo-graph2D.tsx deleted file mode 100644 index 289e9b1..0000000 --- a/packages/example/src/demo-graph2D.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Box } from "@chakra-ui/react"; -import { Graph2, useNgraph2 } from "@diagrams/graph"; -import * as React from "react"; -import { FC, useState } from "react"; -import { edges, nodes } from "./data"; - -interface DemoGraphProps { - // nodes: GraphNode[]; - // edges: GraphEdge[]; - // pumpProducer: string | null; - // pumpValue: string[] | null; - // orbit: boolean; -} - -export const DemoGraph2D: FC<{}> = ({}) => { - const [expanded, setExpanded] = useState([]); - const graph = useNgraph2(nodes, edges, { expanded, iterations: 500 }); - const onSelectNode = React.useCallback( - (args: { name: string }) => { - setExpanded((exp) => (exp.includes(args.name) ? exp.filter((e) => e !== args.name) : [...exp, args.name])); - }, - [] - ); - return ( - - ; - - ); -};