Force-directed graph is working
This commit is contained in:
parent
ec3401845d
commit
2195f5ea56
|
@ -1,6 +1,6 @@
|
||||||
import { DisplayValue } from './display-value.js';
|
import { DisplayValue } from './display-value.js';
|
||||||
import { randomID } from '../../util/helpers.js';
|
import { randomID } from '../../util/helpers.js';
|
||||||
import { Rectangle, Vector } from './geometry.js';
|
import { Rectangle, Vector } from '../supporting/geometry/index.js';
|
||||||
|
|
||||||
export class Box {
|
export class Box {
|
||||||
constructor(name, parentEl, options = {}) {
|
constructor(name, parentEl, options = {}) {
|
||||||
|
@ -61,7 +61,7 @@ export class Box {
|
||||||
return this.el.id;
|
return this.el.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
getGeometry() {
|
get rect() {
|
||||||
const {
|
const {
|
||||||
width, height,
|
width, height,
|
||||||
} = this.el.getBoundingClientRect();
|
} = this.el.getBoundingClientRect();
|
||||||
|
|
|
@ -1,18 +1,16 @@
|
||||||
import {
|
import {
|
||||||
DEFAULT_OVERLAP_FORCE,
|
|
||||||
DEFAULT_TARGET_RADIUS,
|
|
||||||
DEFAULT_TIME_STEP,
|
DEFAULT_TIME_STEP,
|
||||||
DISTANCE_FACTOR,
|
MINIMUM_VELOCITY,
|
||||||
EPSILON,
|
|
||||||
MINIMUM_FORCE,
|
|
||||||
VISCOSITY_FACTOR,
|
VISCOSITY_FACTOR,
|
||||||
TIME_DILATION_FACTOR,
|
TIME_DILATION_FACTOR,
|
||||||
MINIMUM_VELOCITY,
|
MAX_STEPS_TO_EQUILIBRIUM,
|
||||||
|
TRANSLATION_VELOCITY_FACTOR,
|
||||||
} from '../../util/constants.js';
|
} from '../../util/constants.js';
|
||||||
import { Edge } from '../supporting/edge.js';
|
import { Edge } from '../supporting/edge.js';
|
||||||
import { WeightedDirectedGraph } from '../supporting/wdg.js';
|
import { WeightedDirectedGraph } from '../supporting/wdg.js';
|
||||||
import { Box } from './box.js';
|
import { Box } from './box.js';
|
||||||
import { Rectangle, Vector } from './geometry.js';
|
import { Vector, Rectangle } from '../supporting/geometry/index.js';
|
||||||
|
import { overlapRepulsionForce, targetRadiusForce } from './pairwise-forces.js';
|
||||||
|
|
||||||
// Render children with absolute css positioning.
|
// Render children with absolute css positioning.
|
||||||
|
|
||||||
|
@ -39,15 +37,17 @@ import { Rectangle, Vector } from './geometry.js';
|
||||||
// NOTE: When mouse is in our box, we could hijack the scroll actions to zoom in/out.
|
// NOTE: When mouse is in our box, we could hijack the scroll actions to zoom in/out.
|
||||||
|
|
||||||
export class ForceDirectedGraph extends WeightedDirectedGraph {
|
export class ForceDirectedGraph extends WeightedDirectedGraph {
|
||||||
constructor(name, parentEl, options = {}) {
|
constructor(name, parentEl, { width = 800, height = 600, ...options } = {}) {
|
||||||
super(name, options);
|
super(name, options);
|
||||||
this.box = new Box(name, parentEl, options);
|
this.box = new Box(name, parentEl, options);
|
||||||
this.box.addClass('fixed');
|
this.box.addClass('fixed');
|
||||||
this.box.addClass('force-directed-graph');
|
this.box.addClass('force-directed-graph');
|
||||||
this.intervalTask = null;
|
this.intervalTask = null;
|
||||||
this.canvas = window.document.createElement('canvas');
|
this.canvas = window.document.createElement('canvas');
|
||||||
this.box.el.style.width = `${options.width ?? 800}px`;
|
this.width = width;
|
||||||
this.box.el.style.height = `${options.height ?? 600}px`;
|
this.height = height;
|
||||||
|
this.box.el.style.width = `${width}px`;
|
||||||
|
this.box.el.style.height = `${height}px`;
|
||||||
this.box.el.appendChild(this.canvas);
|
this.box.el.appendChild(this.canvas);
|
||||||
this.fitCanvasToGraph();
|
this.fitCanvasToGraph();
|
||||||
this.nodes = [];
|
this.nodes = [];
|
||||||
|
@ -55,23 +55,65 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
|
||||||
}
|
}
|
||||||
|
|
||||||
fitCanvasToGraph() {
|
fitCanvasToGraph() {
|
||||||
[this.canvas.width, this.canvas.height] = this.box.getGeometry().dimensions;
|
[this.canvas.width, this.canvas.height] = this.box.rect.dimensions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addVertex(type, id, data, label, options) {
|
||||||
addVertex(...args) {
|
addVertex(...args) {
|
||||||
const vertex = super.addVertex(...args);
|
const vertex = super.addVertex(...args);
|
||||||
const box = this.box.addBox(vertex.id);
|
const box = this.box.addBox(vertex.id);
|
||||||
|
|
||||||
|
// Link from the graph vertex to the corresponding display box
|
||||||
|
vertex.box = box;
|
||||||
|
|
||||||
|
// Link from the display box to the corresponding graph vertex
|
||||||
|
box.vertex = vertex;
|
||||||
|
|
||||||
box.addClass('absolute');
|
box.addClass('absolute');
|
||||||
box.addClass('vertex');
|
box.addClass('vertex');
|
||||||
box.el.style.left = '0px';
|
box.el.style.left = '0px';
|
||||||
box.el.style.top = '0px';
|
box.el.style.top = '0px';
|
||||||
box.velocity = Vector.from([0, 0]);
|
box.velocity = Vector.from([0, 0]);
|
||||||
box.setInnerHTML(vertex.getHTML());
|
box.setInnerHTML(vertex.getHTML());
|
||||||
box.vertex = vertex;
|
|
||||||
this.nodes.push(box);
|
this.nodes.push(box);
|
||||||
|
|
||||||
|
// When vertex properties are updated, re-render the node contents
|
||||||
vertex.onUpdate = () => {
|
vertex.onUpdate = () => {
|
||||||
box.setInnerHTML(vertex.getHTML());
|
box.setInnerHTML(vertex.getHTML());
|
||||||
|
// Maybe resolve forces
|
||||||
|
this.runUntilEquilibrium();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.runUntilEquilibrium();
|
||||||
|
|
||||||
|
// Allow moving vertices with the mouse
|
||||||
|
box.el.addEventListener('mousedown', (e) => {
|
||||||
|
if (!this.mouseMoving) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Record current mouse position
|
||||||
|
this.mousePosition = Vector.from([e.clientX, e.clientY]);
|
||||||
|
// Begin tracking mouse movement
|
||||||
|
this.mouseMoving = box;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (this.mouseMoving === box) {
|
||||||
|
const mousePosition = Vector.from([e.clientX, e.clientY]);
|
||||||
|
// Apply translation
|
||||||
|
box.move(mousePosition.subtract(this.mousePosition));
|
||||||
|
// Update current mouse position
|
||||||
|
this.mousePosition = mousePosition;
|
||||||
|
// Equilibrate
|
||||||
|
this.runUntilEquilibrium();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
// Stop tracking mouse movement
|
||||||
|
this.mouseMoving = null;
|
||||||
|
// Equilibrate
|
||||||
|
this.runUntilEquilibrium();
|
||||||
|
});
|
||||||
|
|
||||||
return vertex;
|
return vertex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,15 +125,28 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
|
||||||
|
|
||||||
const edge = super.addEdge(type, from, to, ...rest);
|
const edge = super.addEdge(type, from, to, ...rest);
|
||||||
const box = this.box.addBox(Edge.getKey({ from, to, type }));
|
const box = this.box.addBox(Edge.getKey({ from, to, type }));
|
||||||
|
|
||||||
|
edge.box = box;
|
||||||
|
box.edge = edge;
|
||||||
|
|
||||||
box.addClass('absolute');
|
box.addClass('absolute');
|
||||||
box.addClass('edge');
|
box.addClass('edge');
|
||||||
// TODO: Center between nodes
|
|
||||||
box.el.style.left = '0px';
|
|
||||||
box.el.style.top = '0px';
|
|
||||||
box.velocity = Vector.from([0, 0]);
|
|
||||||
box.setInnerHTML(edge.getHTML());
|
box.setInnerHTML(edge.getHTML());
|
||||||
box.edge = edge;
|
|
||||||
this.edges.push(box);
|
this.edges.push(box);
|
||||||
|
|
||||||
|
// Center between nodes, by applying an attraction force from the edge node
|
||||||
|
// to its `from` and `to` nodes.
|
||||||
|
// We can also initially place it near the midpoint between the `from` and `to` nodes.
|
||||||
|
const midpoint = fromBox.rect.center.add(toBox.rect.center).scale(0.5);
|
||||||
|
const startPosition = midpoint.subtract(box.rect.dimensions.scale(0.5));
|
||||||
|
console.log({
|
||||||
|
fromBox, toBox, midpoint, startPosition, dimensions: box.rect.dimensions,
|
||||||
|
});
|
||||||
|
box.move(startPosition);
|
||||||
|
box.velocity = Vector.from([0, 0]);
|
||||||
|
this.runUntilEquilibrium();
|
||||||
|
|
||||||
return edge;
|
return edge;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,35 +156,22 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
|
||||||
return edge;
|
return edge;
|
||||||
}
|
}
|
||||||
|
|
||||||
static pairwiseForce(boxA, boxB, targetRadius) {
|
|
||||||
const rectA = boxA instanceof Rectangle ? boxA : boxA.getGeometry();
|
|
||||||
const centerA = rectA.center;
|
|
||||||
const rectB = boxB instanceof Rectangle ? boxB : boxB.getGeometry();
|
|
||||||
const centerB = rectB.center;
|
|
||||||
const r = centerB.subtract(centerA);
|
|
||||||
|
|
||||||
// Apply a stronger force when overlap occurs
|
|
||||||
if (rectA.doesOverlap(rectB)) {
|
|
||||||
// if their centers actually coincide we can just randomize the direction.
|
|
||||||
if (r.magnitudeSquared === 0) {
|
|
||||||
return Vector.randomUnitVector(rectA.dim).scale(DEFAULT_OVERLAP_FORCE);
|
|
||||||
}
|
|
||||||
return r.normalize().scale(DEFAULT_OVERLAP_FORCE);
|
|
||||||
}
|
|
||||||
// repel if closer than targetRadius
|
|
||||||
// attract if farther than targetRadius
|
|
||||||
const force = -DISTANCE_FACTOR * (r.magnitude - targetRadius);
|
|
||||||
return r.normalize().scale(force);
|
|
||||||
}
|
|
||||||
|
|
||||||
async runUntilEquilibrium(tDelta = DEFAULT_TIME_STEP) {
|
async runUntilEquilibrium(tDelta = DEFAULT_TIME_STEP) {
|
||||||
|
this.steps = 0;
|
||||||
if (this.intervalTask) {
|
if (this.intervalTask) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.intervalTask = setInterval(() => {
|
this.intervalTask = setInterval(() => {
|
||||||
|
this.steps++;
|
||||||
|
if (this.steps > MAX_STEPS_TO_EQUILIBRIUM) {
|
||||||
|
clearInterval(this.intervalTask);
|
||||||
|
this.intervalTask = null;
|
||||||
|
reject(new Error('Exceeded map steps to reach equilibrium'));
|
||||||
|
}
|
||||||
const { atEquilibrium } = this.computeEulerFrame(tDelta);
|
const { atEquilibrium } = this.computeEulerFrame(tDelta);
|
||||||
if (atEquilibrium) {
|
if (atEquilibrium) {
|
||||||
|
console.log(`Reached equilibrium after ${this.steps} steps`);
|
||||||
clearInterval(this.intervalTask);
|
clearInterval(this.intervalTask);
|
||||||
this.intervalTask = null;
|
this.intervalTask = null;
|
||||||
resolve();
|
resolve();
|
||||||
|
@ -139,53 +181,99 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
|
||||||
}
|
}
|
||||||
|
|
||||||
computeEulerFrame(tDelta = DEFAULT_TIME_STEP) {
|
computeEulerFrame(tDelta = DEFAULT_TIME_STEP) {
|
||||||
// Compute all net forces
|
// Compute net forces on each box in the graph
|
||||||
const netForces = Array.from(Array(this.nodes.length), () => Vector.from([0, 0]));
|
const boxes = [...this.nodes, ...this.edges];
|
||||||
let atEquilibrium = true;
|
|
||||||
|
// Initialize net force vectors
|
||||||
|
for (const box of boxes) {
|
||||||
|
box.netForce = Vector.zeros(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute overlap repulsion forces among node boxes
|
||||||
for (const boxA of this.nodes) {
|
for (const boxA of this.nodes) {
|
||||||
const idxA = this.nodes.indexOf(boxA);
|
const idxA = boxes.indexOf(boxA);
|
||||||
for (const boxB of this.nodes.slice(idxA + 1)) {
|
for (const boxB of this.nodes.slice(idxA + 1)) {
|
||||||
const idxB = this.nodes.indexOf(boxB);
|
const force = overlapRepulsionForce(boxA, boxB);
|
||||||
const force = ForceDirectedGraph.pairwiseForce(boxA, boxB, DEFAULT_TARGET_RADIUS);
|
boxA.netForce = boxA.netForce.subtract(force);
|
||||||
// Ignore forces below a certain threshold
|
boxB.netForce = boxB.netForce.add(force);
|
||||||
if (force.magnitude >= MINIMUM_FORCE) {
|
|
||||||
netForces[idxA] = netForces[idxA].subtract(force);
|
|
||||||
netForces[idxB] = netForces[idxB].add(force);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute motions
|
// Compute pairwise forces among nodes
|
||||||
for (const box of this.nodes) {
|
for (const boxA of this.nodes) {
|
||||||
const idx = this.nodes.indexOf(box);
|
const idxA = this.nodes.indexOf(boxA);
|
||||||
box.velocity = box.velocity.add(netForces[idx].scale(tDelta));
|
for (const boxB of this.nodes.slice(idxA + 1)) {
|
||||||
|
const force = targetRadiusForce(boxA, boxB);
|
||||||
|
boxA.netForce = boxA.netForce.subtract(force);
|
||||||
|
boxB.netForce = boxB.netForce.add(force);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute forces on edge boxes:
|
||||||
|
// Attraction to the `from` and `to` nodes
|
||||||
|
for (const edgeBox of this.edges) {
|
||||||
|
const { edge } = edgeBox;
|
||||||
|
const fromBox = edge.from.box;
|
||||||
|
const toBox = edge.to.box;
|
||||||
|
edgeBox.netForce = edgeBox.netForce
|
||||||
|
.subtract(targetRadiusForce(edgeBox, fromBox, 0))
|
||||||
|
.subtract(targetRadiusForce(edgeBox, toBox, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not apply forces to a box if it is being moved by the mouse
|
||||||
|
for (const box of boxes) {
|
||||||
|
if (this.mouseMoving === box) {
|
||||||
|
box.netForce = Vector.zeros(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute velocities
|
||||||
|
for (const box of boxes) {
|
||||||
|
box.velocity = box.velocity.add(box.netForce.scale(tDelta));
|
||||||
// Apply some drag
|
// Apply some drag
|
||||||
box.velocity = box.velocity.scale(1 - VISCOSITY_FACTOR);
|
box.velocity = box.velocity.scale(1 - VISCOSITY_FACTOR);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const box of this.nodes) {
|
// When all velocities are below MINIMUM_VELOCITY, we have reached equilibrium.
|
||||||
|
let atEquilibrium = true;
|
||||||
|
|
||||||
|
// Apply velocities
|
||||||
|
for (const box of boxes) {
|
||||||
if (box.velocity.magnitude >= MINIMUM_VELOCITY) {
|
if (box.velocity.magnitude >= MINIMUM_VELOCITY) {
|
||||||
atEquilibrium = false;
|
atEquilibrium = false;
|
||||||
box.move(box.velocity);
|
box.move(box.velocity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translate everything to keep coordinates positive
|
// Center the items by computing the bounding box and centering that
|
||||||
// TODO: Consider centering and scaling to viewport size
|
if (!this.mouseMoving) {
|
||||||
const topLeft = this.box.getGeometry().startPoint;
|
const topLeft = Vector.from(boxes[0].position);
|
||||||
const translate = Vector.zeros(2);
|
const bottomRight = Vector.from(boxes[0].position);
|
||||||
for (const box of this.nodes) {
|
for (const box of boxes) {
|
||||||
const rect = box.getGeometry();
|
for (const vertex of box.rect.vertices) {
|
||||||
for (const vertex of rect.vertices) {
|
topLeft[0] = Math.min(topLeft[0], vertex[0]);
|
||||||
translate[0] = Math.max(translate[0], topLeft[0] - vertex[0]);
|
topLeft[1] = Math.min(topLeft[1], vertex[1]);
|
||||||
translate[1] = Math.max(translate[1], topLeft[1] - vertex[1]);
|
|
||||||
|
bottomRight[0] = Math.max(bottomRight[0], vertex[0]);
|
||||||
|
bottomRight[1] = Math.max(bottomRight[1], vertex[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const boundingBox = new Rectangle(topLeft, bottomRight.subtract(topLeft));
|
||||||
|
const graphCenter = Vector.from([this.width, this.height]).scale(0.5);
|
||||||
|
const offset = graphCenter.subtract(boundingBox.center);
|
||||||
|
const translate = offset.scale(TRANSLATION_VELOCITY_FACTOR);
|
||||||
|
|
||||||
|
if (translate.magnitude >= MINIMUM_VELOCITY) {
|
||||||
|
atEquilibrium = false;
|
||||||
|
|
||||||
|
// Apply translations
|
||||||
|
for (const box of boxes) {
|
||||||
|
box.move(translate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const box of this.nodes) {
|
|
||||||
box.move(translate);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.fitCanvasToGraph();
|
// TODO: Scaling to fit
|
||||||
|
|
||||||
return { atEquilibrium };
|
return { atEquilibrium };
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,100 +0,0 @@
|
||||||
export class Vector extends Array {
|
|
||||||
get dim() {
|
|
||||||
return this.length ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
add(vector) {
|
|
||||||
if (vector.dim !== this.dim) {
|
|
||||||
throw new Error('Can only add vectors of the same dimensions');
|
|
||||||
}
|
|
||||||
return Vector.from(this.map((q, idx) => q + vector[idx]));
|
|
||||||
}
|
|
||||||
|
|
||||||
subtract(vector) {
|
|
||||||
if (vector.dim !== this.dim) {
|
|
||||||
throw new Error('Can only subtract vectors of the same dimensions');
|
|
||||||
}
|
|
||||||
return Vector.from(this.map((q, idx) => q - vector[idx]));
|
|
||||||
}
|
|
||||||
|
|
||||||
static unitVector(dim, totalDim) {
|
|
||||||
return Vector.from(Array(totalDim), (_, idx) => (idx === dim ? 1 : 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
get magnitudeSquared() {
|
|
||||||
return this.reduce((total, q) => total += q ** 2, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get magnitude() {
|
|
||||||
return Math.sqrt(this.magnitudeSquared);
|
|
||||||
}
|
|
||||||
|
|
||||||
scale(factor) {
|
|
||||||
return Vector.from(this.map((q) => q * factor));
|
|
||||||
}
|
|
||||||
|
|
||||||
normalize() {
|
|
||||||
return this.scale(1 / this.magnitude);
|
|
||||||
}
|
|
||||||
|
|
||||||
static randomUnitVector(totalDim) {
|
|
||||||
return Vector.from(Array(totalDim), () => Math.random()).normalize();
|
|
||||||
}
|
|
||||||
|
|
||||||
static zeros(totalDim) {
|
|
||||||
return Vector.from(Array(totalDim), () => 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Polygon {
|
|
||||||
constructor() {
|
|
||||||
this.vertices = [];
|
|
||||||
this.dim = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
addVertex(point) {
|
|
||||||
point = point instanceof Vector ? point : Vector.from(point);
|
|
||||||
if (!this.dim) {
|
|
||||||
this.dim = point.dim;
|
|
||||||
} else if (this.dim !== point.dim) {
|
|
||||||
throw new Error('All vertices of a polygon must have the same dimensionality');
|
|
||||||
}
|
|
||||||
this.vertices.push(point);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Rectangle extends Polygon {
|
|
||||||
constructor(startPoint, dimensions) {
|
|
||||||
super();
|
|
||||||
this.startPoint = Vector.from(startPoint);
|
|
||||||
this.dimensions = Vector.from(dimensions);
|
|
||||||
// Next point is obtained by moving the specified length along each dimension
|
|
||||||
// one at a time, then reversing these movements in the same order.
|
|
||||||
let point = this.startPoint;
|
|
||||||
for (let dim = dimensions.length - 1; dim >= 0; dim--) {
|
|
||||||
this.addVertex(point);
|
|
||||||
const increment = Vector.unitVector(dim, dimensions.length);
|
|
||||||
point = point.add(increment);
|
|
||||||
}
|
|
||||||
for (let dim = dimensions.length - 1; dim >= 0; dim--) {
|
|
||||||
this.addVertex(point);
|
|
||||||
const increment = Vector.unitVector(dim, dimensions.length);
|
|
||||||
point = point.subtract(increment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get center() {
|
|
||||||
return Vector.from(this.dimensions.map((Q, idx) => this.startPoint[idx] + Q / 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
doesOverlap(rect) {
|
|
||||||
return this.dimensions.every((_, idx) => {
|
|
||||||
const thisMin = this.startPoint[idx];
|
|
||||||
const thisMax = this.startPoint[idx] + this.dimensions[idx];
|
|
||||||
const thatMin = rect.startPoint[idx];
|
|
||||||
const thatMax = rect.startPoint[idx] + rect.dimensions[idx];
|
|
||||||
return (thisMin <= thatMin && thisMax >= thatMin)
|
|
||||||
|| (thisMin >= thatMin && thisMin <= thatMax);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
import {
|
||||||
|
DEFAULT_OVERLAP_FORCE,
|
||||||
|
DEFAULT_TARGET_RADIUS,
|
||||||
|
DISTANCE_FACTOR,
|
||||||
|
} from '../../util/constants.js';
|
||||||
|
import { Rectangle, Vector } from '../supporting/geometry/index.js';
|
||||||
|
|
||||||
|
const getRectangles = (boxes) => boxes.map((box) => (box instanceof Rectangle ? box : box.rect));
|
||||||
|
const getCenters = (boxes) => getRectangles(boxes).map((rect) => rect.center);
|
||||||
|
|
||||||
|
export const overlapRepulsionForce = (boxA, boxB, force = DEFAULT_OVERLAP_FORCE) => {
|
||||||
|
const [rectA, rectB] = getRectangles([boxA, boxB]);
|
||||||
|
const [centerA, centerB] = getCenters([rectA, rectB]);
|
||||||
|
const r = centerB.subtract(centerA);
|
||||||
|
|
||||||
|
// Apply a stronger force when overlap occurs
|
||||||
|
if (!rectA.doesOverlap(rectB)) {
|
||||||
|
return Vector.zeros(rectA.dim);
|
||||||
|
}
|
||||||
|
// If their centers actually coincide we can just randomize the direction.
|
||||||
|
if (r.magnitudeSquared === 0) {
|
||||||
|
return Vector.randomUnitVector(rectA.dim).scale(force);
|
||||||
|
}
|
||||||
|
return r.normalize().scale(force);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const targetRadiusForce = (boxA, boxB, targetRadius = DEFAULT_TARGET_RADIUS) => {
|
||||||
|
const [centerA, centerB] = getCenters([boxA, boxB]);
|
||||||
|
const r = centerB.subtract(centerA);
|
||||||
|
|
||||||
|
// Repel if closer than targetRadius
|
||||||
|
// Attract if farther than targetRadius
|
||||||
|
const force = -DISTANCE_FACTOR * (r.magnitude - targetRadius);
|
||||||
|
return r.normalize().scale(force);
|
||||||
|
};
|
|
@ -46,7 +46,7 @@ export class Edge {
|
||||||
}
|
}
|
||||||
html += '</table>';
|
html += '</table>';
|
||||||
|
|
||||||
return `${Edge.getCombinedKey(this)}("${html}")`;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
displayEdgeNode() {
|
displayEdgeNode() {
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './vector.js';
|
||||||
|
export * from './polygon.js';
|
||||||
|
export * from './rectangle.js';
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Vector } from './vector.js';
|
||||||
|
|
||||||
|
export class Polygon {
|
||||||
|
constructor() {
|
||||||
|
this.vertices = [];
|
||||||
|
this.dim = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
addVertex(point) {
|
||||||
|
point = point instanceof Vector ? point : Vector.from(point);
|
||||||
|
if (!this.dim) {
|
||||||
|
this.dim = point.dim;
|
||||||
|
} else if (this.dim !== point.dim) {
|
||||||
|
throw new Error('All vertices of a polygon must have the same dimensionality');
|
||||||
|
}
|
||||||
|
this.vertices.push(point);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Polygon } from './polygon.js';
|
||||||
|
import { Vector } from './vector.js';
|
||||||
|
|
||||||
|
export class Rectangle extends Polygon {
|
||||||
|
constructor(position, dimensions) {
|
||||||
|
super();
|
||||||
|
if (this.vertices.length) {
|
||||||
|
throw new Error('Reinitializing geometry is not allowed');
|
||||||
|
}
|
||||||
|
this.position = Vector.from(position);
|
||||||
|
this.dimensions = Vector.from(dimensions);
|
||||||
|
// Next point is obtained by moving the specified length along each dimension
|
||||||
|
// one at a time, then reversing these movements in the same order.
|
||||||
|
let point = this.position;
|
||||||
|
for (let dim = dimensions.length - 1; dim >= 0; dim--) {
|
||||||
|
this.addVertex(point);
|
||||||
|
const increment = Vector.unitVector(dim, dimensions.length);
|
||||||
|
point = point.add(increment);
|
||||||
|
}
|
||||||
|
for (let dim = dimensions.length - 1; dim >= 0; dim--) {
|
||||||
|
this.addVertex(point);
|
||||||
|
const increment = Vector.unitVector(dim, dimensions.length);
|
||||||
|
point = point.subtract(increment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get center() {
|
||||||
|
return Vector.from(this.dimensions.map((Q, idx) => this.position[idx] + Q / 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
doesOverlap(rect) {
|
||||||
|
return this.dimensions.every((_, idx) => {
|
||||||
|
const thisMin = this.position[idx];
|
||||||
|
const thisMax = this.position[idx] + this.dimensions[idx];
|
||||||
|
const thatMin = rect.position[idx];
|
||||||
|
const thatMax = rect.position[idx] + rect.dimensions[idx];
|
||||||
|
return (thisMin <= thatMin && thisMax >= thatMin)
|
||||||
|
|| (thisMin >= thatMin && thisMin <= thatMax);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get aspectRatio() {
|
||||||
|
const [width, height] = this.dimensions;
|
||||||
|
return height / width;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
export class Vector extends Array {
|
||||||
|
get dim() {
|
||||||
|
return this.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
add(vector) {
|
||||||
|
if (vector.dim !== this.dim) {
|
||||||
|
throw new Error('Can only add vectors of the same dimensions');
|
||||||
|
}
|
||||||
|
return Vector.from(this.map((q, idx) => q + vector[idx]));
|
||||||
|
}
|
||||||
|
|
||||||
|
subtract(vector) {
|
||||||
|
if (vector.dim !== this.dim) {
|
||||||
|
throw new Error('Can only subtract vectors of the same dimensions');
|
||||||
|
}
|
||||||
|
return Vector.from(this.map((q, idx) => q - vector[idx]));
|
||||||
|
}
|
||||||
|
|
||||||
|
static unitVector(dim, totalDim) {
|
||||||
|
return Vector.from(Array(totalDim), (_, idx) => (idx === dim ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
get magnitudeSquared() {
|
||||||
|
return this.reduce((total, q) => total += q ** 2, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get magnitude() {
|
||||||
|
return Math.sqrt(this.magnitudeSquared);
|
||||||
|
}
|
||||||
|
|
||||||
|
scale(factor) {
|
||||||
|
return Vector.from(this.map((q) => q * factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize() {
|
||||||
|
return this.scale(1 / this.magnitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
static randomUnitVector(totalDim) {
|
||||||
|
return Vector.from(Array(totalDim), () => Math.random()).normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
static zeros(totalDim) {
|
||||||
|
return Vector.from(Array(totalDim), () => 0);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import { Box } from '../../classes/display/box.js';
|
import { Box } from '../../classes/display/box.js';
|
||||||
import { ForceDirectedGraph } from '../../classes/display/force-directed.js';
|
import { ForceDirectedGraph } from '../../classes/display/force-directed.js';
|
||||||
import { Rectangle, Vector } from '../../classes/display/geometry.js';
|
import { Rectangle, Vector } from '../../classes/supporting/geometry/index.js';
|
||||||
|
import { overlapRepulsionForce, targetRadiusForce } from '../../classes/display/pairwise-forces.js';
|
||||||
import { delayOrWait } from '../../classes/display/scene-controls.js';
|
import { delayOrWait } from '../../classes/display/scene-controls.js';
|
||||||
import { Scene } from '../../classes/display/scene.js';
|
import { Scene } from '../../classes/display/scene.js';
|
||||||
import { EPSILON } from '../../util/constants.js';
|
import { EPSILON } from '../../util/constants.js';
|
||||||
|
@ -31,13 +32,13 @@ describe('Force-Directed Graph', function tests() {
|
||||||
rect.vertices[3].should.eql([1, 0]);
|
rect.vertices[3].should.eql([1, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('overlapping boxes should repel with default force', () => {
|
it('overlapping boxes should repel', () => {
|
||||||
const rect1 = new Rectangle([0, 0], [1, 1]);
|
const rect1 = new Rectangle([0, 0], [1, 1]);
|
||||||
const rect2 = new Rectangle([0, 0], [1, 2]);
|
const rect2 = new Rectangle([0, 0], [1, 2]);
|
||||||
rect1.center.should.eql([0.5, 0.5]);
|
rect1.center.should.eql([0.5, 0.5]);
|
||||||
rect2.center.should.eql([0.5, 1]);
|
rect2.center.should.eql([0.5, 1]);
|
||||||
const force1 = ForceDirectedGraph.pairwiseForce(rect1, rect2, 10);
|
const force1 = overlapRepulsionForce(rect1, rect2, 10);
|
||||||
force1.should.eql([0, 200]);
|
force1.should.eql([0, 10]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('boxes at target radius should have no net force', () => {
|
it('boxes at target radius should have no net force', () => {
|
||||||
|
@ -45,7 +46,7 @@ describe('Force-Directed Graph', function tests() {
|
||||||
const rect2 = new Rectangle([10, 0], [1, 1]);
|
const rect2 = new Rectangle([10, 0], [1, 1]);
|
||||||
rect1.center.should.eql([0.5, 0.5]);
|
rect1.center.should.eql([0.5, 0.5]);
|
||||||
rect2.center.should.eql([10.5, 0.5]);
|
rect2.center.should.eql([10.5, 0.5]);
|
||||||
const force = ForceDirectedGraph.pairwiseForce(rect1, rect2, 10);
|
const force = targetRadiusForce(rect1, rect2, 10);
|
||||||
force[0].should.be.within(-EPSILON, EPSILON);
|
force[0].should.be.within(-EPSILON, EPSILON);
|
||||||
force[1].should.be.within(-EPSILON, EPSILON);
|
force[1].should.be.within(-EPSILON, EPSILON);
|
||||||
});
|
});
|
||||||
|
@ -70,12 +71,14 @@ describe('Force-Directed Graph', function tests() {
|
||||||
await delayOrWait(1000);
|
await delayOrWait(1000);
|
||||||
const v = graph.addVertex('v1', 'box2');
|
const v = graph.addVertex('v1', 'box2');
|
||||||
v.setProperty('prop', 'value');
|
v.setProperty('prop', 'value');
|
||||||
await graph.runUntilEquilibrium();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can add an edge to the graph', async () => {
|
it('can add an edge to the graph', async () => {
|
||||||
await delayOrWait(1000);
|
await delayOrWait(1000);
|
||||||
graph.addEdge('e1', 'box1', 'box2', 1);
|
graph.addEdge('e1', 'box1', 'box2', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs until reaching equilibrium', async () => {
|
||||||
await graph.runUntilEquilibrium();
|
await graph.runUntilEquilibrium();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,10 +11,12 @@ export const DEFAULT_OVERLAP_FORCE = 200;
|
||||||
export const DEFAULT_REP_TOKEN_TYPE_ID = 0;
|
export const DEFAULT_REP_TOKEN_TYPE_ID = 0;
|
||||||
export const DEFAULT_TARGET_RADIUS = 300;
|
export const DEFAULT_TARGET_RADIUS = 300;
|
||||||
export const DEFAULT_TIME_STEP = 0.1;
|
export const DEFAULT_TIME_STEP = 0.1;
|
||||||
export const DISTANCE_FACTOR = 0.25;
|
export const DISTANCE_FACTOR = 0.5;
|
||||||
export const EPSILON = 2.23e-16;
|
export const EPSILON = 2.23e-16;
|
||||||
export const INCINERATOR_ADDRESS = '0';
|
export const INCINERATOR_ADDRESS = '0';
|
||||||
|
export const MAX_STEPS_TO_EQUILIBRIUM = 100;
|
||||||
export const MINIMUM_FORCE = 1;
|
export const MINIMUM_FORCE = 1;
|
||||||
export const MINIMUM_VELOCITY = 0.1;
|
export const MINIMUM_VELOCITY = 0.1;
|
||||||
export const TIME_DILATION_FACTOR = 500;
|
export const TIME_DILATION_FACTOR = 500;
|
||||||
|
export const TRANSLATION_VELOCITY_FACTOR = 0.2;
|
||||||
export const VISCOSITY_FACTOR = 0.4;
|
export const VISCOSITY_FACTOR = 0.4;
|
||||||
|
|
Loading…
Reference in New Issue