src/js/data/edge/edge.js
import { calcBezierDistance, bezierDerivative } from '../../util/bezier';
import Point2D from '../../util/point-2d';
import Triangle2D from '../../util/triangle-2d';
import Line2D from '../../util/line-2d';
import Label from '../label';
const EDGE_DISTANCE_THRESHOLD = 10;
/**
* Data representation of a graph edge.
* @class Edge
*/
class Edge {
static numEdges = 0;
id = Edge.numEdges++;
// Line Control
startPoint = { x: 0, y: 0 };
bezierPoint = { x: 0, y: 0 };
destPoint = { x: 0, y: 0 };
// graph data
startNode = null;
destNode = null;
isDirected = false;
partners = [];
// status
isSelected = false;
// label
label;
// appearance
color = '#000000';
selectedColor = '#FF0000';
lineWidth = 1;
/**
* Constructs an Edge instance. Should not be called directly.
* @param {Node} startNode - Start node of the edge.
* @param {Node} destNode - Destination node of the edge.
* @constructs Edge
*/
constructor(startNode, destNode) {
if (typeof startNode === 'undefined' || typeof destNode === 'undefined') {
throw Error(`Edge constructor requires at least two arguments: startNode and destNode. Actually passed in ${startNode}, ${destNode}`);
}
this.startNode = startNode;
this.destNode = destNode;
if (this.startNode !== null && this.destNode !== null) {
this.startNode.edges.add(this);
this.destNode.edges.add(this);
// copy partners from an existing partner edge
for (let edge of this.startNode.edges) {
if (edge.startNode === this.startNode && edge.destNode === this.destNode) {
this.partners = edge.partners.slice(0);
break;
} else if (edge.destNode === this.startNode && edge.startNode === this.destNode) {
this.partners = edge.partners.slice(0);
break;
}
}
// add this edge to partners field of all partner edges
for (let i = 0; i < this.partners.length; i++) {
this.partners[i].partners.push(this);
}
this.partners.push(this);
if (this.startNode === this.destNode) {
this.isDirected = true;
this.updateEndpoints();
} else {
for (let i = 0; i < this.partners.length; i++) {
this.partners[i].updateEndpoints();
}
}
} else {
this.partners.push(this);
}
this.label = new Label(this.bezierPoint.x, this.bezierPoint.y, this);
}
/**
* Remove the edge from all other graph objects associated with it.
*/
detach() {
// remove this edge from partners of all partner edges
for (let i = 0; i < this.partners.length; i++) {
if (this.partners[i] === this) {
continue;
}
let index = this.partners[i].partners.indexOf(this);
this.partners[i].partners.splice(index, 1);
this.partners[i].updateEndpoints();
}
this.partners = [];
this.partners.push(this);
if (this.startNode !== null) {
this.startNode.edges.delete(this);
}
if (this.destNode !== null) {
this.destNode.edges.delete(this);
}
this.startNode = null;
this.destNode = null;
}
/**
* Update endpoints for a self-loop edge. Called by updateEndpoints().
*/
updateSelfLoopEndpoints() {
this.startPoint = this.startNode.getAnglePoint(240);
this.destPoint = this.startNode.getAnglePoint(300);
this.bezierPoint = {
x: (this.startPoint.x + this.destPoint.x) / 2,
y: this.startPoint.y - 2 * this.startNode.radius
};
}
/**
* Update endpoints for a normal edge. Called by updateEndpoints().
*/
updateNormalEdgeEndpoints() {
let dx = this.destNode.x - this.startNode.x;
let dy = this.destNode.y - this.startNode.y;
let distance = Math.sqrt(dx * dx + dy * dy);
// calculate incline based on dy and distance
// this is the angle between x-axis and the line from startNode to destNode
let incline = Math.asin(dy / distance);
// convert from radians to degrees
incline = incline * 180 / Math.PI;
// Note that canvas coordinates increase towards the bottom right
// Quadrants are oriented as follows:
// |
// Q3 | Q4
// |
// ------------- +x
// |
// Q2 | Q1
// |
// +y
//
// if destNode.x < startNode.x, the angle should end:
// in the second quadrant if destNode.y > startNode.y
// in the third quadrant if destNode.y < startNode.y
if (this.startNode.x >= this.destNode.x) {
if (this.startNode.y >= this.destNode.y) {
incline = (540 - incline) % 360;
} else {
incline = 180 - incline;
}
}
let numPartners = this.partners.length + 1;
let multiIndex = this.partners.indexOf(this) + 1;
let ratio = multiIndex / numPartners;
// if the partner edge with index 0 is going in the opposite direction compared to the current edge
// then draw the current edge on the opposite side of the line connecting the two nodes
if (this.partners[0].startNode !== this.startNode) {
ratio = 1 - ratio;
}
let startAngle = incline + 90 * ratio - 45;
let destAngle = incline + 225 - 90 * ratio;
this.startPoint = this.startNode.getAnglePoint(startAngle);
this.destPoint = this.destNode.getAnglePoint(destAngle);
let orient = ratio - 0.5;
let diff = {
x: this.destPoint.x - this.startPoint.x,
y: this.destPoint.y - this.startPoint.y
};
this.bezierPoint = {
x: (this.startPoint.x + this.destPoint.x) / 2 - orient * diff.y,
y: (this.startPoint.y + this.destPoint.y) / 2 + orient * diff.x
};
}
/**
* Update the endpoints of the edge.
*/
updateEndpoints() {
let oldStartPoint = this.startPoint;
let oldBezierPoint = this.bezierPoint;
let oldDestPoint = this.destPoint;
if (this.startNode === this.destNode) {
this.updateSelfLoopEndpoints();
} else {
this.updateNormalEdgeEndpoints();
}
if (this.label) {
if (oldStartPoint === null || oldBezierPoint === null || oldDestPoint === null) {
// initial label location
this.label.x = this.bezierPoint.x;
this.label.y = this.bezierPoint.y;
} else if (this.startNode === this.destNode) {
// self loop case
this.updateSelfLoopLabel(oldBezierPoint);
} else {
let oldStartPoint2D = new Point2D(oldStartPoint.x, oldStartPoint.y);
let oldBezierPoint2D = new Point2D(oldBezierPoint.x, oldBezierPoint.y);
let oldDestPoint2D = new Point2D(oldDestPoint.x, oldDestPoint.y);
let startPoint2D = new Point2D(this.startPoint.x, this.startPoint.y);
let bezierPoint2D = new Point2D(this.bezierPoint.x, this.bezierPoint.y);
let destPoint2D = new Point2D(this.destPoint.x, this.destPoint.y);
let oldStartDest = new Line2D(oldStartPoint2D, oldDestPoint2D);
let startDest = new Line2D(startPoint2D, destPoint2D);
if (startDest.hasPoint(bezierPoint2D)) {
if (startPoint2D.equals(destPoint2D)) {
// corner case where startPoint == endPoint
this.label.x = startPoint2D.x;
this.label.y = startPoint2D.y;
}
// straight edge case
if (!oldStartDest.hasPoint(oldBezierPoint2D)) {
// was previously a bezier curve, so reset coordinates to bezierPoint
this.label.x = this.bezierPoint.x;
this.label.y = this.bezierPoint.y;
return;
}
this.updateStraightEdgeLabel(oldStartPoint2D, oldBezierPoint2D, oldDestPoint2D, startPoint2D, bezierPoint2D, destPoint2D);
} else {
if (oldStartDest.hasPoint(oldBezierPoint2D)) {
// was previously a straight line, so reset coordinates to bezierPoint
this.label.x = this.bezierPoint.x;
this.label.y = this.bezierPoint.y;
return;
}
// curved edge
this.updateCurvedEdgeLabel(oldStartPoint2D, oldBezierPoint2D, oldDestPoint2D, startPoint2D, bezierPoint2D, destPoint2D);
}
}
}
}
/**
* Check if the edge contains a given point (within a distance threshold).
* @param {number} x - x-coordinate of the point.
* @param {number} y - y-coordinate of the point.
* @return {boolean} - Whether or not the edge contains the point.
*/
containsPoint(x, y) {
return EDGE_DISTANCE_THRESHOLD > calcBezierDistance(x, y, this.startPoint, this.bezierPoint, this.destPoint);
}
/**
* Draw the edge on the given canvas context.
* @param {CanvasRenderingContext2D} context - Canvas 2D context.
* @throws {Error} - Throws error if called.
* @abstract
*/
draw(context) {
throw Error('Can\'t call draw from abstract Edge class.');
}
/**
* Draw the Label object associated with this edge.
* @param {CanvasRenderingContext2D} context - Canvas 2D context.
*/
drawLabel(context) {
this.label.draw(context);
}
/**
* Draw an arrow on the destination side of the edge on the given context.
* @param {CanvasRenderingContext2D} context - Canvas 2D context.
*/
drawArrow(context) {
let slope = bezierDerivative(1, this.startPoint, this.bezierPoint, this.destPoint);
let length = Math.sqrt(slope.x * slope.x + slope.y * slope.y);
// normalize slope
slope = { x: slope.x / length, y: slope.y / length };
// perpendicular:
context.beginPath();
context.moveTo(this.destPoint.x, this.destPoint.y);
context.lineTo(this.destPoint.x - 15 * slope.x - 5 * slope.y, this.destPoint.y - 15 * slope.y + 5 * slope.x);
context.lineTo(this.destPoint.x - 9 * slope.x, this.destPoint.y - 9 * slope.y);
context.lineTo(this.destPoint.x - 15 * slope.x + 5 * slope.y, this.destPoint.y - 15 * slope.y - 5 * slope.x);
context.fill();
}
/**
* Update the position of the Label for a self-loop edge.
* @param {Object} oldBezierPoint - The previous bezier point location.
* @param {number} oldBezierPoint.x - The x-coordinate of the bezier point.
* @param {number} oldBezierPoint.y - The y-coordinate of the bezier point.
*/
updateSelfLoopLabel(oldBezierPoint) {
this.label.x += this.bezierPoint.x - oldBezierPoint.x;
this.label.y += this.bezierPoint.y - oldBezierPoint.y;
}
/**
* Update the position of the Label for a straight edge.
* @param {Point2D} oldStartPoint2D - The previous start point.
* @param {Point2D} oldBezierPoint2D - The previous bezier point.
* @param {Point2D} oldDestPoint2D - The previous destination point.
* @param {Point2D} startPoint2D - The current start point.
* @param {Point2D} bezierPoint2D - The current bezier point.
* @param {Point2D} destPoint2D - The current destination point.
*/
updateStraightEdgeLabel(oldStartPoint2D, oldBezierPoint2D, oldDestPoint2D, startPoint2D, bezierPoint2D, destPoint2D) {
let oldLabelPosition = new Point2D(this.label.x, this.label.y);
let oldStartLabelVec = oldStartPoint2D.vectorTo(oldLabelPosition);
let oldStartDestVec = oldStartPoint2D.vectorTo(oldDestPoint2D);
let u = oldStartLabelVec.projectOnto(oldStartDestVec);
let v = oldStartLabelVec.sub(u);
let ratioU = u.length / oldStartDestVec.length;
if (u.degreesTo(oldStartDestVec) !== 0) {
ratioU *= -1;
}
let startDestVec = startPoint2D.vectorTo(destPoint2D);
let newU = startDestVec.scale(ratioU);
let newLabelPosition;
if (v.length === 0) {
// label is on the line
newLabelPosition = startPoint2D.translateVec(newU);
} else {
// label is not on the line
let ratioV = v.length / oldStartDestVec.length;
let angle = oldStartDestVec.degreesTo(v);
let newV = startDestVec.rotateDegrees(angle).scale(ratioV);
newLabelPosition = startPoint2D.translateVec(newU).translateVec(newV);
}
this.label.x = newLabelPosition.x;
this.label.y = newLabelPosition.y;
}
/**
* Update the position of the Label for a curved edge.
* @param {Point2D} oldStartPoint2D - The previous start point.
* @param {Point2D} oldBezierPoint2D - The previous bezier point.
* @param {Point2D} oldDestPoint2D - The previous destination point.
* @param {Point2D} startPoint2D - The current start point.
* @param {Point2D} bezierPoint2D - The current bezier point.
* @param {Point2D} destPoint2D - The current destination point.
*/
updateCurvedEdgeLabel(oldStartPoint2D, oldBezierPoint2D, oldDestPoint2D, startPoint2D, bezierPoint2D, destPoint2D) {
let oldTriangle = new Triangle2D(oldStartPoint2D, oldBezierPoint2D, oldDestPoint2D);
let newTriangle = new Triangle2D(startPoint2D, bezierPoint2D, destPoint2D);
let oldLabelPosition = new Point2D(this.label.x, this.label.y);
let newLabelPosition = oldLabelPosition.relativePositionToTriangle2D(oldTriangle, newTriangle);
this.label.x = newLabelPosition.x;
this.label.y = newLabelPosition.y;
}
}
export { Edge };
export default Edge;