diff --git a/src/classes/display/force-directed.js b/src/classes/display/force-directed.js index a1334f9..6de6c3f 100644 --- a/src/classes/display/force-directed.js +++ b/src/classes/display/force-directed.js @@ -5,6 +5,8 @@ import { TIME_DILATION_FACTOR, MAX_STEPS_TO_EQUILIBRIUM, TRANSLATION_VELOCITY_FACTOR, + ARROWHEAD_LENGTH, + ARROWHEAD_WIDTH, } from '../../util/constants.js'; import { Edge } from '../supporting/edge.js'; import { WeightedDirectedGraph } from '../supporting/wdg.js'; @@ -71,8 +73,7 @@ export class ForceDirectedGraph extends WeightedDirectedGraph { box.addClass('absolute'); box.addClass('vertex'); - box.el.style.left = '0px'; - box.el.style.top = '0px'; + box.move(this.box.rect.dimensions.scale(0.5)); box.velocity = Vector.from([0, 0]); box.setInnerHTML(vertex.getHTML()); this.nodes.push(box); @@ -131,18 +132,12 @@ export class ForceDirectedGraph extends WeightedDirectedGraph { box.addClass('absolute'); box.addClass('edge'); - box.setInnerHTML(edge.getHTML()); 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. + // Initially place 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(); @@ -156,6 +151,38 @@ export class ForceDirectedGraph extends WeightedDirectedGraph { return edge; } + drawEdgeLines() { + const ctx = this.canvas.getContext('2d'); + ctx.clearRect(0, 0, this.width, this.height); + ctx.strokeStyle = '#57747d'; + ctx.fillStyle = '#57747d'; + for (const { edge } of this.edges) { + ctx.beginPath(); + ctx.moveTo(...edge.from.box.rect.center); + ctx.lineTo(...edge.box.rect.center); + ctx.stroke(); + + const direction = edge.to.box.rect.center.subtract(edge.box.rect.center); + 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(); + const arrowBaseLeft = arrowBaseCenter.add(arrowBaseDirection.scale(ARROWHEAD_WIDTH)); + const arrowBaseRight = arrowBaseCenter.subtract(arrowBaseDirection.scale(ARROWHEAD_WIDTH)); + + ctx.beginPath(); + ctx.moveTo(...edge.box.rect.center); + ctx.lineTo(...arrowPoint); + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(...arrowPoint); + ctx.lineTo(...arrowBaseLeft); + ctx.lineTo(...arrowBaseRight); + ctx.closePath(); + ctx.fill(); + } + } + async runUntilEquilibrium(tDelta = DEFAULT_TIME_STEP) { this.steps = 0; if (this.intervalTask) { @@ -258,9 +285,9 @@ export class ForceDirectedGraph extends WeightedDirectedGraph { bottomRight[1] = Math.max(bottomRight[1], vertex[1]); } } - const boundingBox = new Rectangle(topLeft, bottomRight.subtract(topLeft)); + const boundingRect = new Rectangle(topLeft, bottomRight.subtract(topLeft)); const graphCenter = Vector.from([this.width, this.height]).scale(0.5); - const offset = graphCenter.subtract(boundingBox.center); + const offset = graphCenter.subtract(boundingRect.center); const translate = offset.scale(TRANSLATION_VELOCITY_FACTOR); if (translate.magnitude >= MINIMUM_VELOCITY) { @@ -275,6 +302,8 @@ export class ForceDirectedGraph extends WeightedDirectedGraph { // TODO: Scaling to fit + this.drawEdgeLines(); + return { atEquilibrium }; } } diff --git a/src/classes/display/pairwise-forces.js b/src/classes/display/pairwise-forces.js index 0ddefdd..a590cf0 100644 --- a/src/classes/display/pairwise-forces.js +++ b/src/classes/display/pairwise-forces.js @@ -25,11 +25,16 @@ export const overlapRepulsionForce = (boxA, boxB, force = DEFAULT_OVERLAP_FORCE) }; export const targetRadiusForce = (boxA, boxB, targetRadius = DEFAULT_TARGET_RADIUS) => { - const [centerA, centerB] = getCenters([boxA, boxB]); + const [rectA, rectB] = getRectangles([boxA, boxB]); + const [centerA, centerB] = getCenters([rectA, rectB]); const r = centerB.subtract(centerA); + // Use the distance between the outer edges of the boxes. + const outerA = rectA.lineIntersect(centerB, r.scale(-1)); + const outerB = rectB.lineIntersect(centerA, r); + const distance = outerB.subtract(outerA).magnitude; // Repel if closer than targetRadius // Attract if farther than targetRadius - const force = -DISTANCE_FACTOR * (r.magnitude - targetRadius); + const force = -DISTANCE_FACTOR * (distance - targetRadius); return r.normalize().scale(force); }; diff --git a/src/classes/supporting/geometry/rectangle.js b/src/classes/supporting/geometry/rectangle.js index 1943b54..b30a116 100644 --- a/src/classes/supporting/geometry/rectangle.js +++ b/src/classes/supporting/geometry/rectangle.js @@ -11,15 +11,15 @@ export class Rectangle extends Polygon { 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--) { + let point = Vector.from(this.position); + for (let dim = 0; dim < point.dim; dim++) { this.addVertex(point); - const increment = Vector.unitVector(dim, dimensions.length); + const increment = Vector.unitVector(dim, point.dim).scale(this.dimensions[dim]); point = point.add(increment); } - for (let dim = dimensions.length - 1; dim >= 0; dim--) { + for (let dim = 0; dim < point.dim; dim++) { this.addVertex(point); - const increment = Vector.unitVector(dim, dimensions.length); + const increment = Vector.unitVector(dim, point.dim).scale(this.dimensions[dim]); point = point.subtract(increment); } } @@ -29,18 +29,44 @@ export class Rectangle extends Polygon { } 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 this.dimensions.every((_, 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); }); } + doesContain(point) { + return this.dimensions.every((_, dim) => { + const thisMin = this.position[dim]; + const thisMax = this.position[dim] + this.dimensions[dim]; + return point[dim] >= thisMin && point[dim] <= thisMax; + }); + } + get aspectRatio() { const [width, height] = this.dimensions; return height / width; } + + lineIntersect(startPoint, direction) { + const r = Vector.from(direction).normalize(); + let point = Vector.from(startPoint); + const maxDistance = this.center.subtract(point).magnitude; + let increment = maxDistance; + let everInside = false; + for (let i = 0; i < 15; i++) { + if (this.doesContain(point)) { + everInside = true; + point = point.subtract(r.scale(increment)); + } else { + point = point.add(r.scale(increment)); + } + increment /= 2; + } + return everInside ? point : null; + } } diff --git a/src/index.css b/src/index.css index 70d0d6c..6adf8f0 100644 --- a/src/index.css +++ b/src/index.css @@ -121,6 +121,9 @@ span.small { position: absolute; border: 1px red dashed; } +.force-directed-graph > .box { + background-color: #09343f; +} .force-directed-graph > .vertex { border: 1px #46b4b4 solid; text-align: center; diff --git a/src/tests/scripts/force-directed.test.js b/src/tests/scripts/force-directed.test.js index 56b1600..d9b9d13 100644 --- a/src/tests/scripts/force-directed.test.js +++ b/src/tests/scripts/force-directed.test.js @@ -27,9 +27,9 @@ describe('Force-Directed Graph', function tests() { it('rectangle should be a polygon with 4 vertices', () => { const rect = new Rectangle([0, 0], [1, 1]); rect.vertices[0].should.eql([0, 0]); - rect.vertices[1].should.eql([0, 1]); + rect.vertices[1].should.eql([1, 0]); rect.vertices[2].should.eql([1, 1]); - rect.vertices[3].should.eql([1, 0]); + rect.vertices[3].should.eql([0, 1]); }); it('overlapping boxes should repel', () => { @@ -43,12 +43,12 @@ describe('Force-Directed Graph', function tests() { it('boxes at target radius should have no net force', () => { const rect1 = new Rectangle([0, 0], [1, 1]); - const rect2 = new Rectangle([10, 0], [1, 1]); + const rect2 = new Rectangle([11, 0], [1, 1]); rect1.center.should.eql([0.5, 0.5]); - rect2.center.should.eql([10.5, 0.5]); + rect2.center.should.eql([11.5, 0.5]); const force = targetRadiusForce(rect1, rect2, 10); - force[0].should.be.within(-EPSILON, EPSILON); - force[1].should.be.within(-EPSILON, EPSILON); + force[0].should.be.within(-0.01, 0.01); + force[1].should.be.within(-0.01, 0.01); }); it('can construct a unit vector', () => { @@ -67,14 +67,37 @@ describe('Force-Directed Graph', function tests() { u.magnitude.should.be.within(1 - EPSILON, 1 + EPSILON); }); + it('can compute intersection of line with rectangle', () => { + const rect = new Rectangle([100, 100], [100, 100]); + { + const intersect = rect.lineIntersect([0, 150], [1, 0]); + intersect[0].should.be.within(99.99, 100.01); + intersect[1].should.eql(150); + } + { + const intersect = rect.lineIntersect([150, 0], [0, 1]); + intersect[0].should.eql(150); + intersect[1].should.be.within(99.99, 100.01); + } + { + const intersect = rect.lineIntersect([0, 0], [1, 1]); + intersect[0].should.be.within(99.99, 100.01); + intersect[1].should.be.within(99.99, 100.01); + } + { + const intersect = rect.lineIntersect([0, 150], [-1, 0]); + (intersect === null).should.be.true; + } + }); + it('can add a second box to the graph', async () => { - await delayOrWait(1000); + await delayOrWait(500); const v = graph.addVertex('v1', 'box2'); v.setProperty('prop', 'value'); }); it('can add an edge to the graph', async () => { - await delayOrWait(1000); + await delayOrWait(500); graph.addEdge('e1', 'box1', 'box2', 1); }); diff --git a/src/util/constants.js b/src/util/constants.js index b366d3f..8b706eb 100644 --- a/src/util/constants.js +++ b/src/util/constants.js @@ -7,6 +7,8 @@ export const VertexTypes = { POST: 'post', AUTHOR: 'author', }; +export const ARROWHEAD_LENGTH = 12; +export const ARROWHEAD_WIDTH = 6; export const DEFAULT_OVERLAP_FORCE = 200; export const DEFAULT_REP_TOKEN_TYPE_ID = 0; export const DEFAULT_TARGET_RADIUS = 300;