Draw arrowhead and equalize distances
This commit is contained in:
parent
2195f5ea56
commit
4026d7eaa8
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue