Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jumpover-jump-direction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/core": patch
---

connectors.jumpover - fix jumps on a nearly vertical line (e.g. computed by a router) pointing to the opposite side
5 changes: 5 additions & 0 deletions .changeset/jumpover-skip-distant-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/core": patch
---

connectors.jumpover - skip links whose paths cannot cross when looking for jumps
5 changes: 5 additions & 0 deletions .changeset/jumpover-track-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/core": patch
---

connectors.jumpover - draw one jump over a track of close links, no higher than a single jump
105 changes: 75 additions & 30 deletions packages/joint-core/src/connectors/jumpover.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as util from '../util/index.mjs';
import * as g from '../g/index.mjs';
import V from '../V/index.mjs';

// default size of jump if not specified in options
var JUMP_SIZE = 5;
Expand All @@ -21,6 +22,9 @@ var IGNORED_CONNECTORS = ['smooth'];
var _13 = 1 / 3;
var _23 = 2 / 3;

// distance under which two coordinates are considered equal
const TOLERANCE = 1e-6;

function sortPointsAscending(p1, p2) {

let { x: x1, y: y1 } = p1;
Expand Down Expand Up @@ -81,6 +85,10 @@ function createLines(sourcePoint, targetPoint, route) {
}, []);
}

function getPathBBox(sourcePoint, targetPoint, route) {
return new g.Polyline([].concat(sourcePoint, route || [], targetPoint)).bbox();
}

function setupUpdating(jumpOverLinkView) {
var paper = jumpOverLinkView.paper;
var updateList = paper._jumpOverUpdateList;
Expand Down Expand Up @@ -168,53 +176,55 @@ function createJumps(line, intersections, jumpSize) {

// always grab the last line from buffer and modify it
var lastLine = resultLines.pop() || line;
const start = lastLine.start;

// calculate start and end of jump by moving by a given size of jump
var jumpStart = g.point(point).move(lastLine.start, -(jumpSize));
var jumpEnd = g.point(point).move(lastLine.start, +(jumpSize));

// now try to look at the next intersection point
var nextPoint = intersections[idx + 1];
if (nextPoint != null) {
var distance = jumpEnd.distance(nextPoint);
if (distance <= jumpSize) {
// next point is close enough, move the jump end by this
// difference and mark the next point to be skipped
jumpEnd = nextPoint.move(lastLine.start, distance);
nextPoint.skip = true;
}
} else {
// this block is inside of `else` as an optimization so the distance is
// not calculated when we know there are no other intersection points
var endDistance = jumpStart.distance(lastLine.end);
// if the end is too close to possible jump, draw remaining line instead of a jump
if (endDistance < jumpSize * 2 + CLOSE_PROXIMITY_PADDING) {
resultLines.push(lastLine);
return resultLines;
}
}
// the space a jump needs before the first and after the last crossing
const margin = jumpSize + CLOSE_PROXIMITY_PADDING;

var startDistance = jumpEnd.distance(lastLine.start);
if (startDistance < jumpSize * 2 + CLOSE_PROXIMITY_PADDING) {
// if the start of line is too close to jump, draw that line instead of a jump
// if the start or the end of the line is too close to the jump,
// draw that line instead of a jump
if (start.distance(point) < margin || lastLine.end.distance(point) < margin) {
resultLines.push(lastLine);
return resultLines;
}

// calculate start and end of jump by moving by a given size of jump
const jumpStart = g.point(point).move(start, -(jumpSize));
let jumpEnd = g.point(point).move(start, +(jumpSize));

// Merge every following intersection that is close to the end of the
// jump, so a track of parallel lines gets one jump instead of many.
for (let next = idx + 1; next < intersections.length; next++) {
const nextPoint = intersections[next];
if (start.distance(nextPoint) - start.distance(jumpEnd) > jumpSize) break;
// leave the intersections too close to the end out of the jump
if (lastLine.end.distance(nextPoint) < margin) break;
nextPoint.skip = true;
jumpEnd = g.point(nextPoint).move(start, +(jumpSize));
}

// finally create a jump line
var jumpLine = g.line(jumpStart, jumpEnd);
// it's just simple line but with a `isJump` property
jumpLine.isJump = true;

resultLines.push(
g.line(lastLine.start, jumpStart),
g.line(start, jumpStart),
jumpLine,
g.line(jumpEnd, lastLine.end)
);
return resultLines;
}, []);
}

