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