diff --git a/packages/web/src/designer/Designer.svelte b/packages/web/src/designer/Designer.svelte index c3c536ace..47c2fb7c0 100644 --- a/packages/web/src/designer/Designer.svelte +++ b/packages/web/src/designer/Designer.svelte @@ -481,7 +481,7 @@ graph.initialize(); - const layout = GraphLayout.createCircle(graph).fixViewBox(); + const layout = GraphLayout.createCircle(graph).springyAlg().fixViewBox(); callChange(current => { return { diff --git a/packages/web/src/designer/GraphLayout.ts b/packages/web/src/designer/GraphLayout.ts index ae6407055..96b9dc26b 100644 --- a/packages/web/src/designer/GraphLayout.ts +++ b/packages/web/src/designer/GraphLayout.ts @@ -1,4 +1,12 @@ import _ from 'lodash'; +import { rectangleDistance, Vector2D } from './designerMath'; + +const MIN_NODE_DISTANCE = 50; +const SPRING_LENGTH = 100; +const SPRINGY_STEPS = 50; +const GRAVITY = 0.01; +const REPULSION = 500_000; +const MAX_FORCE_SIZE = 100; class GraphNode { neightboors: GraphNode[] = []; @@ -56,16 +64,111 @@ export class GraphDefinition { } class LayoutNode { - constructor(public node: GraphNode, public x: number, public y: number) {} + position: Vector2D; + left: number; + right: number; + top: number; + bottom: number; + + constructor(public node: GraphNode, public x: number, public y: number) { + this.left = x - node.width / 2; + this.top = y - node.height / 2; + this.right = x + node.width / 2; + this.bottom = y + node.height / 2; + this.position = new Vector2D(x, y); + } translate(dx: number, dy: number) { return new LayoutNode(this.node, this.x + dx, this.y + dy); } + + distanceTo(node: LayoutNode) { + return rectangleDistance( + this.left, + this.top, + this.right, + this.bottom, + node.left, + node.top, + node.right, + node.bottom + ); + } +} + +class ForceAlgorithmStep { + nodeForces: { [designerId: string]: Vector2D } = {}; + constructor(public layout: GraphLayout) {} + + applyForce(node: LayoutNode, force: Vector2D) { + // if (node.node.designerId == '7ef3dd10-6ec0-11ec-b179-6d02a7c011ad') { + // console.log('APPLY', node.node.designerId, force.x, force.y); + // } + + const size = force.magnitude(); + if (size > MAX_FORCE_SIZE) { + force = force.normalise().multiply(MAX_FORCE_SIZE); + } + + if (node.node.designerId in this.nodeForces) { + this.nodeForces[node.node.designerId] = this.nodeForces[node.node.designerId].add(force); + } else { + this.nodeForces[node.node.designerId] = force; + } + } + + applyCoulombsLaw() { + // console.log('****** COULOMB'); + + for (const n1 of _.values(this.layout.nodes)) { + for (const n2 of _.values(this.layout.nodes)) { + if (n1.node.designerId == n2.node.designerId) { + continue; + } + + const d = n1.position.subtract(n2.position); + const direction = d.normalise(); + const distance = n1.distanceTo(n2) + MIN_NODE_DISTANCE; + + this.applyForce(n1, direction.multiply((+0.5 * REPULSION) / (distance * distance))); + this.applyForce(n2, direction.multiply((-0.5 * REPULSION) / (distance * distance))); + } + } + } + + applyHooksLaw() { + for (const edge of this.layout.edges) { + const d = edge.target.position.subtract(edge.source.position); // the direction of the spring + const displacement = SPRING_LENGTH - edge.length; + var direction = d.normalise(); + + // apply force to each end point + this.applyForce(edge.source, direction.multiply(displacement * -0.5)); + this.applyForce(edge.target, direction.multiply(displacement * +0.5)); + } + } + + applyGravity() { + for (const node of _.values(this.layout.nodes)) { + var direction = node.position.multiply(-1.0); + this.applyForce(node, direction.multiply(GRAVITY)); + } + } + + moveNode(node: LayoutNode): LayoutNode { + const force = this.nodeForces[node.node.designerId]; + if (force) { + return node.translate(force.x, force.y); + } + return node; + } } class LayoutEdge { edge: GraphEdge; length: number; + source: LayoutNode; + target: LayoutNode; } function addNodeNeighboors(nodes: GraphNode[], res: GraphNode[], addedNodes: Set) { @@ -104,13 +207,28 @@ export class GraphLayout { res.nodes[node.designerId] = new LayoutNode(node, Math.sin(angle) * radius, Math.cos(angle) * radius); angle += dangle; } + res.fillEdges(); return res; } + fillEdges() { + this.edges = this.graph.edges.map(edge => { + const res = new LayoutEdge(); + res.edge = edge; + const n1 = this.nodes[edge.source.designerId]; + const n2 = this.nodes[edge.target.designerId]; + res.length = n1.distanceTo(n2); + res.source = n1; + res.target = n2; + return res; + }); + } + changePositions(nodeFunc: (node: LayoutNode) => LayoutNode): GraphLayout { const res = new GraphLayout(this.graph); res.nodes = _.mapValues(this.nodes, nodeFunc); + res.fillEdges(); return res; } @@ -118,6 +236,22 @@ export class GraphLayout { const minX = _.min(_.values(this.nodes).map(n => n.x - n.node.width / 2)); const minY = _.min(_.values(this.nodes).map(n => n.y - n.node.height / 2)); - return this.changePositions(n => n.translate(-minX + 10, -minY + 10)); + return this.changePositions(n => n.translate(-minX + 50, -minY + 50)); + } + + springyStep() { + const step = new ForceAlgorithmStep(this); + step.applyHooksLaw(); + step.applyCoulombsLaw(); + step.applyGravity(); + return this.changePositions(node => step.moveNode(node)); + } + + springyAlg() { + let res: GraphLayout = this; + for (let step = 0; step < SPRINGY_STEPS; step++) { + res = res.springyStep(); + } + return res; } } diff --git a/packages/web/src/designer/designerMath.ts b/packages/web/src/designer/designerMath.ts index 34984020c..b5f2b4452 100644 --- a/packages/web/src/designer/designerMath.ts +++ b/packages/web/src/designer/designerMath.ts @@ -55,3 +55,72 @@ export function intersectLineBox(p1: IPoint, p2: IPoint, box: IBoxBounds): IPoin return res; } + +export function rectangleDistance( + x1: number, + y1: number, + x1b: number, + y1b: number, + x2: number, + y2: number, + x2b: number, + y2b: number +) { + function dist(x1: number, y1: number, x2: number, y2: number) { + let dx = x1 - x2; + let dy = y1 - y2; + return Math.sqrt(dx * dx + dy * dy); + } + + let left = x2b < x1; + let right = x1b < x2; + let bottom = y2b < y1; + let top = y1b < y2; + + if (top && left) return dist(x1, y1b, x2b, y2); + else if (left && bottom) return dist(x1, y1, x2b, y2b); + else if (bottom && right) return dist(x1b, y1, x2, y2b); + else if (right && top) return dist(x1b, y1b, x2, y2); + else if (left) return x1 - x2b; + else if (right) return x2 - x1b; + else if (bottom) return y1 - y2b; + else if (top) return y2 - y1b; + // rectangles intersect + else return 0; +} + +export class Vector2D { + constructor(public x: number, public y: number) {} + + static random() { + return new Vector2D(10.0 * (Math.random() - 0.5), 10.0 * (Math.random() - 0.5)); + } + + add(v2: Vector2D) { + return new Vector2D(this.x + v2.x, this.y + v2.y); + } + + subtract(v2: Vector2D) { + return new Vector2D(this.x - v2.x, this.y - v2.y); + } + + multiply(n: number) { + return new Vector2D(this.x * n, this.y * n); + } + + divide(n: number) { + return new Vector2D(this.x / n || 0, this.y / n || 0); // Avoid divide by zero errors.. + } + + magnitude() { + return Math.sqrt(this.x * this.x + this.y * this.y); + } + + normal(n: number) { + return new Vector2D(-this.y, this.x); + } + + normalise() { + return this.divide(this.magnitude()); + } +}