Files
dbgate/packages/web/src/designer/GraphLayout.ts
2022-01-16 17:07:27 +01:00

357 lines
10 KiB
TypeScript

import _ from 'lodash';
import { IBoxBounds, IPoint, rectangleDistance, rectangleIntersectArea, Vector2D } from './designerMath';
const MIN_NODE_DISTANCE = 50;
const SPRING_LENGTH = 100;
const SPRINGY_STEPS = 50;
const GRAVITY_X = 0.005;
const GRAVITY_Y = 0.01;
const REPULSION = 500_000;
const MAX_FORCE_SIZE = 100;
const NODE_MARGIN = 20;
const MOVE_STEP = 20;
const MOVE_BIG_STEP = 70;
const MOVE_STEP_COUNT = 1000;
const MINIMAL_SCORE_BENEFIT = 1;
const SCORE_ASPECT_RATIO = 1.6;
class GraphNode {
neightboors: GraphNode[] = [];
radius: number;
constructor(
public graph: GraphDefinition,
public designerId: string,
public width: number,
public height: number,
public fixedPosition: IPoint
) {}
initialize() {
this.radius = Math.sqrt((this.width * this.width) / 4 + (this.height * this.height) / 4);
}
}
class GraphEdge {
constructor(public graph: GraphDefinition, public source: GraphNode, public target: GraphNode) {}
}
// function initialCompareNodes(a: GraphNode, b: GraphNode) {
// if (a.neightboors.length < b.neightboors.length) return -1;
// if (a.neightboors.length > b.neightboors.length) return 1;
// if (a.height < b.height) return -1;
// if (a.height > b.height) return 1;
// return;
// }
export class GraphDefinition {
nodes: { [designerId: string]: GraphNode } = {};
edges: GraphEdge[] = [];
addNode(designerId: string, width: number, height: number, fixedPosition: IPoint) {
this.nodes[designerId] = new GraphNode(this, designerId, width, height, fixedPosition);
}
addEdge(sourceId: string, targetId: string) {
const source = this.nodes[sourceId];
const target = this.nodes[targetId];
if (
source &&
target &&
!this.edges.find(x => (x.source == source && x.target == target) || (x.target == source && x.source == target))
) {
this.edges.push(new GraphEdge(this, source, target));
}
}
initialize() {
for (const node of Object.values(this.nodes)) {
for (const edge of this.edges) {
if (edge.source == node && !node.neightboors.includes(edge.target)) node.neightboors.push(edge.target);
if (edge.target == node && !node.neightboors.includes(edge.source)) node.neightboors.push(edge.source);
}
node.initialize();
}
}
}
class LayoutNode {
position: Vector2D;
left: number;
right: number;
top: number;
bottom: number;
paddedRect: IBoxBounds;
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);
this.paddedRect = {
left: this.left - NODE_MARGIN,
top: this.top - NODE_MARGIN,
right: this.right + NODE_MARGIN,
bottom: this.bottom + NODE_MARGIN,
};
}
translate(dx: number, dy: number) {
if (this.node.fixedPosition) return this;
return new LayoutNode(this.node, this.x + dx, this.y + dy);
}
distanceTo(node: LayoutNode) {
return rectangleDistance(this, node);
}
intersectArea(node: LayoutNode) {
return rectangleIntersectArea(this.paddedRect, node.paddedRect);
}
}
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, new Vector2D(direction.x * GRAVITY_X, direction.y * GRAVITY_Y));
}
}
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<string>) {
const nodesSorted = _.sortBy(nodes, [x => x.neightboors.length, x => x.height, x => x.designerId]);
for (const node of nodesSorted) {
if (addedNodes.has(node.designerId)) continue;
addedNodes.add(node.designerId);
res.push(node);
addNodeNeighboors(node.neightboors, res, addedNodes);
}
return res;
}
export class GraphLayout {
nodes: { [designerId: string]: LayoutNode } = {};
edges: LayoutEdge[] = [];
constructor(public graph: GraphDefinition) {}
static createCircle(graph: GraphDefinition, middle: IPoint = { x: 0, y: 0 }): GraphLayout {
const res = new GraphLayout(graph);
if (_.isEmpty(graph.nodes)) return res;
const addedNodes = new Set<string>();
const circleSortedNodes: GraphNode[] = [];
addNodeNeighboors(
_.values(graph.nodes).filter(x => !x.fixedPosition),
circleSortedNodes,
addedNodes
);
const nodeRadius = _.max(circleSortedNodes.map(x => x.radius));
const nodeCount = circleSortedNodes.length;
const radius = (nodeCount * nodeRadius) / (2 * Math.PI) + nodeRadius;
let angle = 0;
const dangle = (2 * Math.PI) / circleSortedNodes.length;
for (const node of circleSortedNodes) {
res.nodes[node.designerId] = new LayoutNode(
node,
middle.x + Math.sin(angle) * radius,
middle.y + Math.cos(angle) * radius
);
angle += dangle;
}
for (const node of _.values(graph.nodes).filter(x => x.fixedPosition)) {
res.nodes[node.designerId] = new LayoutNode(node, node.fixedPosition.x, node.fixedPosition.y);
}
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;
}
fixViewBox() {
const minX = _.min(_.values(this.nodes).map(n => n.left));
const minY = _.min(_.values(this.nodes).map(n => n.top));
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;
}
score() {
let res = 0;
for (const n1 of _.values(this.nodes)) {
for (const n2 of _.values(this.nodes)) {
if (n1.node.designerId == n2.node.designerId) {
continue;
}
res += n1.intersectArea(n2);
}
}
const minX = _.min(_.values(this.nodes).map(n => n.left));
const minY = _.min(_.values(this.nodes).map(n => n.top));
const maxX = _.max(_.values(this.nodes).map(n => n.right));
const maxY = _.max(_.values(this.nodes).map(n => n.bottom));
res += maxX - minX;
res += (maxY - minY) * SCORE_ASPECT_RATIO;
return res;
}
tryMoveNode(node: LayoutNode): GraphLayout[] {
if (node.node.fixedPosition) return [];
return [
this.changePositions(x => (x == node ? node.translate(MOVE_STEP, 0) : x)),
this.changePositions(x => (x == node ? node.translate(-MOVE_STEP, 0) : x)),
this.changePositions(x => (x == node ? node.translate(0, MOVE_STEP) : x)),
this.changePositions(x => (x == node ? node.translate(0, -MOVE_STEP) : x)),
this.changePositions(x => (x == node ? node.translate(MOVE_BIG_STEP, MOVE_BIG_STEP) : x)),
this.changePositions(x => (x == node ? node.translate(MOVE_BIG_STEP, -MOVE_BIG_STEP) : x)),
this.changePositions(x => (x == node ? node.translate(-MOVE_BIG_STEP, MOVE_BIG_STEP) : x)),
this.changePositions(x => (x == node ? node.translate(-MOVE_BIG_STEP, -MOVE_BIG_STEP) : x)),
];
}
tryMoveElement() {
let res = null;
let resScore = null;
for (const node of _.values(this.nodes)) {
for (const item of this.tryMoveNode(node)) {
const score = item.score();
if (resScore == null || score < resScore) {
res = item;
resScore = score;
}
}
}
return res;
}
doMoveSteps() {
let res: GraphLayout = this;
let score = res.score();
for (let step = 0; step < MOVE_STEP_COUNT; step++) {
const lastRes = res;
res = res.tryMoveElement();
const newScore = res.score();
// console.log('SCORE, NEW SCORE', score, newScore);
if (score - newScore < MINIMAL_SCORE_BENEFIT) return lastRes;
score = newScore;
}
return res;
}
}