Improvements to graph rendering

This commit is contained in:
Ladd Hoffman 2023-08-09 18:32:58 -05:00
parent 4026d7eaa8
commit 7388a15cff
8 changed files with 262 additions and 59 deletions

View File

@ -69,6 +69,7 @@ export class Box {
}
move(vector) {
vector = vector instanceof Vector ? vector : Vector.from(vector);
this.position = this.position.add(vector);
this.el.style.left = `${Math.floor(this.position[0])}px`;
this.el.style.top = `${Math.floor(this.position[1])}px`;

View File

@ -3,16 +3,19 @@ import {
MINIMUM_VELOCITY,
VISCOSITY_FACTOR,
TIME_DILATION_FACTOR,
MAX_STEPS_TO_EQUILIBRIUM,
MAXIMUM_STEPS,
TRANSLATION_VELOCITY_FACTOR,
ARROWHEAD_LENGTH,
ARROWHEAD_WIDTH,
MINIMUM_STEPS,
CENTRAL_RESTORING_FORCE,
} from '../../util/constants.js';
import { Edge } from '../supporting/edge.js';
import { WeightedDirectedGraph } from '../supporting/wdg.js';
import { Box } from './box.js';
import { Vector, Rectangle } from '../supporting/geometry/index.js';
import { overlapRepulsionForce, targetRadiusForce } from './pairwise-forces.js';
import { Vertex } from '../supporting/vertex.js';
// 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;
}
// addVertex(type, id, data, label, options) {
addVertex(...args) {
const vertex = super.addVertex(...args);
const box = this.box.addBox(vertex.id);
@ -89,6 +91,7 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
// Allow moving vertices with the mouse
box.el.addEventListener('mousedown', (e) => {
console.log('mousedown, button:', e.button);
if (!this.mouseMoving) {
e.preventDefault();
// Record current mouse position
@ -119,10 +122,10 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
}
addEdge(type, from, to, ...rest) {
const fromBox = this.nodes.find(({ name }) => name === from);
const toBox = this.nodes.find(({ name }) => name === to);
if (!fromBox) throw new Error(`from ${from}: Node not found`);
if (!toBox) throw new Error(`to ${to}: Node not found`);
from = from instanceof Vertex ? from : this.getVertex(from);
to = to instanceof Vertex ? to : this.getVertex(to);
if (!from) throw new Error(`from ${from}: Node not found`);
if (!to) throw new Error(`to ${to}: Node not found`);
const edge = super.addEdge(type, from, to, ...rest);
const box = this.box.addBox(Edge.getKey({ from, to, type }));
@ -136,7 +139,7 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
this.edges.push(box);
// 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));
box.move(startPosition);
box.velocity = Vector.from([0, 0]);
@ -163,6 +166,7 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
ctx.stroke();
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 arrowBaseCenter = arrowPoint.subtract(direction.normalize().scale(ARROWHEAD_LENGTH));
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));
ctx.beginPath();
ctx.moveTo(...edge.box.rect.center);
ctx.moveTo(...arrowTail);
ctx.lineTo(...arrowPoint);
ctx.stroke();
@ -190,13 +194,16 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
}
return new Promise((resolve, reject) => {
this.intervalTask = setInterval(() => {
if (this.intervalTaskExecuting) return;
this.steps++;
if (this.steps > MAX_STEPS_TO_EQUILIBRIUM) {
if (this.steps > MAXIMUM_STEPS) {
clearInterval(this.intervalTask);
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);
this.intervalTaskExecuting = false;
if (atEquilibrium) {
console.log(`Reached equilibrium after ${this.steps} steps`);
clearInterval(this.intervalTask);
@ -213,44 +220,81 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
// Initialize net force vectors
for (const box of boxes) {
box.netForce = Vector.zeros(2);
box.forces = [];
}
// Compute overlap repulsion forces among node boxes
for (const boxA of this.nodes) {
const addForce = (box, force, type) => box.forces.push({ force, type });
// All boxes repel each other if they overlap
for (const boxA of boxes) {
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);
boxA.netForce = boxA.netForce.subtract(force);
boxB.netForce = boxB.netForce.add(force);
addForce(
boxA,
force.scale(-1),
`${boxB.name} -- overlapRepulsion --> ${boxA.name}`,
);
addForce(
boxB,
force,
`${boxA.name} -- overlapRepulsion --> ${boxB.name}`,
);
}
}
// Compute pairwise forces among nodes
for (const boxA of this.nodes) {
const idxA = this.nodes.indexOf(boxA);
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);
// Center of graph attracts all boxes that are outside the graph
for (const box of boxes) {
if (!this.box.rect.doesContain(box.rect.center)) {
const r = this.box.rect.center.subtract(box.rect.center);
addForce(
box,
r.normalize().scale(CENTRAL_RESTORING_FORCE),
`center -- attraction --> ${box.name}`,
);
}
}
// Compute forces on edge boxes:
// Attraction to the `from` and `to` nodes
// Compute edge-related forces
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));
// Attraction to the `from` and `to` nodes
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
for (const box of boxes) {
if (this.mouseMoving === box) {
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);
}
// When all velocities are below MINIMUM_VELOCITY, we have reached equilibrium.
let atEquilibrium = true;
// When all velocities are below MINIMUM_VELOCITY and we have executed more than
// MINIMUM_STEPS, we have reached equilibrium.
let atEquilibrium = this.steps > MINIMUM_STEPS;
// Apply velocities
for (const box of boxes) {

View File

@ -1,30 +1,55 @@
import {
DEFAULT_OVERLAP_FORCE,
DEFAULT_TARGET_RADIUS,
DISTANCE_FACTOR,
DEFAULT_DISTANCE_FACTOR,
DEFAULT_OVERLAP_BUFFER,
OVERLAP_THRESHOLD_RANDOMIZE,
} 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) => {
export const overlapRepulsionForce = (
boxA,
boxB,
force = DEFAULT_OVERLAP_FORCE,
margin = DEFAULT_OVERLAP_BUFFER,
) => {
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);
const overlap = rectA.doesOverlap(rectB);
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);
}
return boxB.overlapForceDirection.scale(force);
}
return r.normalize().scale(force);
}
// If their centers actually coincide we can just randomize the direction.
if (r.magnitudeSquared === 0) {
return Vector.randomUnitVector(rectA.dim).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));
}
return r.normalize().scale(force);
// 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 [centerA, centerB] = getCenters([rectA, rectB]);
const r = centerB.subtract(centerA);
@ -35,6 +60,6 @@ export const targetRadiusForce = (boxA, boxB, targetRadius = DEFAULT_TARGET_RADI
// Repel if closer than targetRadius
// Attract if farther than targetRadius
const force = -DISTANCE_FACTOR * (distance - targetRadius);
const force = -distanceFactor * (distance - targetRadius);
return r.normalize().scale(force);
};

