Draw arrowhead and equalize distances

This commit is contained in:
Ladd Hoffman 2023-08-08 18:38:49 -05:00
parent 2195f5ea56
commit 4026d7eaa8
6 changed files with 119 additions and 31 deletions

View File

@ -5,6 +5,8 @@ import {
TIME_DILATION_FACTOR, TIME_DILATION_FACTOR,
MAX_STEPS_TO_EQUILIBRIUM, MAX_STEPS_TO_EQUILIBRIUM,
TRANSLATION_VELOCITY_FACTOR, TRANSLATION_VELOCITY_FACTOR,
ARROWHEAD_LENGTH,
ARROWHEAD_WIDTH,
} 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';
@ -71,8 +73,7 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
box.addClass('absolute'); box.addClass('absolute');
box.addClass('vertex'); box.addClass('vertex');
box.el.style.left = '0px'; box.move(this.box.rect.dimensions.scale(0.5));
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());
this.nodes.push(box); this.nodes.push(box);
@ -131,18 +132,12 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
box.addClass('absolute'); box.addClass('absolute');
box.addClass('edge'); box.addClass('edge');
box.setInnerHTML(edge.getHTML()); box.setInnerHTML(edge.getHTML());
this.edges.push(box); this.edges.push(box);
// Center between nodes, by applying an attraction force from the edge node // Initially place near the midpoint between the `from` and `to` nodes.
// 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 midpoint = fromBox.rect.center.add(toBox.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));
console.log({
fromBox, toBox, midpoint, startPosition, dimensions: box.rect.dimensions,
});
box.move(startPosition); box.move(startPosition);
box.velocity = Vector.from([0, 0]); box.velocity = Vector.from([0, 0]);
this.runUntilEquilibrium(); this.runUntilEquilibrium();
@ -156,6 +151,38 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
return edge; 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) { async runUntilEquilibrium(tDelta = DEFAULT_TIME_STEP) {
this.steps = 0; this.steps = 0;
if (this.intervalTask) { if (this.intervalTask) {
@ -258,9 +285,9 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
bottomRight[1] = Math.max(bottomRight[1], vertex[1]); 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 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); const translate = offset.scale(TRANSLATION_VELOCITY_FACTOR);
if (translate.magnitude >= MINIMUM_VELOCITY) { if (translate.magnitude >= MINIMUM_VELOCITY) {
@ -275,6 +302,8 @@ export class ForceDirectedGraph extends WeightedDirectedGraph {
// TODO: Scaling to fit // TODO: Scaling to fit
this.drawEdgeLines();
return { atEquilibrium }; return { atEquilibrium };
} }
} }

View File

@ -25,11 +25,16 @@ export const overlapRepulsionForce = (boxA, boxB, force = DEFAULT_OVERLAP_FORCE)
}; };
export const targetRadiusForce = (boxA, boxB, targetRadius = DEFAULT_TARGET_RADIUS) => { 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); 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 // Repel if closer than targetRadius
// Attract if farther 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); return r.normalize().scale(force);
}; };

View File

@ -11,15 +11,15 @@ export class Rectangle extends Polygon {
this.dimensions = Vector.from(dimensions); this.dimensions = Vector.from(dimensions);
// Next point is obtained by moving the specified length along each dimension // Next point is obtained by moving the specified length along each dimension
// one at a time, then reversing these movements in the same order. // one at a time, then reversing these movements in the same order.
let point = this.position; let point = Vector.from(this.position);
for (let dim = dimensions.length - 1; dim >= 0; dim--) { for (let dim = 0; dim < point.dim; dim++) {
this.addVertex(point); 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); point = point.add(increment);
} }
for (let dim = dimensions.length - 1; dim >= 0; dim--) { for (let dim = 0; dim < point.dim; dim++) {
this.addVertex(point); 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); point = point.subtract(increment);
} }
} }
@ -29,18 +29,44 @@ export class Rectangle extends Polygon {
} }
doesOverlap(rect) { doesOverlap(rect) {
return this.dimensions.every((_, idx) => { return this.dimensions.every((_, dim) => {
const thisMin = this.position[idx]; const thisMin = this.position[dim];
const thisMax = this.position[idx] + this.dimensions[idx]; const thisMax = this.position[dim] + this.dimensions[dim];
const thatMin = rect.position[idx]; const thatMin = rect.position[dim];
const thatMax = rect.position[idx] + rect.dimensions[idx]; const thatMax = rect.position[dim] + rect.dimensions[dim];
return (thisMin <= thatMin && thisMax >= thatMin) return (thisMin <= thatMin && thisMax >= thatMin)
|| (thisMin >= thatMin && thisMin <= thatMax); || (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() { get aspectRatio() {
const [width, height] = this.dimensions; const [width, height] = this.dimensions;
return height / width; 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;
}
} }

View File

@ -121,6 +121,9 @@ span.small {
position: absolute; position: absolute;
border: 1px red dashed; border: 1px red dashed;
} }
.force-directed-graph > .box {
background-color: #09343f;
}
.force-directed-graph > .vertex { .force-directed-graph > .vertex {
border: 1px #46b4b4 solid; border: 1px #46b4b4 solid;
text-align: center; text-align: center;

View File

@ -27,9 +27,9 @@ describe('Force-Directed Graph', function tests() {
it('rectangle should be a polygon with 4 vertices', () => { it('rectangle should be a polygon with 4 vertices', () => {
const rect = new Rectangle([0, 0], [1, 1]); const rect = new Rectangle([0, 0], [1, 1]);
rect.vertices[0].should.eql([0, 0]); 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[2].should.eql([1, 1]);
rect.vertices[3].should.eql([1, 0]); rect.vertices[3].should.eql([0, 1]);
}); });
it('overlapping boxes should repel', () => { 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', () => { 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([10, 0], [1, 1]); const rect2 = new Rectangle([11, 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([11.5, 0.5]);
const force = targetRadiusForce(rect1, rect2, 10); const force = targetRadiusForce(rect1, rect2, 10);
force[0].should.be.within(-EPSILON, EPSILON); force[0].should.be.within(-0.01, 0.01);
force[1].should.be.within(-EPSILON, EPSILON); force[1].should.be.within(-0.01, 0.01);
}); });
it('can construct a unit vector', () => { 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); 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 () => { it('can add a second box to the graph', async () => {
await delayOrWait(1000); await delayOrWait(500);
const v = graph.addVertex('v1', 'box2'); const v = graph.addVertex('v1', 'box2');
v.setProperty('prop', 'value'); v.setProperty('prop', 'value');
}); });
it('can add an edge to the graph', async () => { it('can add an edge to the graph', async () => {
await delayOrWait(1000); await delayOrWait(500);
graph.addEdge('e1', 'box1', 'box2', 1); graph.addEdge('e1', 'box1', 'box2', 1);
}); });

View File

@ -7,6 +7,8 @@ export const VertexTypes = {
POST: 'post', POST: 'post',
AUTHOR: 'author', AUTHOR: 'author',
}; };
export const ARROWHEAD_LENGTH = 12;
export const ARROWHEAD_WIDTH = 6;
export const DEFAULT_OVERLAP_FORCE = 200; 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;