// Whether the jump has to be flipped so it points up (or right). A vertical
// line computed by a router can be a tiny bit off (e.g. `x` differs by 1e-13),
// which must not flip the jump.
function pointsLeftOrDown(diff) {
if (Math.abs(diff.x) > TOLERANCE) return Number(diff.x < 0);
return Number(diff.y < 0);
}

/**
* Assemble `D` attribute of a SVG path by iterating given lines.
* @param {g.line[]} lines source lines to use
Expand Down Expand Up @@ -244,12 +254,34 @@ function buildPath(lines, jumpSize, jumpType, radius) {
// determine rotation of arc based on difference between points
diff = line.start.difference(line.end);
// make sure the arc always points up (or right)
var xAxisRotate = Number((diff.x < 0) || (diff.x === 0 && diff.y < 0));
var xAxisRotate = pointsLeftOrDown(diff);
if (xAxisRotate) angle += 180;

var midpoint = line.midpoint();
var centerLine = new g.Line(midpoint, line.end).rotate(midpoint, angle);

if (line.length() > 2 * jumpSize + TOLERANCE) {
// A merged jump over several lines: a quarter circle up,
// a straight top and a quarter circle down, so it bulges
// no more than a single jump (by `jumpSize`).
const along = new g.Point(line.end).difference(line.start).normalize(jumpSize);
const normal = new g.Point(centerLine.end).difference(midpoint).normalize(jumpSize);
const topStart = new g.Point(line.start).offset(along.x + normal.x, along.y + normal.y);
const topEnd = new g.Point(line.end).offset(-along.x + normal.x, -along.y + normal.y);
path.appendSegment(g.Path.createSegment('C',
new g.Point(line.start).offset(normal.x * V.KAPPA, normal.y * V.KAPPA),
new g.Point(topStart).offset(-along.x * V.KAPPA, -along.y * V.KAPPA),
topStart
));
path.appendSegment(g.Path.createSegment('L', topEnd));
path.appendSegment(g.Path.createSegment('C',
new g.Point(topEnd).offset(along.x * V.KAPPA, along.y * V.KAPPA),
new g.Point(line.end).offset(normal.x * V.KAPPA, normal.y * V.KAPPA),
line.end
));
return;
}

var halfLine;

// first half
Expand Down Expand Up @@ -283,7 +315,7 @@ function buildPath(lines, jumpSize, jumpType, radius) {
// determine rotation of arc based on difference between points
diff = line.start.difference(line.end);
// make sure the arc always points up (or right)
xAxisRotate = Number((diff.x < 0) || (diff.x === 0 && diff.y < 0));
xAxisRotate = pointsLeftOrDown(diff);
if (xAxisRotate) yOffset *= -1;

control1 = g.Point(line.start.x + xOffset, line.start.y + yOffset).rotate(line.start, angle);
Expand Down Expand Up @@ -390,6 +422,19 @@ export const jumpover = function(sourcePoint, targetPoint, route, opt, linkView)
return paper.findViewByModel(link);
});

// Two links can cross only where the bounding boxes of their routes meet,
// so the links outside of this link's bounding box are left out.
// It is inflated, since `g.Rect.intersect()` misses boxes that only touch
// (e.g. the zero-height box of a horizontal link).
const thisBBox = getPathBBox(sourcePoint, targetPoint, route).inflate(1);
const isCandidate = links.map((link, i) => {
const view = linkViews[i];
if (link === thisModel || !view || !view.sourcePoint || !view.targetPoint) return true;
return thisBBox.intersect(getPathBBox(view.sourcePoint, view.targetPoint, view.route)) !== null;
});
links = links.filter((_, i) => isCandidate[i]);
linkViews = linkViews.filter((_, i) => isCandidate[i]);

// create lines for this link
var thisLines = createLines(
sourcePoint,
Expand Down
145 changes: 145 additions & 0 deletions packages/joint-core/test/jointjs/connectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -392,4 +392,149 @@ QUnit.module('connectors', function(hooks) {
// The link consists of two straight lines - no jumps
assert.checkDataPath(pathData, 'M 25 300 L 25 75 L 50 75');
});

QUnit.module('jumpover connector', function(hooks) {

const JUMP_SIZE = 5;

hooks.beforeEach(function() {
this.addLink = (source, target, connectorName = 'jumpover', args = {}) => {
const link = new joint.shapes.standard.Link({
source,
target,
connector: { name: connectorName, args: { size: JUMP_SIZE, ...args }}
});
this.graph.addCell(link);
return link;
};
this.refresh = () => {
// jumpover links are refreshed when a batch ends
this.graph.startBatch('test');
this.graph.stopBatch('test');
};
});

QUnit.test('a jump over a track of close parallel links is a single jump', function(assert) {
const link = this.addLink({ x: 0, y: 100 }, { x: 300, y: 100 });
[100, 107, 114, 121].forEach((x) => this.addLink({ x, y: 0 }, { x, y: 200 }, 'normal'));
this.refresh();
const path = link.findView(this.paper).getConnection();
const jumpSegments = path.segments.filter((segment) => segment.type === 'C');
assert.equal(jumpSegments.length, 2, 'one jump (two quarter circles)');
const bbox = path.bbox();
assert.equal(Math.round(bbox.y * 1000) / 1000, 100 - JUMP_SIZE, 'the jump bulges by the jump size');
assert.equal(Math.round(path.start.x), 0);
assert.equal(Math.round(path.end.x), 300);
});

QUnit.test('two close crossings get one jump', function(assert) {
const link = this.addLink({ x: 0, y: 100 }, { x: 300, y: 100 });
[100, 107].forEach((x) => this.addLink({ x, y: 0 }, { x, y: 200 }, 'normal'));
this.refresh();
const path = link.findView(this.paper).getConnection();
assert.equal(path.segments.filter((segment) => segment.type === 'C').length, 2, 'one jump');
assert.equal(Math.round(path.bbox().y * 1000) / 1000, 100 - JUMP_SIZE, 'the jump bulges by the jump size');
});

QUnit.test('a crossing too close to the end of the line does not cancel the jump over the others', function(assert) {
// the last crossing is 4px before the end of the line
const link = this.addLink({ x: 0, y: 100 }, { x: 100, y: 100 });
[60, 67, 74, 81, 88, 96].forEach((x) => this.addLink({ x, y: 0 }, { x, y: 200 }, 'normal'));
this.refresh();
const path = link.findView(this.paper).getConnection();
assert.equal(path.segments.filter((segment) => segment.type === 'C').length, 2, 'one jump');
const bbox = path.bbox();
assert.ok(bbox.y < 100, 'the jump is drawn');
assert.equal(Math.round(path.end.x), 100);
});

QUnit.test('a crossing too close to the start of the line does not cancel the jump over the others', function(assert) {
// the first crossing is 4px after the start of the line
const link = this.addLink({ x: 0, y: 100 }, { x: 100, y: 100 });
[4, 11, 18, 25, 32].forEach((x) => this.addLink({ x, y: 0 }, { x, y: 200 }, 'normal'));
this.refresh();
const path = link.findView(this.paper).getConnection();
assert.equal(path.segments.filter((segment) => segment.type === 'C').length, 2, 'one jump');
assert.equal(Math.round(path.start.x), 0);
});

QUnit.test('a gap over a track of close parallel links is a single gap', function(assert) {
const link = this.addLink({ x: 0, y: 100 }, { x: 300, y: 100 }, 'jumpover', { jump: 'gap' });
[100, 107, 114, 121].forEach((x) => this.addLink({ x, y: 0 }, { x, y: 200 }, 'normal'));
this.refresh();
const path = link.findView(this.paper).getConnection();
const moves = path.segments.filter((segment) => segment.type === 'M');
assert.equal(moves.length, 2, 'one gap');
const gapStart = path.segments[path.segments.indexOf(moves[1]) - 1].end;
assert.equal(Math.round(gapStart.x), 100 - JUMP_SIZE, 'the gap starts before the first link');
assert.equal(Math.round(moves[1].end.x), 121 + JUMP_SIZE, 'the gap ends after the last link');
});

QUnit.test('a cubic jump over a track of close parallel links is a single curve', function(assert) {
const link = this.addLink({ x: 0, y: 100 }, { x: 300, y: 100 }, 'jumpover', { jump: 'cubic' });
[100, 107, 114, 121].forEach((x) => this.addLink({ x, y: 0 }, { x, y: 200 }, 'normal'));
this.refresh();
const path = link.findView(this.paper).getConnection();
assert.equal(path.segments.filter((segment) => segment.type === 'C').length, 1, 'one curve');
assert.ok(100 - path.bbox().y < JUMP_SIZE * 1.1, 'the curve is about as high as the jump size');
});

QUnit.test('a single jump is a semicircle of the jump size', function(assert) {
const link = this.addLink({ x: 0, y: 100 }, { x: 300, y: 100 });
this.addLink({ x: 150, y: 0 }, { x: 150, y: 200 }, 'normal');
this.refresh();
const path = link.findView(this.paper).getConnection();
assert.equal(path.segments.filter((segment) => segment.type === 'C').length, 2);
assert.equal(Math.round(path.bbox().y * 1000) / 1000, 100 - JUMP_SIZE);
});

QUnit.test('distant crossings get separate jumps', function(assert) {
const link = this.addLink({ x: 0, y: 100 }, { x: 300, y: 100 });
[100, 200].forEach((x) => this.addLink({ x, y: 0 }, { x, y: 200 }, 'normal'));
this.refresh();
const path = link.findView(this.paper).getConnection();
assert.equal(path.segments.filter((segment) => segment.type === 'C').length, 4, 'two jumps');
});

QUnit.test('a vertical jump points the same way when the line is a tiny bit off', function(assert) {
const exact = this.addLink({ x: 100, y: 200 }, { x: 100, y: 0 });
const offRight = this.addLink({ x: 200, y: 200 }, { x: 200 + 1e-9, y: 0 });
const exactDown = this.addLink({ x: 300, y: 0 }, { x: 300, y: 200 });
const offLeftDown = this.addLink({ x: 400, y: 0 }, { x: 400 - 1e-9, y: 200 });
this.addLink({ x: 0, y: 100 }, { x: 500, y: 100 }, 'normal');
this.refresh();
const side = (link, x) => Math.sign(Math.round(link.findView(this.paper).getConnection().bbox().center().x - x));
assert.notEqual(side(exact, 100), 0, 'the jump is drawn');
assert.equal(side(offRight, 200), side(exact, 100), 'up');
assert.equal(side(offLeftDown, 400), side(exactDown, 300), 'down');
});

QUnit.test('links outside of the bounding box are not tested for crossings', function(assert) {
const link = this.addLink({ x: 0, y: 100 }, { x: 300, y: 100 });
this.addLink({ x: 150, y: 0 }, { x: 150, y: 200 }, 'normal');
[400, 500, 600].forEach((x) => this.addLink({ x, y: 0 }, { x, y: 200 }, 'normal'));
const intersectionSpy = sinon.spy(joint.g.Line.prototype, 'intersection');
try {
link.findView(this.paper).requestUpdate(link.findView(this.paper).getFlag('CONNECTOR'));
// 1 own segment: tested against the crossing link only
assert.equal(intersectionSpy.callCount, 1);
} finally {
intersectionSpy.restore();
}
assert.equal(link.findView(this.paper).getConnection().segments.filter((segment) => segment.type === 'C').length, 2, 'the crossing still gets a jump');
});

QUnit.test('a link touching the bounding box at its edge is tested', function(assert) {
// the zero-height box of a horizontal link and a vertical link ending on it
const link = this.addLink({ x: 0, y: 100 }, { x: 300, y: 100 });
this.addLink({ x: 150, y: 0 }, { x: 150, y: 100 }, 'normal');
const intersectionSpy = sinon.spy(joint.g.Line.prototype, 'intersection');
try {
link.findView(this.paper).requestUpdate(link.findView(this.paper).getFlag('CONNECTOR'));
assert.equal(intersectionSpy.callCount, 1);
} finally {
intersectionSpy.restore();
}
});
});
});
Loading