diff --git a/.changeset/jumpover-jump-direction.md b/.changeset/jumpover-jump-direction.md new file mode 100644 index 0000000000..81f0da5bc6 --- /dev/null +++ b/.changeset/jumpover-jump-direction.md @@ -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 diff --git a/.changeset/jumpover-skip-distant-links.md b/.changeset/jumpover-skip-distant-links.md new file mode 100644 index 0000000000..cd143950ee --- /dev/null +++ b/.changeset/jumpover-skip-distant-links.md @@ -0,0 +1,5 @@ +--- +"@joint/core": patch +--- + +connectors.jumpover - skip links whose paths cannot cross when looking for jumps diff --git a/.changeset/jumpover-track-jump.md b/.changeset/jumpover-track-jump.md new file mode 100644 index 0000000000..510bf8db59 --- /dev/null +++ b/.changeset/jumpover-track-jump.md @@ -0,0 +1,5 @@ +--- +"@joint/core": patch +--- + +connectors.jumpover - draw one jump over a track of close links, no higher than a single jump diff --git a/packages/joint-core/src/connectors/jumpover.mjs b/packages/joint-core/src/connectors/jumpover.mjs index cf6eea8a60..df95f44208 100644 --- a/packages/joint-core/src/connectors/jumpover.mjs +++ b/packages/joint-core/src/connectors/jumpover.mjs @@ -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; @@ -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; @@ -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; @@ -168,46 +176,40 @@ 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) ); @@ -215,6 +217,14 @@ function createJumps(line, intersections, jumpSize) { }, []); } +// 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 @@ -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 @@ -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); @@ -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, diff --git a/packages/joint-core/test/jointjs/connectors.js b/packages/joint-core/test/jointjs/connectors.js index fcf22cac26..eefe0904d0 100644 --- a/packages/joint-core/test/jointjs/connectors.js +++ b/packages/joint-core/test/jointjs/connectors.js @@ -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(); + } + }); + }); });