-
-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
6fbcde1
commit 2b7cd75
Showing
12 changed files
with
440 additions
and
8 deletions.
There are no files selected for viewing
136 changes: 136 additions & 0 deletions
136
agdb_studio/src/composables/graph/ForceDirectedGraph.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
62 changes: 62 additions & 0 deletions
62
agdb_studio/tests/composable/graph/ForceDirectedGraph.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
Oops, something went wrong.