mirror of
https://github.com/DeNNiiInc/dbgate.git
synced 2026-04-20 21:46:00 +00:00
485 lines
15 KiB
TypeScript
485 lines
15 KiB
TypeScript
import _ from 'lodash';
|
|
import {
|
|
IBoxBounds,
|
|
IPoint,
|
|
rectangleDistance,
|
|
rectangleIntersectArea,
|
|
solveOverlapsInIntervalArray,
|
|
Vector2D,
|
|
} from './designerMath';
|
|
import { union, intersection } from 'interval-operations';
|
|
|
|
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 REPULSION = 1000;
|
|
const MAX_FORCE_SIZE = 100;
|
|
const NODE_MARGIN = 30;
|
|
const GRAVITY_EXPONENT = 1.05;
|
|
|
|
// const MOVE_STEP = 20;
|
|
// const MOVE_BIG_STEP = 50;
|
|
// const MOVE_STEP_COUNT = 100;
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
detectCentreNode(): GraphNode {
|
|
if (_.values(this.nodes).find(x => x.fixedPosition)) {
|
|
return null;
|
|
}
|
|
const res: GraphNode[] = [];
|
|
for (const n1 of _.values(this.nodes)) {
|
|
let candidate = true;
|
|
for (const n2 of _.values(this.nodes)) {
|
|
if (n1 == n2) {
|
|
continue;
|
|
}
|
|
if (!n1.neightboors.includes(n2)) {
|
|
candidate = false;
|
|
break;
|
|
}
|
|
}
|
|
if (candidate) {
|
|
res.push(n1);
|
|
}
|
|
}
|
|
if (res.length == 1) return res[0];
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class LayoutNode {
|
|
position: Vector2D;
|
|
left: number;
|
|
right: number;
|
|
top: number;
|
|
bottom: number;
|
|
// paddedRect: IBoxBounds;
|
|
|
|
rangeXPadded: [number, number];
|
|
rangeYPadded: [number, 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);
|
|
|
|
this.rangeXPadded = [this.left - NODE_MARGIN, this.right + NODE_MARGIN];
|
|
this.rangeYPadded = [this.top - NODE_MARGIN, this.bottom + NODE_MARGIN];
|
|
// 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, forceMoveFixed = false) {
|
|
if (this.node.fixedPosition && !forceMoveFixed) 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);
|
|
// }
|
|
hasPaddedIntersect(node: LayoutNode) {
|
|
const xIntersection = intersection(this.rangeXPadded, node.rangeXPadded) as [number, number];
|
|
const yIntersection = intersection(this.rangeYPadded, node.rangeYPadded) as [number, number];
|
|
return (
|
|
xIntersection &&
|
|
xIntersection[1] - xIntersection[0] > 0.1 &&
|
|
yIntersection &&
|
|
yIntersection[1] - yIntersection[0] > 0.1
|
|
);
|
|
}
|
|
}
|
|
|
|
class ForceAlgorithmStep {
|
|
nodeForces: { [designerId: string]: Vector2D } = {};
|
|
constructor(public layout: GraphLayout) {}
|
|
|
|
applyForce(node: LayoutNode, force: Vector2D) {
|
|
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() {
|
|
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)) {
|
|
this.applyForce(
|
|
node,
|
|
// new Vector2D(-node.position.x * GRAVITY_X, -node.position.y * GRAVITY_Y)
|
|
|
|
new Vector2D(
|
|
-Math.pow(Math.abs(node.position.x), GRAVITY_EXPONENT) * Math.sign(node.position.x) * GRAVITY_X,
|
|
-Math.pow(Math.abs(node.position.y), GRAVITY_EXPONENT) * Math.sign(node.position.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[] = [];
|
|
|
|
const centreNode = graph.detectCentreNode();
|
|
|
|
addNodeNeighboors(
|
|
_.values(graph.nodes).filter(x => x != centreNode && !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);
|
|
}
|
|
|
|
if (centreNode) {
|
|
res.nodes[centreNode.designerId] = new LayoutNode(centreNode, middle.x, middle.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, callFillEdges = true): GraphLayout {
|
|
const res = new GraphLayout(this.graph);
|
|
res.nodes = _.mapValues(this.nodes, nodeFunc);
|
|
if (callFillEdges) 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, true));
|
|
}
|
|
|
|
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), false),
|
|
// this.changePositions(x => (x == node ? node.translate(-MOVE_STEP, 0) : x), false),
|
|
// this.changePositions(x => (x == node ? node.translate(0, MOVE_STEP) : x), false),
|
|
// this.changePositions(x => (x == node ? node.translate(0, -MOVE_STEP) : x), false),
|
|
|
|
// this.changePositions(x => (x == node ? node.translate(MOVE_BIG_STEP, MOVE_BIG_STEP) : x), false),
|
|
// this.changePositions(x => (x == node ? node.translate(MOVE_BIG_STEP, -MOVE_BIG_STEP) : x), false),
|
|
// this.changePositions(x => (x == node ? node.translate(-MOVE_BIG_STEP, MOVE_BIG_STEP) : x), false),
|
|
// this.changePositions(x => (x == node ? node.translate(-MOVE_BIG_STEP, -MOVE_BIG_STEP) : x), false),
|
|
// ];
|
|
// }
|
|
|
|
// 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();
|
|
// const start = new Date().getTime();
|
|
// for (let step = 0; step < MOVE_STEP_COUNT; step++) {
|
|
// const lastRes = res;
|
|
// res = res.tryMoveElement();
|
|
// if (!res) {
|
|
// lastRes.fillEdges();
|
|
// return lastRes;
|
|
// }
|
|
// const newScore = res.score();
|
|
// // console.log('STEP, SCORE, NEW SCORE', step, score, newScore);
|
|
// if (score - newScore < MINIMAL_SCORE_BENEFIT || new Date().getTime() - start > 1000) {
|
|
// lastRes.fillEdges();
|
|
// return lastRes;
|
|
// }
|
|
// score = newScore;
|
|
// }
|
|
// res.fillEdges();
|
|
// return res;
|
|
// }
|
|
|
|
solveOverlaps(): GraphLayout {
|
|
const nodes = _.sortBy(_.values(this.nodes), x => x.position.magnitude());
|
|
const res = new GraphLayout(this.graph);
|
|
for (const node of nodes) {
|
|
const placedNodes = _.values(res.nodes);
|
|
if (placedNodes.find(x => x.hasPaddedIntersect(node))) {
|
|
// if (node.node.designerId.startsWith('ProtocolWinPriceAllocation')) {
|
|
// console.log('PLACING NODE', node);
|
|
// console.log('PLACED NODES', placedNodes);
|
|
// }
|
|
|
|
// intersection found, must perform moving algorithm
|
|
const xIntervalArray = union(
|
|
...placedNodes.filter(x => intersection(x.rangeYPadded, node.rangeYPadded)).map(x => x.rangeXPadded)
|
|
);
|
|
|
|
const yIntervalArray = union(
|
|
...placedNodes.filter(x => intersection(x.rangeXPadded, node.rangeXPadded)).map(x => x.rangeYPadded)
|
|
);
|
|
|
|
// if (node.node.designerId.startsWith('ProtocolWinPriceAllocation')) {
|
|
// console.log('xIntervalArray', xIntervalArray);
|
|
// console.log('yIntervalArray', yIntervalArray);
|
|
// }
|
|
|
|
const newX = solveOverlapsInIntervalArray(node.x, node.node.width + NODE_MARGIN * 2, xIntervalArray as any);
|
|
const newY = solveOverlapsInIntervalArray(node.y, node.node.height + NODE_MARGIN * 2, yIntervalArray as any);
|
|
|
|
// if (node.node.designerId.startsWith('ProtocolWinPriceAllocation')) {
|
|
// console.log('NEWXY', newX, newY);
|
|
// }
|
|
|
|
const newNode =
|
|
Math.abs(newX - node.x) < Math.abs(newY - node.y)
|
|
? new LayoutNode(node.node, newX, node.y)
|
|
: new LayoutNode(node.node, node.x, newY);
|
|
res.nodes[node.node.designerId] = newNode;
|
|
|
|
// if (placedNodes.find(x => x.hasPaddedIntersect(newNode))) {
|
|
// console.log('!!!!! LOGICAL ERROR WHEN PLACING', newNode);
|
|
// }
|
|
} else {
|
|
res.nodes[node.node.designerId] = node;
|
|
}
|
|
}
|
|
res.fillEdges();
|
|
return res;
|
|
}
|
|
|
|
print() {
|
|
for (const node of _.values(this.nodes)) {
|
|
console.log({
|
|
designerId: node.node.designerId,
|
|
left: node.left,
|
|
top: node.top,
|
|
right: node.right,
|
|
bottom: node.bottom,
|
|
});
|
|
}
|
|
}
|
|
}
|