Skip to content

Commit

Permalink
[studio] add "force directed graph" logic #790 (#847)
Browse files Browse the repository at this point in the history
force directed graph init

Co-authored-by: Michael Vlach <vlach.michael@gmail.com>
  • Loading branch information
janavlachova and michaelvlach authored Dec 11, 2023
1 parent 6fbcde1 commit 2b7cd75
Show file tree
Hide file tree
Showing 12 changed files with 440 additions and 8 deletions.
136 changes: 136 additions & 0 deletions agdb_studio/src/composables/graph/ForceDirectedGraph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { ref } from "vue";
import Line from "./line";
import Vector from "./vector";

export type Node = {
index: number;
pos: Vector;
next?: Vector;
connections: number[];
connectionIndexes: number[];
velocity?: Vector;
};

export type GraphEdge = {
toNode: number;
index: number;
};

export type GraphNode = {
index: number;
edges: GraphEdge[];
};

export type Graph = {
nodes: GraphNode[];
};

const ATTRACTION_CONSTANT = 0.1;
const REPULSION_CONSTANT = 1000.0;
const SPRING_LENGTH = 100.0;
const DAMPER = 0.5;

export default function useForceDirectedGraph() {
const graph = ref<Graph>();
const nodes = ref<Node[]>([]);
const angle = ref(0.1);

const setGraph = (value: Graph): void => {
graph.value = value;
};

const getNodes = (): Node[] => {
return nodes.value;
};

const simulate = (): void => {
load();

let iterations = 0;

while (applyForces() && iterations < 500) {
iterations++;
}
};

const nextPos = (): Vector => {
angle.value += 0.1;
const distance = 10.0 * angle.value;
return new Vector([Math.cos(angle.value) * distance, Math.sin(angle.value) * distance]);
};

const load = (): void => {
if (!graph.value) return;

nodes.value = graph.value.nodes.map((node) => {
const _node: Node = {
index: node.index,
pos: nextPos(),
connections: [],
connectionIndexes: [],
};

for (const edge of node.edges) {
if (edge.toNode !== node.index) {
_node.connections.push(edge.toNode);
_node.connectionIndexes.push(edge.index);
}
}

return _node;
});
};

const attractionForce = (node1: Node, node2: Node): Vector => {
const line = new Line(node1.pos, node2.pos);
const distance = Math.max(line.getLength(), 1.0);
const force = ATTRACTION_CONSTANT * Math.max(distance - SPRING_LENGTH, 0.0);
const angle = line.getAngle();
return new Vector([force, angle]);
};

const repulsionForce = (node1: Node, node2: Node): Vector => {
const line = new Line(node1.pos, node2.pos);
const distance = Math.max(line.getLength(), 1.0);
const force = -REPULSION_CONSTANT / (distance * distance);
const angle = line.getAngle();
return new Vector([force, angle]);
};

// Return true if the simulation should continue, false otherwise
const applyForces = (): boolean => {
if (!graph.value) return false;
let totalMovement = 0.0;

for (const node of nodes.value) {
const line = new Line(new Vector(), node.pos);
const currentPosition = new Vector([line.getLength(), line.getAngle()]);
const netForce: Vector = new Vector();

node.velocity = new Vector();

for (const connection of node.connections) {
netForce.add(
attractionForce(node, nodes.value.find((n) => n.index === connection) as Node),
);
}

for (const otherNode of nodes.value) {
if (otherNode.index !== node.index) {
netForce.add(repulsionForce(node, otherNode));
}
}

node.velocity.add(netForce).mult(DAMPER);
node.next = currentPosition.add(node.velocity);
}

for (const node of nodes.value) {
totalMovement += node.pos.dist(node.next as Vector);
node.pos = node.next as Vector;
}
return totalMovement >= 10.0;
};

return { getNodes, setGraph, simulate };
}
23 changes: 23 additions & 0 deletions agdb_studio/src/composables/graph/line.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Vector from "./vector";

