From 7388a15cffc6f26d49ba1bae41647569f398c3f0 Mon Sep 17 00:00:00 2001 From: Ladd Hoffman Date: Wed, 9 Aug 2023 18:32:58 -0500 Subject: [PATCH] Improvements to graph rendering --- src/classes/display/box.js | 1 + src/classes/display/force-directed.js | 105 +++++++++++++------ src/classes/display/pairwise-forces.js | 45 ++++++-- src/classes/supporting/geometry/rectangle.js | 38 ++++++- src/classes/supporting/geometry/vector.js | 4 +- src/index.css | 13 ++- src/tests/scripts/force-directed.test.js | 99 ++++++++++++++++- src/util/constants.js | 16 +-- 8 files changed, 262 insertions(+), 59 deletions(-) diff --git a/src/classes/display/box.js b/src/classes/display/box.js index 843b0b2..a436f71 100644 --- a/src/classes/display/box.js +++ b/src/classes/display/box.js @@ -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`; diff --git a/src/classes/display/force-directed.js b/src/classes/display/force-directed.js index 6de6c3f..627e5a8 100644 --- a/src/classes/display/force-directed.js +++ b/src/classes/display/force-directed.js @@ -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) { diff --git a/src/classes/display/pairwise-forces.js b/src/classes/display/pairwise-forces.js index a590cf0..3c5fb07 100644 --- a/src/classes/display/pairwise-forces.js +++ b/src/classes/display/pairwise-forces.js @@ -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); }; diff --git a/src/classes/supporting/geometry/rectangle.js b/src/classes/supporting/geometry/rectangle.js index b30a116..b08afbc 100644 --- a/src/classes/supporting/geometry/rectangle.js +++ b/src/classes/supporting/geometry/rectangle.js @@ -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; + } } diff --git a/src/classes/supporting/geometry/vector.js b/src/classes/supporting/geometry/vector.js index 09588be..318f0b7 100644 --- a/src/classes/supporting/geometry/vector.js +++ b/src/classes/supporting/geometry/vector.js @@ -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) { diff --git a/src/index.css b/src/index.css index 6adf8f0..d51b782 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } \ No newline at end of file diff --git a/src/tests/scripts/force-directed.test.js b/src/tests/scripts/force-directed.test.js index d9b9d13..44b9cbc 100644 --- a/src/tests/scripts/force-directed.test.js +++ b/src/tests/scripts/force-directed.test.js @@ -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(); diff --git a/src/util/constants.js b/src/util/constants.js index 8b706eb..af8eb15 100644 --- a/src/util/constants.js +++ b/src/util/constants.js @@ -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;