Improvements to graph rendering
This commit is contained in:
parent
4026d7eaa8
commit
7388a15cff
|
@ -69,6 +69,7 @@ export class Box {
|
||||||
}
|
}
|
||||||
|
|
||||||
move(vector) {
|
move(vector) {
|
||||||
|
vector = vector instanceof Vector ? vector : Vector.from(vector);
|
||||||
this.position = this.position.add(vector);
|
this.position = this.position.add(vector);
|
||||||
this.el.style.left = `${Math.floor(this.position[0])}px`;
|
this.el.style.left = `${Math.floor(this.position[0])}px`;
|
||||||
this.el.style.top = `${Math.floor(this.position[1])}px`;
|
this.el.style.top = `${Math.floor(this.position[1])}px`;
|
||||||
|
|
|
@ -3,16 +3,19 @@ import {
|
||||||
MINIMUM_VELOCITY,
|
MINIMUM_VELOCITY,
|
||||||
VISCOSITY_FACTOR,
|
VISCOSITY_FACTOR,
|
||||||
TIME_DILATION_FACTOR,
|
TIME_DILATION_FACTOR,
|
||||||
MAX_STEPS_TO_EQUILIBRIUM,
|
MAXIMUM_STEPS,
|
||||||
TRANSLATION_VELOCITY_FACTOR,
|
TRANSLATION_VELOCITY_FACTOR,
|
||||||
ARROWHEAD_LENGTH,
|
ARROWHEAD_LENGTH,
|
||||||
ARROWHEAD_WIDTH,
|
ARROWHEAD_WIDTH,
|
||||||
|
MINIMUM_STEPS,
|
||||||
|
CENTRAL_RESTORING_FORCE,
|
||||||
} 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 { Vector, Rectangle } from '../supporting/geometry/index.js';
|
import { Vector, Rectangle } from '../supporting/geometry/index.js';
|
||||||
import { overlapRepulsionForce, targetRadiusForce } from './pairwise-forces.js';
|
import { overlapRepulsionForce, targetRadiusForce } from './pairwise-forces.js';
|
||||||
|
import { Vertex } from '../supporting/vertex.js';
|
||||||
|
|
||||||
// Render children with absolute css positioning.
|
// Render children with absolute css positioning.
|
||||||
|
|
||||||
|
@ -60,7 +63,6 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
|
||||||
[this.canvas.width, this.canvas.height] = this.box.rect.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);
|
||||||
|
@ -89,6 +91,7 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
|
||||||
|
|
||||||
// Allow moving vertices with the mouse
|
// Allow moving vertices with the mouse
|
||||||
box.el.addEventListener('mousedown', (e) => {
|
box.el.addEventListener('mousedown', (e) => {
|
||||||
|
console.log('mousedown, button:', e.button);
|
||||||
if (!this.mouseMoving) {
|
if (!this.mouseMoving) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Record current mouse position
|
// Record current mouse position
|
||||||
|
@ -119,10 +122,10 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
|
||||||
}
|
}
|
||||||
|
|
||||||
addEdge(type, from, to, ...rest) {
|
addEdge(type, from, to, ...rest) {
|
||||||
const fromBox = this.nodes.find(({ name }) => name === from);
|
from = from instanceof Vertex ? from : this.getVertex(from);
|
||||||
const toBox = this.nodes.find(({ name }) => name === to);
|
to = to instanceof Vertex ? to : this.getVertex(to);
|
||||||
if (!fromBox) throw new Error(`from ${from}: Node not found`);
|
if (!from) throw new Error(`from ${from}: Node not found`);
|
||||||
if (!toBox) throw new Error(`to ${to}: Node not found`);
|
if (!to) throw new Error(`to ${to}: Node not found`);
|
||||||
|
|
||||||
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 }));
|
||||||
|
@ -136,7 +139,7 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
|
||||||
this.edges.push(box);
|
this.edges.push(box);
|
||||||
|
|
||||||
// Initially place near the midpoint between the `from` and `to` nodes.
|
// Initially place near the midpoint between the `from` and `to` nodes.
|
||||||
const midpoint = fromBox.rect.center.add(toBox.rect.center).scale(0.5);
|
const midpoint = from.box.rect.center.add(to.box.rect.center).scale(0.5);
|
||||||
const startPosition = midpoint.subtract(box.rect.dimensions.scale(0.5));
|
const startPosition = midpoint.subtract(box.rect.dimensions.scale(0.5));
|
||||||
box.move(startPosition);
|
box.move(startPosition);
|
||||||
box.velocity = Vector.from([0, 0]);
|
box.velocity = Vector.from([0, 0]);
|
||||||
|
@ -163,6 +166,7 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
const direction = edge.to.box.rect.center.subtract(edge.box.rect.center);
|
const direction = edge.to.box.rect.center.subtract(edge.box.rect.center);
|
||||||
|
const arrowTail = edge.box.rect.lineIntersect(edge.to.box.rect.center, direction.scale(-1));
|
||||||
const arrowPoint = edge.to.box.rect.lineIntersect(edge.box.rect.center, direction);
|
const arrowPoint = edge.to.box.rect.lineIntersect(edge.box.rect.center, direction);
|
||||||
const arrowBaseCenter = arrowPoint.subtract(direction.normalize().scale(ARROWHEAD_LENGTH));
|
const arrowBaseCenter = arrowPoint.subtract(direction.normalize().scale(ARROWHEAD_LENGTH));
|
||||||
const arrowBaseDirection = Vector.from([direction[1], -direction[0]]).normalize();
|
const arrowBaseDirection = Vector.from([direction[1], -direction[0]]).normalize();
|
||||||
|
@ -170,7 +174,7 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
|
||||||
const arrowBaseRight = arrowBaseCenter.subtract(arrowBaseDirection.scale(ARROWHEAD_WIDTH));
|
const arrowBaseRight = arrowBaseCenter.subtract(arrowBaseDirection.scale(ARROWHEAD_WIDTH));
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(...edge.box.rect.center);
|
ctx.moveTo(...arrowTail);
|
||||||
ctx.lineTo(...arrowPoint);
|
ctx.lineTo(...arrowPoint);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
@ -190,13 +194,16 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
|
||||||
}
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.intervalTask = setInterval(() => {
|
this.intervalTask = setInterval(() => {
|
||||||
|
if (this.intervalTaskExecuting) return;
|
||||||
this.steps++;
|
this.steps++;
|
||||||
if (this.steps > MAX_STEPS_TO_EQUILIBRIUM) {
|
if (this.steps > MAXIMUM_STEPS) {
|
||||||
clearInterval(this.intervalTask);
|
clearInterval(this.intervalTask);
|
||||||
this.intervalTask = null;
|
this.intervalTask = null;
|
||||||
reject(new Error('Exceeded map steps to reach equilibrium'));
|
reject(new Error('Exceeded max steps to reach equilibrium'));
|
||||||
}
|
}
|
||||||
|
this.intervalTaskExecuting = true;
|
||||||
const { atEquilibrium } = this.computeEulerFrame(tDelta);
|
const { atEquilibrium } = this.computeEulerFrame(tDelta);
|
||||||
|
this.intervalTaskExecuting = false;
|
||||||
if (atEquilibrium) {
|
if (atEquilibrium) {
|
||||||
console.log(`Reached equilibrium after ${this.steps} steps`);
|
console.log(`Reached equilibrium after ${this.steps} steps`);
|
||||||
clearInterval(this.intervalTask);
|
clearInterval(this.intervalTask);
|
||||||
|
@ -213,44 +220,81 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
|
||||||
|
|
||||||
// Initialize net force vectors
|
// Initialize net force vectors
|
||||||
for (const box of boxes) {
|
for (const box of boxes) {
|
||||||
box.netForce = Vector.zeros(2);
|
box.forces = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute overlap repulsion forces among node boxes
|
const addForce = (box, force, type) => box.forces.push({ force, type });
|
||||||
for (const boxA of this.nodes) {
|
|
||||||
|
// All boxes repel each other if they overlap
|
||||||
|
for (const boxA of boxes) {
|
||||||
const idxA = boxes.indexOf(boxA);
|
const idxA = boxes.indexOf(boxA);
|
||||||
for (const boxB of this.nodes.slice(idxA + 1)) {
|
for (const boxB of boxes.slice(idxA + 1)) {
|
||||||
const force = overlapRepulsionForce(boxA, boxB);
|
const force = overlapRepulsionForce(boxA, boxB);
|
||||||
boxA.netForce = boxA.netForce.subtract(force);
|
addForce(
|
||||||
boxB.netForce = boxB.netForce.add(force);
|
boxA,
|
||||||
|
force.scale(-1),
|
||||||
|
`${boxB.name} -- overlapRepulsion --> ${boxA.name}`,
|
||||||
|
);
|
||||||
|
addForce(
|
||||||
|
boxB,
|
||||||
|
force,
|
||||||
|
`${boxA.name} -- overlapRepulsion --> ${boxB.name}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute pairwise forces among nodes
|
// Center of graph attracts all boxes that are outside the graph
|
||||||
for (const boxA of this.nodes) {
|
for (const box of boxes) {
|
||||||
const idxA = this.nodes.indexOf(boxA);
|
if (!this.box.rect.doesContain(box.rect.center)) {
|
||||||
for (const boxB of this.nodes.slice(idxA + 1)) {
|
const r = this.box.rect.center.subtract(box.rect.center);
|
||||||
const force = targetRadiusForce(boxA, boxB);
|
addForce(
|
||||||
boxA.netForce = boxA.netForce.subtract(force);
|
box,
|
||||||
boxB.netForce = boxB.netForce.add(force);
|
r.normalize().scale(CENTRAL_RESTORING_FORCE),
|
||||||
|
`center -- attraction --> ${box.name}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute forces on edge boxes:
|
// Compute edge-related forces
|
||||||
// Attraction to the `from` and `to` nodes
|
|
||||||
for (const edgeBox of this.edges) {
|
for (const edgeBox of this.edges) {
|
||||||
const { edge } = edgeBox;
|
const { edge } = edgeBox;
|
||||||
const fromBox = edge.from.box;
|
const fromBox = edge.from.box;
|
||||||
const toBox = edge.to.box;
|
const toBox = edge.to.box;
|
||||||
edgeBox.netForce = edgeBox.netForce
|
|
||||||
.subtract(targetRadiusForce(edgeBox, fromBox, 0))
|
// Attraction to the `from` and `to` nodes
|
||||||
.subtract(targetRadiusForce(edgeBox, toBox, 0));
|
addForce(
|
||||||
|
edgeBox,
|
||||||
|
targetRadiusForce(fromBox, edgeBox, 0),
|
||||||
|
`${edgeBox.name} -- attract fromBox --> ${fromBox.name}`,
|
||||||
|
);
|
||||||
|
addForce(
|
||||||
|
edgeBox,
|
||||||
|
targetRadiusForce(toBox, edgeBox, 0),
|
||||||
|
`${edgeBox.name} -- attract toBox --> ${toBox.name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pairwise force between nodes
|
||||||
|
{
|
||||||
|
const force = targetRadiusForce(fromBox, toBox);
|
||||||
|
addForce(
|
||||||
|
fromBox,
|
||||||
|
force.scale(-1),
|
||||||
|
`${toBox.name} -- pairwise targetRadius --> ${fromBox.name}`,
|
||||||
|
);
|
||||||
|
addForce(
|
||||||
|
toBox,
|
||||||
|
force,
|
||||||
|
`${fromBox.name} -- pairwise targetRadius --> ${toBox.name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not apply forces to a box if it is being moved by the mouse
|
// Do not apply forces to a box if it is being moved by the mouse
|
||||||
for (const box of boxes) {
|
for (const box of boxes) {
|
||||||
if (this.mouseMoving === box) {
|
if (this.mouseMoving === box) {
|
||||||
box.netForce = Vector.zeros(2);
|
box.netForce = Vector.zeros(2);
|
||||||
|
} else {
|
||||||
|
box.netForce = box.forces.reduce((net, { force }) => net.add(force), Vector.from([0, 0]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -261,8 +305,9 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
|
||||||
box.velocity = box.velocity.scale(1 - VISCOSITY_FACTOR);
|
box.velocity = box.velocity.scale(1 - VISCOSITY_FACTOR);
|
||||||
}
|
}
|
||||||
|
|
||||||
// When all velocities are below MINIMUM_VELOCITY, we have reached equilibrium.
|
// When all velocities are below MINIMUM_VELOCITY and we have executed more than
|
||||||
let atEquilibrium = true;
|
// MINIMUM_STEPS, we have reached equilibrium.
|
||||||
|
let atEquilibrium = this.steps > MINIMUM_STEPS;
|
||||||
|
|
||||||
// Apply velocities
|
// Apply velocities
|
||||||
for (const box of boxes) {
|
for (const box of boxes) {
|
||||||
|
|
|
@ -1,30 +1,55 @@
|
||||||
import {
|
import {
|
||||||
DEFAULT_OVERLAP_FORCE,
|
DEFAULT_OVERLAP_FORCE,
|
||||||
DEFAULT_TARGET_RADIUS,
|
DEFAULT_TARGET_RADIUS,
|
||||||
DISTANCE_FACTOR,
|
DEFAULT_DISTANCE_FACTOR,
|
||||||
|
DEFAULT_OVERLAP_BUFFER,
|
||||||
|
OVERLAP_THRESHOLD_RANDOMIZE,
|
||||||
} from '../../util/constants.js';
|
} from '../../util/constants.js';
|
||||||
import { Rectangle, Vector } from '../supporting/geometry/index.js';
|
import { Rectangle, Vector } from '../supporting/geometry/index.js';
|
||||||
|
|
||||||
const getRectangles = (boxes) => boxes.map((box) => (box instanceof Rectangle ? box : box.rect));
|
const getRectangles = (boxes) => boxes.map((box) => (box instanceof Rectangle ? box : box.rect));
|
||||||
const getCenters = (boxes) => getRectangles(boxes).map((rect) => rect.center);
|
const getCenters = (boxes) => getRectangles(boxes).map((rect) => rect.center);
|
||||||
|
|
||||||
export const overlapRepulsionForce = (boxA, boxB, force = DEFAULT_OVERLAP_FORCE) => {
|
export const overlapRepulsionForce = (
|
||||||
|
boxA,
|
||||||
|
boxB,
|
||||||
|
force = DEFAULT_OVERLAP_FORCE,
|
||||||
|
margin = DEFAULT_OVERLAP_BUFFER,
|
||||||
|
) => {
|
||||||
const [rectA, rectB] = getRectangles([boxA, boxB]);
|
const [rectA, rectB] = getRectangles([boxA, boxB]);
|
||||||
const [centerA, centerB] = getCenters([rectA, rectB]);
|
const [centerA, centerB] = getCenters([rectA, rectB]);
|
||||||
const r = centerB.subtract(centerA);
|
const r = centerB.subtract(centerA);
|
||||||
|
|
||||||
// Apply a stronger force when overlap occurs
|
// Apply a stronger force when overlap occurs
|
||||||
if (!rectA.doesOverlap(rectB)) {
|
const overlap = rectA.doesOverlap(rectB);
|
||||||
return Vector.zeros(rectA.dim);
|
if (overlap) {
|
||||||
|
// If there is sufficient overlap, randomize the direction of force.
|
||||||
|
// Note that we don't want to keep randomizing it once we've picked a direction
|
||||||
|
if (overlap <= OVERLAP_THRESHOLD_RANDOMIZE) {
|
||||||
|
if (!boxB.overlapForceDirection) {
|
||||||
|
boxB.overlapForceDirection = Vector.randomUnitVector(rectB.dim);
|
||||||
}
|
}
|
||||||
// If their centers actually coincide we can just randomize the direction.
|
return boxB.overlapForceDirection.scale(force);
|
||||||
if (r.magnitudeSquared === 0) {
|
|
||||||
return Vector.randomUnitVector(rectA.dim).scale(force);
|
|
||||||
}
|
}
|
||||||
return r.normalize().scale(force);
|
return r.normalize().scale(force);
|
||||||
|
}
|
||||||
|
boxB.overlapForceDirection = null;
|
||||||
|
|
||||||
|
// Apply a weaker force until distance > margin
|
||||||
|
const separation = rectA.separationFromRect(rectB);
|
||||||
|
if (separation < margin) {
|
||||||
|
return r.normalize().scale(force * ((margin - separation) / margin));
|
||||||
|
}
|
||||||
|
// Otherwise, zero force
|
||||||
|
return Vector.zeros(rectA.dim);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const targetRadiusForce = (boxA, boxB, targetRadius = DEFAULT_TARGET_RADIUS) => {
|
export const targetRadiusForce = (
|
||||||
|
boxA,
|
||||||
|
boxB,
|
||||||
|
targetRadius = DEFAULT_TARGET_RADIUS,
|
||||||
|
distanceFactor = DEFAULT_DISTANCE_FACTOR,
|
||||||
|
) => {
|
||||||
const [rectA, rectB] = getRectangles([boxA, boxB]);
|
const [rectA, rectB] = getRectangles([boxA, boxB]);
|
||||||
const [centerA, centerB] = getCenters([rectA, rectB]);
|
const [centerA, centerB] = getCenters([rectA, rectB]);
|
||||||
const r = centerB.subtract(centerA);
|
const r = centerB.subtract(centerA);
|
||||||
|
@ -35,6 +60,6 @@ export const targetRadiusForce = (boxA, boxB, targetRadius = DEFAULT_TARGET_RADI
|
||||||
|
|
||||||
// Repel if closer than targetRadius
|
// Repel if closer than targetRadius
|
||||||
// Attract if farther than targetRadius
|
// Attract if farther than targetRadius
|
||||||
const force = -DISTANCE_FACTOR * (distance - targetRadius);
|
const force = -distanceFactor * (distance - targetRadius);
|
||||||
return r.normalize().scale(force);
|
return r.normalize().scale(force);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { DEFAULT_OVERLAP_BUFFER } from '../../../util/constants.js';
|
||||||
import { Polygon } from './polygon.js';
|
import { Polygon } from './polygon.js';
|
||||||
import { Vector } from './vector.js';
|
import { Vector } from './vector.js';
|
||||||
|
|
||||||
|
@ -29,14 +30,29 @@ export class Rectangle extends Polygon {
|
||||||
}
|
}
|
||||||
|
|
||||||
doesOverlap(rect) {
|
doesOverlap(rect) {
|
||||||
return this.dimensions.every((_, dim) => {
|
const overlapFractions = this.dimensions.map((_, dim) => {
|
||||||
const thisMin = this.position[dim];
|
const thisMin = this.position[dim];
|
||||||
const thisMax = this.position[dim] + this.dimensions[dim];
|
const thisMax = this.position[dim] + this.dimensions[dim];
|
||||||
const thatMin = rect.position[dim];
|
const thatMin = rect.position[dim];
|
||||||
const thatMax = rect.position[dim] + rect.dimensions[dim];
|
const thatMax = rect.position[dim] + rect.dimensions[dim];
|
||||||
return (thisMin <= thatMin && thisMax >= thatMin)
|
if (thatMin <= thisMin && thatMax >= thisMin) {
|
||||||
|| (thisMin >= thatMin && thisMin <= thatMax);
|
if (thatMax >= thisMax) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return (thatMax - thisMin) / (thatMax - thatMin);
|
||||||
|
}
|
||||||
|
if (thatMin <= thisMax && thatMax >= thisMin) {
|
||||||
|
if (thatMax <= thisMax) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return (thisMax - thatMin) / (thatMax - thatMin);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
});
|
});
|
||||||
|
if (overlapFractions.every((x) => x > 0)) {
|
||||||
|
return Math.max(...overlapFractions);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
doesContain(point) {
|
doesContain(point) {
|
||||||
|
@ -69,4 +85,20 @@ export class Rectangle extends Polygon {
|
||||||
}
|
}
|
||||||
return everInside ? point : null;
|
return everInside ? point : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addMargin(margin = DEFAULT_OVERLAP_BUFFER) {
|
||||||
|
const position = this.position.subtract([margin, margin]);
|
||||||
|
const dimensions = this.dimensions.add([2 * margin, 2 * margin]);
|
||||||
|
return new Rectangle(position, dimensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
separationFromRect(rect) {
|
||||||
|
if (this.doesOverlap(rect)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const r = rect.center.subtract(this.center);
|
||||||
|
const outerA = this.lineIntersect(rect.center, r.scale(-1));
|
||||||
|
const outerB = rect.lineIntersect(this.center, r);
|
||||||
|
return outerA.subtract(outerB).magnitude;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ export class Vector extends Array {
|
||||||
}
|
}
|
||||||
|
|
||||||
add(vector) {
|
add(vector) {
|
||||||
|
vector = vector instanceof Vector ? vector : Vector.from(vector);
|
||||||
if (vector.dim !== this.dim) {
|
if (vector.dim !== this.dim) {
|
||||||
throw new Error('Can only add vectors of the same dimensions');
|
throw new Error('Can only add vectors of the same dimensions');
|
||||||
}
|
}
|
||||||
|
@ -11,6 +12,7 @@ export class Vector extends Array {
|
||||||
}
|
}
|
||||||
|
|
||||||
subtract(vector) {
|
subtract(vector) {
|
||||||
|
vector = vector instanceof Vector ? vector : Vector.from(vector);
|
||||||
if (vector.dim !== this.dim) {
|
if (vector.dim !== this.dim) {
|
||||||
throw new Error('Can only subtract vectors of the same dimensions');
|
throw new Error('Can only subtract vectors of the same dimensions');
|
||||||
}
|
}
|
||||||
|
@ -38,7 +40,7 @@ export class Vector extends Array {
|
||||||
}
|
}
|
||||||
|
|
||||||
static randomUnitVector(totalDim) {
|
static randomUnitVector(totalDim) {
|
||||||
return Vector.from(Array(totalDim), () => Math.random()).normalize();
|
return Vector.from(Array(totalDim), () => Math.random() - 0.5).normalize();
|
||||||
}
|
}
|
||||||
|
|
||||||
static zeros(totalDim) {
|
static zeros(totalDim) {
|
||||||
|
|
|
@ -114,21 +114,20 @@ span.small {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
}
|
}
|
||||||
.force-directed-graph {
|
.force-directed-graph {
|
||||||
border: 1px white dotted;
|
|
||||||
margin: 20px;
|
margin: 20px;
|
||||||
}
|
}
|
||||||
.force-directed-graph > canvas {
|
.force-directed-graph > canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border: 1px red dashed;
|
|
||||||
}
|
}
|
||||||
.force-directed-graph > .box {
|
.force-directed-graph > .box {
|
||||||
background-color: #09343f;
|
border: 1px hsl(195.4545454545, 4%, 39.4117647059%) solid;
|
||||||
|
color: #b6b6b6;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px;
|
||||||
}
|
}
|
||||||
.force-directed-graph > .vertex {
|
.force-directed-graph > .vertex {
|
||||||
border: 1px #46b4b4 solid;
|
background-color: #216262;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
.force-directed-graph > .edge {
|
.force-directed-graph > .edge {
|
||||||
border: 1px #51b769 solid;
|
background-color: #2a5b6c;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
|
@ -4,7 +4,7 @@ import { Rectangle, Vector } from '../../classes/supporting/geometry/index.js';
|
||||||
import { overlapRepulsionForce, targetRadiusForce } from '../../classes/display/pairwise-forces.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 { DEFAULT_OVERLAP_BUFFER, EPSILON } from '../../util/constants.js';
|
||||||
import { mochaRun } from '../../util/helpers.js';
|
import { mochaRun } from '../../util/helpers.js';
|
||||||
|
|
||||||
const rootElement = document.getElementById('scene');
|
const rootElement = document.getElementById('scene');
|
||||||
|
@ -18,7 +18,7 @@ describe('Force-Directed Graph', function tests() {
|
||||||
|
|
||||||
before(() => {
|
before(() => {
|
||||||
graph = (window.graph = new ForceDirectedGraph('test1', window.scene.middleSection.el, {
|
graph = (window.graph = new ForceDirectedGraph('test1', window.scene.middleSection.el, {
|
||||||
width: 800, height: 600,
|
width: 1200, height: 900,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
graph.addVertex('v1', 'box1');
|
graph.addVertex('v1', 'box1');
|
||||||
|
@ -32,6 +32,34 @@ describe('Force-Directed Graph', function tests() {
|
||||||
rect.vertices[3].should.eql([0, 1]);
|
rect.vertices[3].should.eql([0, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should measure extent of overlap', () => {
|
||||||
|
const rects = [
|
||||||
|
new Rectangle([0, 0], [1, 1]),
|
||||||
|
new Rectangle([0.5, 0], [1, 1]),
|
||||||
|
new Rectangle([0, 0.5], [1, 1]),
|
||||||
|
new Rectangle([0.5, 0.5], [1, 1]),
|
||||||
|
new Rectangle([0, 1], [1, 1]),
|
||||||
|
new Rectangle([0, 0], [1, 2]),
|
||||||
|
new Rectangle([0, 0], [0.5, 0.5]),
|
||||||
|
new Rectangle([2, 2], [1, 1]),
|
||||||
|
];
|
||||||
|
rects[0].doesOverlap(rects[1]).should.eql(1);
|
||||||
|
rects[0].doesOverlap(rects[2]).should.eql(1);
|
||||||
|
rects[0].doesOverlap(rects[3]).should.eql(0.5);
|
||||||
|
rects[0].doesOverlap(rects[4]).should.eql(0);
|
||||||
|
rects[0].doesOverlap(rects[5]).should.eql(1);
|
||||||
|
rects[0].doesOverlap(rects[6]).should.eql(1);
|
||||||
|
rects[0].doesOverlap(rects[7]).should.eql(0);
|
||||||
|
|
||||||
|
rects[1].doesOverlap(rects[0]).should.eql(1);
|
||||||
|
rects[2].doesOverlap(rects[0]).should.eql(1);
|
||||||
|
rects[3].doesOverlap(rects[0]).should.eql(0.5);
|
||||||
|
rects[4].doesOverlap(rects[0]).should.eql(0);
|
||||||
|
rects[5].doesOverlap(rects[0]).should.eql(1);
|
||||||
|
rects[6].doesOverlap(rects[0]).should.eql(1);
|
||||||
|
rects[7].doesOverlap(rects[0]).should.eql(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('overlapping boxes should repel', () => {
|
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]);
|
||||||
|
@ -41,6 +69,29 @@ describe('Force-Directed Graph', function tests() {
|
||||||
force1.should.eql([0, 10]);
|
force1.should.eql([0, 10]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adjacent boxes should repel', () => {
|
||||||
|
const rect1 = new Rectangle([0, 0], [1, 1]);
|
||||||
|
const rect2 = new Rectangle([0, 1], [1, 1]);
|
||||||
|
const rect3 = new Rectangle([0, DEFAULT_OVERLAP_BUFFER / 2 + 1], [1, 1]);
|
||||||
|
const rect4 = new Rectangle([0, DEFAULT_OVERLAP_BUFFER + 1], [1, 1]);
|
||||||
|
const rect5 = new Rectangle([DEFAULT_OVERLAP_BUFFER + 1, DEFAULT_OVERLAP_BUFFER + 1], [1, 1]);
|
||||||
|
rect1.doesOverlap(rect2).should.eql(0);
|
||||||
|
rect1.doesOverlap(rect3).should.eql(0);
|
||||||
|
rect1.doesOverlap(rect4).should.eql(0);
|
||||||
|
const force1 = overlapRepulsionForce(rect1, rect2, 10);
|
||||||
|
force1[0].should.eql(0);
|
||||||
|
force1[1].should.be.within(9.99, 10.01);
|
||||||
|
const force2 = overlapRepulsionForce(rect1, rect3, 10);
|
||||||
|
force2[0].should.eql(0);
|
||||||
|
force2[1].should.be.within(4.99, 5.01);
|
||||||
|
const force3 = overlapRepulsionForce(rect1, rect4, 10);
|
||||||
|
force3[0].should.eql(0);
|
||||||
|
force3[1].should.be.within(-0.01, 0.01);
|
||||||
|
const force4 = overlapRepulsionForce(rect1, rect5, 10);
|
||||||
|
force4[0].should.be.within(-0.01, 0.01);
|
||||||
|
force4[1].should.be.within(-0.01, 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
it('boxes at target radius should have no net force', () => {
|
it('boxes at target radius should have no net force', () => {
|
||||||
const rect1 = new Rectangle([0, 0], [1, 1]);
|
const rect1 = new Rectangle([0, 0], [1, 1]);
|
||||||
const rect2 = new Rectangle([11, 0], [1, 1]);
|
const rect2 = new Rectangle([11, 0], [1, 1]);
|
||||||
|
@ -104,6 +155,50 @@ describe('Force-Directed Graph', function tests() {
|
||||||
it('runs until reaching equilibrium', async () => {
|
it('runs until reaching equilibrium', async () => {
|
||||||
await graph.runUntilEquilibrium();
|
await graph.runUntilEquilibrium();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can add 10 random nodes', async () => {
|
||||||
|
for (let i = 3; i <= 10; i++) {
|
||||||
|
await delayOrWait(200);
|
||||||
|
const v = graph.addVertex('v2', `box${i}`);
|
||||||
|
v.setProperty('prop', 'value');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can add 10 random edges', async () => {
|
||||||
|
await delayOrWait(500);
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
let from;
|
||||||
|
let to;
|
||||||
|
do {
|
||||||
|
from = graph.nodes[Math.floor(Math.random() * graph.nodes.length)];
|
||||||
|
to = graph.nodes[Math.floor(Math.random() * graph.nodes.length)];
|
||||||
|
} while (from.name === to.name && !graph.getEdge('one', from.name, to.name));
|
||||||
|
await delayOrWait(200);
|
||||||
|
graph.addEdge('one', from.name, to.name, Math.floor(Math.random() * 100) / 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('can add 10 more random nodes', async () => {
|
||||||
|
for (let i = 11; i <= 20; i++) {
|
||||||
|
await delayOrWait(200);
|
||||||
|
const v = graph.addVertex('v3', `box${i}`);
|
||||||
|
v.setProperty('prop', Math.random() * 10000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can add 10 more random edges', async () => {
|
||||||
|
await delayOrWait(500);
|
||||||
|
for (let i = 11; i <= 20; i++) {
|
||||||
|
let from;
|
||||||
|
let to;
|
||||||
|
do {
|
||||||
|
from = graph.nodes[Math.floor(Math.random() * graph.nodes.length)];
|
||||||
|
to = graph.nodes[Math.floor(Math.random() * graph.nodes.length)];
|
||||||
|
} while (from.name === to.name && !graph.getEdge('two', from.name, to.name));
|
||||||
|
await delayOrWait(200);
|
||||||
|
graph.addEdge('two', from.name, to.name, Math.floor(Math.random() * 100) / 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
mochaRun();
|
mochaRun();
|
||||||
|
|
|
@ -9,16 +9,20 @@ export const VertexTypes = {
|
||||||
};
|
};
|
||||||
export const ARROWHEAD_LENGTH = 12;
|
export const ARROWHEAD_LENGTH = 12;
|
||||||
export const ARROWHEAD_WIDTH = 6;
|
export const ARROWHEAD_WIDTH = 6;
|
||||||
export const DEFAULT_OVERLAP_FORCE = 200;
|
export const CENTRAL_RESTORING_FORCE = 100;
|
||||||
|
export const DEFAULT_OVERLAP_BUFFER = 100;
|
||||||
|
export const DEFAULT_OVERLAP_FORCE = 400;
|
||||||
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 = 200;
|
||||||
export const DEFAULT_TIME_STEP = 0.1;
|
export const DEFAULT_TIME_STEP = 0.1;
|
||||||
export const DISTANCE_FACTOR = 0.5;
|
export const DEFAULT_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 MAXIMUM_STEPS = 500;
|
||||||
export const MINIMUM_FORCE = 1;
|
export const MINIMUM_FORCE = 1;
|
||||||
export const MINIMUM_VELOCITY = 0.1;
|
export const MINIMUM_VELOCITY = 1;
|
||||||
|
export const MINIMUM_STEPS = 10;
|
||||||
|
export const OVERLAP_THRESHOLD_RANDOMIZE = 0.5;
|
||||||
export const TIME_DILATION_FACTOR = 500;
|
export const TIME_DILATION_FACTOR = 500;
|
||||||
export const TRANSLATION_VELOCITY_FACTOR = 0.2;
|
export const TRANSLATION_VELOCITY_FACTOR = 0.2;
|
||||||
export const VISCOSITY_FACTOR = 0.4;
|
export const VISCOSITY_FACTOR = 0.7;
|
||||||
|
|
Loading…
Reference in New Issue