export default class Line {
public start: Vector;
public end: Vector;

constructor(start: Vector, end: Vector) {
this.start = start;
this.end = end;
}

public getLength(): number {
return this.start.dist(this.end);
}

public getAngle(): number {
return Math.atan2(this.end.y - this.start.y, this.end.x - this.start.x);
}

public getMidPoint(): Vector {
return new Vector([(this.start.x + this.end.x) / 2, (this.start.y + this.end.y) / 2]);
}
}
76 changes: 76 additions & 0 deletions agdb_studio/src/composables/graph/vector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
type VectorOptions =
| [number, number]
| {
x: number;
y: number;
};

export default class Vector {
public x: number;
public y: number;

constructor(options: VectorOptions | undefined = undefined) {
let values: [number, number] = [0, 0];
if (Array.isArray(options)) {
values = options;
} else if (options !== undefined) {
values = [options.x, options.y];
}
this.x = values[0];
this.y = values[1];
}

public copy(): Vector {
return new Vector({ x: this.x, y: this.y });
}

public set(x: number, y: number): Vector {
this.x = x;
this.y = y;
return this;
}

public add(v: Vector): Vector {
this.x += v.x;
this.y += v.y;
return this;
}

public sub(v: Vector): Vector {
this.x -= v.x;
this.y -= v.y;
return this;
}

public mult(n: number): Vector {
this.x *= n;
this.y *= n;
return this;
}

public div(n: number): Vector {
this.x /= n;
this.y /= n;
return this;
}

public dist(v: Vector): number {
return Math.sqrt(Math.pow(this.x - v.x, 2) + Math.pow(this.y - v.y, 2));
}

public static add(v1: Vector, v2: Vector): Vector {
return new Vector({ x: v1.x + v2.x, y: v1.y + v2.y });
}

public static sub(v1: Vector, v2: Vector): Vector {
return new Vector({ x: v1.x - v2.x, y: v1.y - v2.y });
}

public static mult(v: Vector, n: number): Vector {
return new Vector({ x: v.x * n, y: v.y * n });
}

public static div(v: Vector, n: number): Vector {
return new Vector({ x: v.x / n, y: v.y / n });
}
}
2 changes: 1 addition & 1 deletion agdb_studio/tests/components/auth/LoginForm.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import LoginForm from "../../../src/components/auth/LoginForm.vue";
import LoginForm from "@/components/auth/LoginForm.vue";

describe("LoginForm", () => {
it("renders properly", () => {
Expand Down
62 changes: 62 additions & 0 deletions agdb_studio/tests/composable/graph/ForceDirectedGraph.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import useForceDirectedGraph, { type Graph } from "@/composables/graph/ForceDirectedGraph";
import { describe, it, expect } from "vitest";

describe("useForceDirectedGraph", () => {
it("should simulate the force-directed graph", () => {
const { setGraph, simulate, getNodes } = useForceDirectedGraph();

const graph: Graph = {
nodes: [
{
index: 0,
edges: [
{
index: 0,
toNode: 1,
},
],
},
{
index: 1,
edges: [
{
index: 0,
toNode: 0,
},
],
},
{
index: 2,
edges: [],
},
{
index: 3,
edges: [
{
index: 0,
toNode: 0,
},
{
index: 1,
toNode: 1,
},
],
},
],
};

setGraph(graph);
simulate();

const nodes = getNodes();

expect(nodes.length).to.equal(4);
});

it("should not fail if graph was not set ", () => {
const { simulate, getNodes } = useForceDirectedGraph();

simulate();
expect(getNodes().length).to.equal(0);
});
});
27 changes: 27 additions & 0 deletions agdb_studio/tests/composable/graph/line.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Line from "@/composables/graph/line";
import Vector from "@/composables/graph/vector";
import { describe, it, expect, beforeEach } from "vitest";

describe("Line", () => {
let line: Line;

beforeEach(() => {
const start = new Vector([0, 0]);
const end = new Vector([3, 4]);
line = new Line(start, end);
});

it("should calculate the length correctly", () => {
expect(line.getLength()).toBe(5);
});

it("should calculate the angle correctly", () => {
expect(line.getAngle()).toBeCloseTo(0.927295218, 6);
});

it("should calculate the midpoint correctly", () => {
const midpoint = line.getMidPoint();
expect(midpoint.x).toBe(1.5);
expect(midpoint.y).toBe(2);
});
});
Loading

0 comments on commit 2b7cd75

Please sign in to comment.