From fd4c6028921f67bc73a840f0b19ad59c356a5dae Mon Sep 17 00:00:00 2001 From: Philipp Fromme Date: Wed, 4 Jul 2018 09:03:15 +0200 Subject: [PATCH] feat(layout/ManhattanLayout): add t|r|b|l configuration options Give users more control over the layouted connection by allowing explicit sites <[t]op, [l]eft, [b]ottom, [r]ight> to be specified with the preferred layout. If explicit outgoing/incoming directions are given, the layouter will layout a u-turn to enforce the preference. Related to bpmn-io/bpmn-js#467 --- lib/layout/ManhattanLayout.js | 393 +++++++++++++++++++----- test/spec/layout/ManhattanLayoutSpec.js | 235 ++++++++++++-- 2 files changed, 516 insertions(+), 112 deletions(-) diff --git a/lib/layout/ManhattanLayout.js b/lib/layout/ManhattanLayout.js index 78a5e7207..1c7f07d2a 100644 --- a/lib/layout/ManhattanLayout.js +++ b/lib/layout/ManhattanLayout.js @@ -13,9 +13,15 @@ import { import { pointInRect, pointDistance, - pointsAligned + pointsAligned, + pointsOnLine } from '../util/Geometry'; +var MIN_SEGMENT_LENGTH = 20, + POINT_ORIENTATION_PADDING = 5; + +var round = Math.round; + var INTERSECTION_THRESHOLD = 20, ORIENTATION_THRESHOLD = { 'h:h': 20, @@ -24,66 +30,224 @@ var INTERSECTION_THRESHOLD = 20, 'v:h': -10 }; +function needsTurn(orientation, startDirection) { + return !{ + t: /top/, + r: /right/, + b: /bottom/, + l: /left/, + h: /./, + v: /./ + }[startDirection].test(orientation); +} + +function canLayoutStraight(direction, targetOrientation) { + return { + t: /top/, + r: /right/, + b: /bottom/, + l: /left/, + h: /left|right/, + v: /top|bottom/ + }[direction].test(targetOrientation); +} + +function getSegmentBendpoints(a, b, directions) { + var orientation = getOrientation(b, a, POINT_ORIENTATION_PADDING); + + var startDirection = directions.split(':')[0]; + + var xmid = round((b.x - a.x) / 2 + a.x), + ymid = round((b.y - a.y) / 2 + a.y); + + var segmentEnd, segmentDirections; + + var layoutStraight = canLayoutStraight(startDirection, orientation), + layoutHorizontal = /h|r|l/.test(startDirection), + layoutTurn = false; + + var turnNextDirections = false; + + if (layoutStraight) { + segmentEnd = layoutHorizontal ? { x: xmid, y: a.y } : { x: a.x, y: ymid }; + + segmentDirections = layoutHorizontal ? 'h:h' : 'v:v'; + } else { + layoutTurn = needsTurn(orientation, startDirection); + + segmentDirections = layoutHorizontal ? 'h:v' : 'v:h'; + + if (layoutTurn) { + + if (layoutHorizontal) { + turnNextDirections = ymid === a.y; + + segmentEnd = { + x: a.x + MIN_SEGMENT_LENGTH * (/l/.test(startDirection) ? -1 : 1), + y: turnNextDirections ? ymid + MIN_SEGMENT_LENGTH : ymid + }; + } else { + turnNextDirections = xmid === a.x; + + segmentEnd = { + x: turnNextDirections ? xmid + MIN_SEGMENT_LENGTH : xmid, + y: a.y + MIN_SEGMENT_LENGTH * (/t/.test(startDirection) ? -1 : 1) + }; + } + + } else { + segmentEnd = { + x: xmid, + y: ymid + }; + } + } + + return { + waypoints: getBendpoints(a, segmentEnd, segmentDirections).concat(segmentEnd), + directions: segmentDirections, + turnNextDirections: turnNextDirections + }; +} + +function getStartSegment(a, b, directions) { + return getSegmentBendpoints(a, b, directions); +} + +function getEndSegment(a, b, directions) { + var invertedSegment = getSegmentBendpoints(b, a, invertDirections(directions)); + + return { + waypoints: invertedSegment.waypoints.slice().reverse(), + directions: invertDirections(invertedSegment.directions), + turnNextDirections: invertedSegment.turnNextDirections + }; +} + +function getMidSegment(startSegment, endSegment) { + + var startDirection = startSegment.directions.split(':')[1], + endDirection = endSegment.directions.split(':')[0]; + + if (startSegment.turnNextDirections) { + startDirection = startDirection == 'h' ? 'v' : 'h'; + } + + if (endSegment.turnNextDirections) { + endDirection = endDirection == 'h' ? 'v' : 'h'; + } + + var directions = startDirection + ':' + endDirection; + + var bendpoints = getBendpoints( + startSegment.waypoints[startSegment.waypoints.length - 1], + endSegment.waypoints[0], + directions + ); + + return { + waypoints: bendpoints, + directions: directions + }; +} + +function invertDirections(directions) { + return directions.split(':').reverse().join(':'); +} + +/** + * Handle simple layouts with maximum two bendpoints. + */ +function getSimpleBendpoints(a, b, directions) { + + var xmid = round((b.x - a.x) / 2 + a.x), + ymid = round((b.y - a.y) / 2 + a.y); + + // one point, right or left from a + if (directions === 'h:v') { + return [ { x: b.x, y: a.y } ]; + } + + // one point, above or below a + if (directions === 'v:h') { + return [ { x: a.x, y: b.y } ]; + } + + // vertical segment between a and b + if (directions === 'h:h') { + return [ + { x: xmid, y: a.y }, + { x: xmid, y: b.y } + ]; + } + + // horizontal segment between a and b + if (directions === 'v:v') { + return [ + { x: a.x, y: ymid }, + { x: b.x, y: ymid } + ]; + } + + throw new Error('invalid directions: can only handle varians of [hv]:[hv]'); +} + /** * Returns the mid points for a manhattan connection between two points. * - * @example + * @example h:h (horizontal:horizontal) * * [a]----[x] * | * [x]----[b] * - * @example + * @example h:v (horizontal:vertical) * * [a]----[x] * | * [b] * + * @example h:r (horizontal:right) + * + * [a]----[x] + * | + * [b]-[x] + * * @param {Point} a * @param {Point} b * @param {String} directions * * @return {Array} */ -export function getBendpoints(a, b, directions) { - +function getBendpoints(a, b, directions) { directions = directions || 'h:h'; - var xmid, ymid; - - // one point, next to a - if (directions === 'h:v') { - return [ { x: b.x, y: a.y } ]; - } else - // one point, above a - if (directions === 'v:h') { - return [ { x: a.x, y: b.y } ]; - } else - // vertical edge xmid - if (directions === 'h:h') { - xmid = Math.round((b.x - a.x) / 2 + a.x); - - return [ - { x: xmid, y: a.y }, - { x: xmid, y: b.y } - ]; - } else - // horizontal edge ymid - if (directions === 'v:v') { - ymid = Math.round((b.y - a.y) / 2 + a.y); - - return [ - { x: a.x, y: ymid }, - { x: b.x, y: ymid } - ]; - } else { + if (!isValidDirections(directions)) { throw new Error( 'unknown directions: <' + directions + '>: ' + - 'directions must be specified as {a direction}:{b direction} (direction in h|v)'); + 'must be specified as : ' + + 'with start/end in { h,v,t,r,b,l }' + ); } -} + // compute explicit directions, involving trbl dockings + // using a three segmented layouting algorithm + if (isExplicitDirections(directions)) { + var startSegment = getStartSegment(a, b, directions), + endSegment = getEndSegment(a, b, directions), + midSegment = getMidSegment(startSegment, endSegment); + + return [].concat( + startSegment.waypoints, + midSegment.waypoints, + endSegment.waypoints + ); + } + + // handle simple [hv]:[hv] cases that can be easily computed + return getSimpleBendpoints(a, b, directions); +} /** * Create a connection between the two points according @@ -99,16 +263,12 @@ export function getBendpoints(a, b, directions) { */ export function connectPoints(a, b, directions) { - var points = []; - - if (!pointsAligned(a, b)) { - points = getBendpoints(a, b, directions); - } + var points = getBendpoints(a, b, directions); points.unshift(a); points.push(b); - return points; + return withoutRedundantPoints(points); } @@ -143,48 +303,15 @@ export function connectRectangles(source, target, start, end, hints) { start = start || getMid(source); end = end || getMid(target); - // overlapping elements - if (!directions) { - return; - } - - if (directions === 'h:h') { - - switch (orientation) { - case 'top-right': - case 'right': - case 'bottom-right': - start = { original: start, x: source.x, y: start.y }; - end = { original: end, x: target.x + target.width, y: end.y }; - break; - case 'top-left': - case 'left': - case 'bottom-left': - start = { original: start, x: source.x + source.width, y: start.y }; - end = { original: end, x: target.x, y: end.y }; - break; - } - } - - if (directions === 'v:v') { + var directionSplit = directions.split(':'); - switch (orientation) { - case 'top-left': - case 'top': - case 'top-right': - start = { original: start, x: start.x, y: source.y + source.height }; - end = { original: end, x: end.x, y: target.y }; - break; - case 'bottom-left': - case 'bottom': - case 'bottom-right': - start = { original: start, x: start.x, y: source.y }; - end = { original: end, x: end.x, y: target.y + target.height }; - break; - } - } + // compute actual docking points for start / end + // this ensures we properly layout only parts of the + // connection that lies in between the two rectangles + var startDocking = getDockingPoint(start, source, directionSplit[0], invertOrientation(orientation)), + endDocking = getDockingPoint(end, target, directionSplit[1], orientation); - return connectPoints(start, end, directions); + return connectPoints(startDocking, endDocking, directions); } @@ -283,8 +410,8 @@ export function layoutStraight(source, target, start, end, hints) { orientation = getOrientation(source, target); - // We're only interested in layouting a straight connection - // if the shapes are horizontally or vertically aligned + // only layout a straight connection if shapes are + // horizontally or vertically aligned if (!/^(top|bottom|left|right)$/.test(orientation)) { return null; } @@ -448,9 +575,13 @@ export function _repairConnectionSide(moved, other, newDocking, points) { * Returns the manhattan directions connecting two rectangles * with the given orientation. * + * Will always return the default layout, if it is specific + * regarding sides already (trbl). + * * @example * * getDirections('top'); // -> 'v:v' + * getDirections('intersect'); // -> 't:t' * * getDirections('top-right', 'v:h'); // -> 'v:h' * getDirections('top-right', 'h:h'); // -> 'h:h' @@ -463,9 +594,14 @@ export function _repairConnectionSide(moved, other, newDocking, points) { */ function getDirections(orientation, defaultLayout) { + // don't override specific trbl directions + if (isExplicitDirections(defaultLayout)) { + return defaultLayout; + } + switch (orientation) { case 'intersect': - return null; + return 't:t'; case 'top': case 'bottom': @@ -483,3 +619,92 @@ function getDirections(orientation, defaultLayout) { return defaultLayout; } } + +function isValidDirections(directions) { + return directions && /^h|v|t|r|b|l:h|v|t|r|b|l$/.test(directions); +} + +function isExplicitDirections(directions) { + return directions && /t|r|b|l/.test(directions); +} + +function invertOrientation(orientation) { + return { + 'top': 'bottom', + 'bottom': 'top', + 'left': 'right', + 'right': 'left', + 'top-left': 'bottom-right', + 'bottom-right': 'top-left', + 'top-right': 'bottom-left', + 'bottom-left': 'top-right', + }[orientation]; +} + +function getDockingPoint(point, rectangle, dockingDirection, targetOrientation) { + + // ensure we end up with a specific docking direction + // based on the targetOrientation, if is being passed + + if (dockingDirection === 'h') { + dockingDirection = /left/.test(targetOrientation) ? 'l' : 'r'; + } + + if (dockingDirection === 'v') { + dockingDirection = /top/.test(targetOrientation) ? 't' : 'b'; + } + + if (dockingDirection === 't') { + return { original: point, x: point.x, y: rectangle.y }; + } + + if (dockingDirection === 'r') { + return { original: point, x: rectangle.x + rectangle.width, y: point.y }; + } + + if (dockingDirection === 'b') { + return { original: point, x: point.x, y: rectangle.y + rectangle.height }; + } + + if (dockingDirection === 'l') { + return { original: point, x: rectangle.x, y: point.y }; + } + + throw new Error('unexpected dockingDirection: <' + dockingDirection + '>'); +} + + +/** + * Return list of waypoints with redundant ones filtered out. + * + * @example + * + * Original points: + * + * [x] ----- [x] ------ [x] + * | + * [x] ----- [x] - [x] + * + * Filtered: + * + * [x] ---------------- [x] + * | + * [x] ----------- [x] + * + * @param {Array} waypoints + * + * @return {Array} + */ +export function withoutRedundantPoints(waypoints) { + return waypoints.reduce(function(points, p, idx) { + + var previous = points[points.length - 1], + next = waypoints[idx + 1]; + + if (!pointsOnLine(previous, next, p, 0)) { + points.push(p); + } + + return points; + }, []); +} \ No newline at end of file diff --git a/test/spec/layout/ManhattanLayoutSpec.js b/test/spec/layout/ManhattanLayoutSpec.js index d899ed9bd..34117c2f8 100755 --- a/test/spec/layout/ManhattanLayoutSpec.js +++ b/test/spec/layout/ManhattanLayoutSpec.js @@ -23,6 +23,14 @@ function expectConnection(connection, expected) { } } +function expectConnectionWaypoints(a, b, directions, waypoints) { + return function() { + var connection = connectPoints(a, b, directions); + + // expect + expectConnection(connection, waypoints); + }; +} describe('layout/ManhattanLayout', function() { @@ -32,43 +40,155 @@ describe('layout/ManhattanLayout', function() { b = point(200, 200); - it('should create h:v manhattan connection', function() { + /** + * x----x + * | + * | + * x + */ + describe('manhattan connection with 3 waypoints', function() { - // when - var connection = connectPoints(a, b, 'h:v'); + var waypoints = [ a, point(200, 100), b ]; - // expect - expectConnection(connection, [ a, point(200, 100), b ]); + + it('h:v', expectConnectionWaypoints(a, b, 'h:v', waypoints)); + it('r:v', expectConnectionWaypoints(a, b, 'r:v', waypoints)); + it('r:t', expectConnectionWaypoints(a, b, 'r:t', waypoints)); + it('h:t', expectConnectionWaypoints(a, b, 'h:t', waypoints)); }); - it('should create v:h manhattan connection', function() { + /** + * x + * | + * | + * x----x + */ + describe('manhattan connection with 3 waypoints', function() { - // when - var connection = connectPoints(a, b, 'v:h'); + var waypoints = [ a, point(100, 200), b ]; - // expect - expectConnection(connection, [ a, point(100, 200), b ]); + + it('v:h', expectConnectionWaypoints(a, b, 'v:h', waypoints)); + it('b:h', expectConnectionWaypoints(a, b, 'b:h', waypoints)); + it('b:l', expectConnectionWaypoints(a, b, 'b:l', waypoints)); + it('v:l', expectConnectionWaypoints(a, b, 'v:l', waypoints)); }); - it('should create h:h manhattan connection', function() { + /** + * x--x + * | + * | + * x--x + */ + describe('manhattan connection with 4 waypoints', function() { - // when - var connection = connectPoints(a, b, 'h:h'); + var waypoints = [ a, point(150, 100), point(150, 200), b ]; - // expect - expectConnection(connection, [ a, point(150, 100), point(150, 200), b ]); + + it('h:h', expectConnectionWaypoints(a, b, 'h:h', waypoints)); + it('r:h', expectConnectionWaypoints(a, b, 'r:h', waypoints)); + it('r:l', expectConnectionWaypoints(a, b, 'r:l', waypoints)); + it('h:l', expectConnectionWaypoints(a, b, 'h:l', waypoints)); }); - it('should create v:v manhattan connection', function() { + /** + * x----x + * | + * | + * x-x + */ + describe('manhattan connection with 4 waypoints', function() { - // when - var connection = connectPoints(a, b, 'v:v'); + var waypoints = [ a, point(220, 100), point(220, 200), b ]; - // expect - expectConnection(connection, [ a, point(100, 150), point(200, 150), b ]); + + it('h:r', expectConnectionWaypoints(a, b, 'h:r', waypoints)); + it('r:r', expectConnectionWaypoints(a, b, 'r:r', waypoints)); + }); + + + /** + * x-x + * | + * | + * x----x + */ + describe('manhattan connection with 4 waypoints', function() { + + var waypoints = [ a, point(80, 100), point(80, 200), b ]; + + + it('l:h', expectConnectionWaypoints(a, b, 'l:h', waypoints)); + it('l:l', expectConnectionWaypoints(a, b, 'l:l', waypoints)); + }); + + + /** + * x--x + * | | + * x | + * | + * x--x + */ + describe('manhattan connection with 5 waypoints', function() { + + var waypoints = [ a, point(100, 80), point(150, 80), point(150, 200), b ]; + + + it('t:h', expectConnectionWaypoints(a, b, 't:h', waypoints)); + it('t:l', expectConnectionWaypoints(a, b, 't:l', waypoints)); + }); + + + /** + * x--x + * | + * | x + * | | + * x--x + */ + describe('manhattan connection with 5 waypoints', function() { + + var waypoints = [ a, point(150, 100), point(150, 220), point(200, 220), b ]; + + + it('h:b', expectConnectionWaypoints(a, b, 'h:b', waypoints)); + it('r:b', expectConnectionWaypoints(a, b, 'r:b', waypoints)); + }); + + + /** + * x-x + * | + * x----x + * | + * x-x + */ + describe('manhattan connection with 6 waypoints', function() { + + var waypoints = [ a, point(80, 100), point(80, 150), point(220, 150), point(220, 200), b ]; + + + it('l:r', expectConnectionWaypoints(a, b, 'l:r', waypoints)); + }); + + + /** + * x--x + * | | + * x | x + * | | + * x--x + */ + describe('manhattan connection with 6 waypoints', function() { + + var waypoints = [ a, point(100, 80), point(150, 80), point(150, 220), point(200, 220), b ]; + + + it('t:b', expectConnectionWaypoints(a, b, 't:b', waypoints)); }); @@ -273,7 +393,7 @@ describe('layout/ManhattanLayout', function() { }); - it('should connect v:h outside tolerance (preferred v:h)', function() { + it('should connect v:h outside tolerance (preferred = v:h)', function() { // given var end = rect(230, 0, 50, 50); @@ -284,16 +404,16 @@ describe('layout/ManhattanLayout', function() { }); // expect - // layouted h:h + // layouted v:h expectConnection(connection, [ - { x: 150, y: 150 }, + { original: { x: 150, y: 150 }, x: 150, y: 100 }, { x: 150, y: 25 }, - { x: 255, y: 25 } + { original: { x: 255, y: 25 }, x: 230, y: 25 } ]); }); - it('should connect h:v outside tolerance (preferred h:v)', function() { + it('should connect h:v outside tolerance (preferred = h:v)', function() { // given var end = rect(230, 0, 50, 50); @@ -304,16 +424,75 @@ describe('layout/ManhattanLayout', function() { }); // expect - // layouted h:h + // layouted h:v expectConnection(connection, [ - { x: 150, y: 150 }, + { original: { x: 150, y: 150 }, x: 200, y: 150 }, { x: 255, y: 150 }, - { x: 255, y: 25 } + { original: { x: 255, y: 25 }, x: 255, y: 50 } ]); }); }); + + it('should connect top (directions = t:t)', function() { + + // given + var end = rect(200, 100, 50, 50); + + // when + var connection = connectRectangles(start, end, null, null, { + preferredLayouts: [ 't:t' ] + }); + + + // expect + expectConnection(connection, [ + { original: { x: 150, y: 150 }, x: 150, y: 100 }, + { x: 150, y: 80 }, + { x: 225, y: 80 }, + { original: { x: 225, y: 125 }, x: 225, y: 100 } + ]); + }); + + + it('should connect top (directions = r:r)', function() { + + // given + var end = rect(200, 100, 50, 50); + + // when + var connection = connectRectangles(start, end, null, null, { + preferredLayouts: [ 'r:r' ] + }); + + // expect + expectConnection(connection, [ + { original: { x: 150, y: 150 }, x: 200, y: 150 }, + { x: 270, y: 150 }, + { x: 270, y: 125 }, + { original: { x: 225, y: 125 }, x: 250, y: 125 } + ]); + }); + + + it('should connection t:t by default when intersecting', function() { + + // given + var end = rect(150, 100, 100, 100); + + // when + var connection = connectRectangles(start, end); + + // expect + expectConnection(connection, [ + { original: { x: 150, y: 150 }, x: 150, y: 100 }, + { x: 150, y: 80 }, + { x: 200, y: 80 }, + { original: { x: 200, y: 150 }, x: 200, y: 100 } + ]); + }); + });