View File

@ -1,3 +1,4 @@
import { DEFAULT_OVERLAP_BUFFER } from '../../../util/constants.js';
import { Polygon } from './polygon.js';
import { Vector } from './vector.js';
@ -29,14 +30,29 @@ export class Rectangle extends Polygon {
}
doesOverlap(rect) {
return this.dimensions.every((_, dim) => {
const overlapFractions = this.dimensions.map((_, dim) => {
const thisMin = this.position[dim];
const thisMax = this.position[dim] + this.dimensions[dim];
const thatMin = rect.position[dim];
const thatMax = rect.position[dim] + rect.dimensions[dim];
return (thisMin <= thatMin && thisMax >= thatMin)
|| (thisMin >= thatMin && thisMin <= thatMax);
if (thatMin <= thisMin && thatMax >= thisMin) {
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) {
@ -69,4 +85,20 @@ export class Rectangle extends Polygon {
}
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;
}
}

View File

@ -4,6 +4,7 @@ export class Vector extends Array {
}
add(vector) {
vector = vector instanceof Vector ? vector : Vector.from(vector);
if (vector.dim !== this.dim) {
throw new Error('Can only add vectors of the same dimensions');
}
@ -11,6 +12,7 @@ export class Vector extends Array {
}
subtract(vector) {
vector = vector instanceof Vector ? vector : Vector.from(vector);
if (vector.dim !== this.dim) {
throw new Error('Can only subtract vectors of the same dimensions');
}
@ -38,7 +40,7 @@ export class Vector extends Array {
}
static randomUnitVector(totalDim) {
return Vector.from(Array(totalDim), () => Math.random()).normalize();
return Vector.from(Array(totalDim), () => Math.random() - 0.5).normalize();
}
static zeros(totalDim) {

View File

@ -114,21 +114,20 @@ span.small {
width: 1px;
}
.force-directed-graph {
border: 1px white dotted;
margin: 20px;
}
.force-directed-graph > canvas {
position: absolute;
border: 1px red dashed;
}
.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 {
border: 1px #46b4b4 solid;
text-align: center;
background-color: #216262;
}
.force-directed-graph > .edge {
border: 1px #51b769 solid;
text-align: center;
background-color: #2a5b6c;
}

View File

@ -4,7 +4,7 @@ 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 { 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';
const rootElement = document.getElementById('scene');
@ -18,7 +18,7 @@ describe('Force-Directed Graph', function tests() {
before(() => {
graph = (window.graph = new ForceDirectedGraph('test1', window.scene.middleSection.el, {
width: 800, height: 600,
width: 1200, height: 900,
}));
graph.addVertex('v1', 'box1');
@ -32,6 +32,34 @@ describe('Force-Directed Graph', function tests() {
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', () => {
const rect1 = new Rectangle([0, 0], [1, 1]);
const rect2 = new Rectangle([0, 0], [1, 2]);
@ -41,6 +69,29 @@ describe('Force-Directed Graph', function tests() {
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', () => {
const rect1 = new Rectangle([0, 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 () => {
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();

View File

@ -9,16 +9,20 @@ export const VertexTypes = {
};
export const ARROWHEAD_LENGTH = 12;
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_TARGET_RADIUS = 300;
export const DEFAULT_TARGET_RADIUS = 200;
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 INCINERATOR_ADDRESS = '0';
export const MAX_STEPS_TO_EQUILIBRIUM = 100;
export const MAXIMUM_STEPS = 500;
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 TRANSLATION_VELOCITY_FACTOR = 0.2;
export const VISCOSITY_FACTOR = 0.4;
export const VISCOSITY_FACTOR = 0.7;