diff --git a/lib/features/auto-place/AutoPlace.js b/lib/features/auto-place/AutoPlace.js new file mode 100644 index 000000000..2dcd46933 --- /dev/null +++ b/lib/features/auto-place/AutoPlace.js @@ -0,0 +1,93 @@ +import { + asTRBL, + getMid +} from '../../layout/LayoutUtil'; + +import { DEFAULT_DISTANCE } from './AutoPlaceUtil'; + +var LOW_PRIORITY = 100; + + +/** + * A service that places elements connected to existing ones + * to an appropriate position in an _automated_ fashion. + * + * @param {EventBus} eventBus + * @param {Modeling} modeling + */ +export default function AutoPlace(eventBus, modeling) { + + eventBus.on('autoPlace', LOW_PRIORITY, function(context) { + var shape = context.shape, + source = context.source; + + return getNewShapePosition(source, shape); + }); + + /** + * Append shape to source at appropriate position. + * + * @param {djs.model.Shape} source + * @param {djs.model.Shape} shape + * + * @return {djs.model.Shape} appended shape + */ + this.append = function(source, shape, hints) { + + eventBus.fire('autoPlace.start', { + source: source, + shape: shape + }); + + // allow others to provide the position + var position = eventBus.fire('autoPlace', { + source: source, + shape: shape + }); + + var newShape = modeling.appendShape(source, shape, position, source.parent, hints); + + eventBus.fire('autoPlace.end', { + source: source, + shape: newShape + }); + + return newShape; + }; + +} + +AutoPlace.$inject = [ + 'eventBus', + 'modeling' +]; + +// helpers ////////// + +/** + * Find the new position for the target element to + * connect to source. + * + * @param {djs.model.Shape} source + * @param {djs.model.Shape} element + * @param {Object} [hints] + * @param {Object} [hints.defaultDistance] + * + * @returns {Point} + */ +function getNewShapePosition(source, element, hints) { + if (!hints) { + hints = {}; + } + + var distance = hints.defaultDistance || DEFAULT_DISTANCE; + + var sourceMid = getMid(source), + sourceTrbl = asTRBL(source); + + // simply put element right next to source + return { + x: sourceTrbl.right + distance + element.width / 2, + y: sourceMid.y + }; +} \ No newline at end of file diff --git a/lib/features/auto-place/AutoPlaceSelectionBehavior.js b/lib/features/auto-place/AutoPlaceSelectionBehavior.js new file mode 100644 index 000000000..6024694c4 --- /dev/null +++ b/lib/features/auto-place/AutoPlaceSelectionBehavior.js @@ -0,0 +1,18 @@ +/** + * Select element after auto placement. + * + * @param {EventBus} eventBus + * @param {Selection} selection + */ +export default function AutoPlaceSelectionBehavior(eventBus, selection) { + + eventBus.on('autoPlace.end', 500, function(e) { + selection.select(e.shape); + }); + +} + +AutoPlaceSelectionBehavior.$inject = [ + 'eventBus', + 'selection' +]; \ No newline at end of file diff --git a/lib/features/auto-place/AutoPlaceUtil.js b/lib/features/auto-place/AutoPlaceUtil.js new file mode 100644 index 000000000..5f1d583bb --- /dev/null +++ b/lib/features/auto-place/AutoPlaceUtil.js @@ -0,0 +1,301 @@ +import { + asTRBL, + getOrientation +} from '../../layout/LayoutUtil'; + +import { + find, + reduce, + isObject +} from 'min-dash'; + +// padding to detect element placement +var PLACEMENT_DETECTION_PAD = 10; + +export var DEFAULT_DISTANCE = 50; + +var MAX_DISTANCE = 250; + +/** + * Returns a new, position for the given element + * based on the given element that is not occupied + * by some element connected to source. + * + * Take into account the escapeDirection (where to move + * on positioning clashes) in the computation. + * + * @param {djs.model.Shape} source + * @param {djs.model.Shape} element + * @param {Point} position + * @param {Object} escapeDelta + * + * @return {Point} + */ +export function deconflictPosition(source, element, position, escapeDelta) { + + function nextPosition(existingElement) { + + var newPosition = { + x: position.x, + y: position.y + }; + + [ 'x', 'y' ].forEach(function(axis) { + + var axisDelta = escapeDelta[axis]; + + if (!axisDelta) { + return; + } + + var dimension = axis === 'x' ? 'width' : 'height'; + + var margin = axisDelta.margin, + rowSize = axisDelta.rowSize; + + if (margin < 0) { + newPosition[axis] = Math.min( + existingElement[axis] + margin - element[dimension] / 2, + position[axis] - rowSize + margin + ); + } else { + newPosition[axis] = Math.max( + existingTarget[axis] + existingTarget[dimension] + margin + element[dimension] / 2, + position[axis] + rowSize + margin + ); + } + }); + + return newPosition; + } + + var existingTarget; + + // deconflict position until free slot is found + while ((existingTarget = getConnectedAtPosition(source, position, element))) { + position = nextPosition(existingTarget); + } + + return position; +} + +/** + * Return target at given position, if defined. + * + * This takes connected elements from host and attachers + * into account, too. + */ +export function getConnectedAtPosition(source, position, element) { + + var bounds = { + x: position.x - (element.width / 2), + y: position.y - (element.height / 2), + width: element.width, + height: element.height + }; + + var closure = getAutoPlaceClosure(source, element); + + return find(closure, function(target) { + + if (target === element) { + return false; + } + + var orientation = getOrientation(target, bounds, PLACEMENT_DETECTION_PAD); + + return orientation === 'intersect'; + }); +} + +/** +* Compute best distance between source and target, +* based on existing connections to and from source. +* +* @param {djs.model.Shape} source +* @param {string} axis +* @param {Function} [filter] +* @param {Object} [hints] +* @param {djs.model.Shape} [hints.connectionTarget] +* @param {djs.model.Shape} [hints.maxDistance] +* +* @return {Number} distance +*/ +export function getConnectedDistance(source, axis, filter, hints) { + if (!filter) { + filter = noneFilter; + } + + if (isObject(filter)) { + hints = filter; + filter = noneFilter; + } + + if (!hints) { + hints = {}; + } + + var maxDistance = hints.maxDistance || MAX_DISTANCE; + + var connectionTargetIsSource = hints.connectionTarget === source; + + var sourceTrbl = asTRBL(source); + + function toTargetNode(weight) { + + return function(shape) { + return { + shape: shape, + weight: weight, + distanceTo: function(shape) { + var shapeTrbl = asTRBL(shape); + + if (axis === 'x') { + return shapeTrbl.left - sourceTrbl.right; + } else { + return shapeTrbl.top - sourceTrbl.bottom; + } + } + }; + }; + } + + function toSourceNode(weight) { + return function(shape) { + return { + shape: shape, + weight: weight, + distanceTo: function(shape) { + var shapeTrbl = asTRBL(shape); + + if (axis === 'x') { + return sourceTrbl.left - shapeTrbl.right; + } else { + return sourceTrbl.top - shapeTrbl.bottom; + } + } + }; + }; + } + + // we create a list of nodes to take into consideration + // for calculating the optimal flow node distance + // + // * weight existing target nodes higher than source nodes unless otherwise indicated + // * only take into account individual nodes once + // + var nodes = reduce([].concat( + getTargets(source, filter).map(connectionTargetIsSource ? toSourceNode(5) : toTargetNode(5)), + getSources(source, filter).map(connectionTargetIsSource ? toTargetNode(1) : toSourceNode(1)) + ), function(nodes, node) { + + // filter out shapes connected twice via source or target + nodes[node.shape.id + '__weight_' + node.weight] = node; + + return nodes; + }, {}); + + // compute distances between source and incoming nodes; + // group at the same time by distance and expose the + // favourite distance as { fav: { count, value } }. + var distancesGrouped = reduce(nodes, function(result, node) { + var shape = node.shape, + weight = node.weight, + distanceTo = node.distanceTo; + + var fav = result.fav, + currentDistance, + currentDistanceCount, + currentDistanceEntry; + + currentDistance = distanceTo(shape); + + // ignore too far away peers + if (currentDistance < 0 || currentDistance > maxDistance) { + return result; + } + + currentDistanceEntry = result[String(currentDistance)] = + result[String(currentDistance)] || { + value: currentDistance, + count: 0 + }; + + // inc diff count + currentDistanceCount = currentDistanceEntry.count += 1 * weight; + + if (!fav || fav.count < currentDistanceCount) { + result.fav = currentDistanceEntry; + } + + return result; + }, { }); + + if (distancesGrouped.fav) { + return distancesGrouped.fav.value; + } else { + return hints.defaultDistance || DEFAULT_DISTANCE; + } +} + +/** + * Returns all connected elements around the given source. + * + * This includes: + * + * - connected elements + * - host connected elements + * - attachers connected elements + * + * @param {djs.model.Shape} source + * @param {djs.model.Shape} element + * + * @return {Array} + */ +function getAutoPlaceClosure(source, element) { + + var allConnected = getConnected(source); + + if (source.host) { + allConnected = allConnected.concat(getConnected(source.host)); + } + + if (source.attachers) { + allConnected = allConnected.concat(source.attachers.reduce(function(shapes, attacher) { + return shapes.concat(getConnected(attacher)); + }, [])); + } + + return allConnected; +} + +function getConnected(element, connectionFilter) { + return [].concat( + getTargets(element, connectionFilter), + getSources(element, connectionFilter) + ); +} + +function getSources(shape, connectionFilter) { + if (!connectionFilter) { + connectionFilter = noneFilter; + } + + return shape.incoming.filter(connectionFilter).map(function(c) { + return c.source; + }); +} + +function getTargets(shape, connectionFilter) { + if (!connectionFilter) { + connectionFilter = noneFilter; + } + + return shape.outgoing.filter(connectionFilter).map(function(c) { + return c.target; + }); +} + +function noneFilter() { + return true; +} \ No newline at end of file diff --git a/lib/features/auto-place/index.js b/lib/features/auto-place/index.js new file mode 100644 index 000000000..cf9a7a0b0 --- /dev/null +++ b/lib/features/auto-place/index.js @@ -0,0 +1,8 @@ +import AutoPlace from './AutoPlace'; +import AutoPlaceSelectionBehavior from './AutoPlaceSelectionBehavior'; + +export default { + __init__: [ 'autoPlaceSelectionBehavior' ], + autoPlace: [ 'type', AutoPlace ], + autoPlaceSelectionBehavior: [ 'type', AutoPlaceSelectionBehavior ] +}; \ No newline at end of file diff --git a/lib/features/create/Create.js b/lib/features/create/Create.js index f927d0463..d819ee69e 100644 --- a/lib/features/create/Create.js +++ b/lib/features/create/Create.js @@ -49,7 +49,7 @@ export default function Create( * * @returns {boolean|null|Object} */ - function canCreate(elements, target, position, source) { + function canCreate(elements, target, position, source, hints) { if (!target) { return false; } @@ -98,13 +98,14 @@ export default function Create( } + var connectionTarget = hints.connectionTarget; + // (3) appending single shapes if (create || attach) { - if (shape && source) { connect = rules.allowed('connection.create', { - source: source, - target: shape, + source: connectionTarget === source ? shape : source, + target: connectionTarget === source ? source : shape, hints: { targetParent: target, targetAttach: attach @@ -143,7 +144,8 @@ export default function Create( var context = event.context, elements = context.elements, hover = event.hover, - source = context.source; + source = context.source, + hints = context.hints || {}; if (!hover) { context.canExecute = false; @@ -159,7 +161,7 @@ export default function Create( y: event.y }; - var canExecute = context.canExecute = hover && canCreate(elements, hover, position, source); + var canExecute = context.canExecute = hover && canCreate(elements, hover, position, source, hints); if (hover && canExecute !== null) { context.target = hover; @@ -186,10 +188,10 @@ export default function Create( shape = context.shape, elements = context.elements, target = context.target, - hints = context.hints, canExecute = context.canExecute, attach = canExecute && canExecute.attach, - connect = canExecute && canExecute.connect; + connect = canExecute && canExecute.connect, + hints = context.hints || {}; if (canExecute === false || !target) { return false; @@ -205,7 +207,8 @@ export default function Create( if (connect) { shape = modeling.appendShape(source, shape, position, target, { attach: attach, - connection: connect === true ? {} : connect + connection: connect === true ? {} : connect, + connectionTarget: hints.connectionTarget }); } else { elements = modeling.createElements(elements, position, target, assign({}, hints, { diff --git a/lib/features/modeling/Modeling.js b/lib/features/modeling/Modeling.js index b80ace1b0..e59b067a8 100644 --- a/lib/features/modeling/Modeling.js +++ b/lib/features/modeling/Modeling.js @@ -365,7 +365,7 @@ Modeling.prototype.appendShape = function(source, shape, position, target, hints shape: shape, connection: hints.connection, connectionParent: hints.connectionParent, - attach: hints.attach + hints: hints }; this._commandStack.execute('shape.append', context); diff --git a/lib/features/modeling/cmd/AppendShapeHandler.js b/lib/features/modeling/cmd/AppendShapeHandler.js index 65b7bcf9f..e82fafedf 100644 --- a/lib/features/modeling/cmd/AppendShapeHandler.js +++ b/lib/features/modeling/cmd/AppendShapeHandler.js @@ -37,24 +37,30 @@ AppendShapeHandler.prototype.preExecute = function(context) { } var target = context.target || source.parent, - shape = context.shape; + shape = context.shape, + hints = context.hints || {}; shape = context.shape = this._modeling.createShape( shape, context.position, - target, { attach: context.attach }); + target, { attach: hints.attach }); context.shape = shape; }; AppendShapeHandler.prototype.postExecute = function(context) { - var parent = context.connectionParent || context.shape.parent; + var parent = context.connectionParent || context.shape.parent, + hints = context.hints || {}; if (!existsConnection(context.source, context.shape)) { // create connection - this._modeling.connect(context.source, context.shape, context.connection, parent); + if (hints.connectionTarget === context.source) { + this._modeling.connect(context.shape, context.source, context.connection, parent); + } else { + this._modeling.connect(context.source, context.shape, context.connection, parent); + } } }; diff --git a/test/spec/features/auto-place/AutoPlaceSpec.js b/test/spec/features/auto-place/AutoPlaceSpec.js new file mode 100644 index 000000000..a366685d6 --- /dev/null +++ b/test/spec/features/auto-place/AutoPlaceSpec.js @@ -0,0 +1,390 @@ +import { + bootstrapDiagram, + inject +} from 'test/TestHelper'; + +import autoPlaceModule from 'lib/features/auto-place'; +import coreModule from 'lib/core'; +import modelingModule from 'lib/features/modeling'; +import selectionModule from 'lib/features/selection'; + +import { getMid } from 'lib/layout/LayoutUtil'; + +import { + deconflictPosition, + getConnectedAtPosition, + getConnectedDistance +} from 'lib/features/auto-place/AutoPlaceUtil'; + +import { DEFAULT_DISTANCE } from 'lib/features/auto-place/AutoPlaceUtil'; + + +describe('features/auto-place', function() { + + beforeEach(bootstrapDiagram({ + modules: [ + autoPlaceModule, + coreModule, + modelingModule, + selectionModule + ] + })); + + var root, shape, newShape; + + beforeEach(inject(function(canvas, elementFactory) { + root = elementFactory.createRoot({ + id: 'root' + }); + + canvas.setRootElement(root); + + shape = elementFactory.createShape({ + id: 'shape', + x: 0, + y: 0, + width: 100, + height: 100 + }); + + canvas.addShape(shape, root); + + newShape = elementFactory.createShape({ + id: 'newShape', + width: 100, + height: 100 + }); + })); + + + describe('element placement', function() { + + it('at default distance', inject(function(autoPlace) { + + // when + autoPlace.append(shape, newShape); + + // then + expect(newShape).to.have.bounds({ + x: 150, + y: 0, + width: 100, + height: 100 + }); + })); + + }); + + + describe('integration', function() { + + it('should select', inject(function(autoPlace, selection) { + + // when + autoPlace.append(shape, newShape); + + // then + expect(selection.get()).to.eql([ newShape ]); + })); + + }); + + + describe('eventbus integration', function() { + + it('', inject(function(autoPlace, eventBus) { + + // given + var listener = sinon.spy(function(event) { + + // then + expect(event.shape).to.equal(newShape); + expect(event.source).to.equal(shape); + }); + + eventBus.on('autoPlace.start', listener); + + // when + autoPlace.append(shape, newShape); + + expect(listener).to.have.been.called; + })); + + + it('', inject(function(autoPlace, eventBus) { + + // given + var listener = sinon.spy(function(event) { + + // then + expect(event.shape).to.equal(newShape); + expect(event.source).to.equal(shape); + + return { + x: 0, + y: 0 + }; + }); + + eventBus.on('autoPlace', listener); + + // when + newShape = autoPlace.append(shape, newShape); + + expect(listener).to.have.been.called; + + expect(getMid(newShape)).to.eql({ + x: 0, + y: 0 + }); + })); + + + it('', inject(function(autoPlace, eventBus) { + + // given + var listener = sinon.spy(function(event) { + + // then + expect(event.shape).to.equal(newShape); + expect(event.source).to.equal(shape); + }); + + eventBus.on('autoPlace.end', listener); + + // when + newShape = autoPlace.append(shape, newShape); + + expect(listener).to.have.been.called; + })); + + }); + + + it('should pass hints', inject(function(autoPlace) { + + // when + autoPlace.append(shape, newShape, { + connectionTarget: shape + }); + + // then + expect(newShape.outgoing).to.have.lengthOf(1); + expect(shape.incoming).to.have.lengthOf(1); + })); + + + describe('util', function() { + + describe('#deconflictPosition', function() { + + it('should not have to deconflict', inject(function(modeling) { + + // given + var position = { + x: 200, + y: 50 + }; + + var escapeDirection = { + y: { + margin: 50, + rowSize: 50 + } + }; + + // when + var deconflictedPosition = deconflictPosition(shape, newShape, position, escapeDirection); + + modeling.appendShape(shape, newShape, deconflictedPosition); + + // then + expect(deconflictedPosition).to.eql(position); + })); + + + it('should deconflict once', inject(function(autoPlace, elementFactory, modeling) { + + // given + var shape1 = elementFactory.createShape({ + id: 'shape1', + width: 100, + height: 100 + }); + + autoPlace.append(shape, shape1); + + var position = { + x: 200, + y: 50 + }; + + var escapeDirection = { + y: { + margin: 50, + rowSize: 50 + } + }; + + // when + var deconflictedPosition = deconflictPosition(shape, newShape, position, escapeDirection); + + modeling.appendShape(shape, newShape, deconflictedPosition); + + // then + expect(deconflictedPosition).to.eql({ + x: 200, + y: 200 + }); + })); + + }); + + + describe('#getConnectedAtPosition', function() { + + it('should get connected at position', inject(function(autoPlace, elementFactory) { + + // given + var shape1 = elementFactory.createShape({ + id: 'shape1', + width: 100, + height: 100 + }); + + autoPlace.append(shape, shape1); + + var position = { + x: 200, + y: 50 + }; + + // when + var connectedAtPosition = getConnectedAtPosition(shape, position, newShape); + + // then + expect(connectedAtPosition).to.equal(shape1); + })); + + }); + + + describe('#getConnectedDistance', function() { + + it('should get default distance', function() { + + // when + var connectedDistance = getConnectedDistance(shape, 'x'); + + // then + expect(connectedDistance).to.equal(DEFAULT_DISTANCE); + }); + + + it('should get connected distance', inject(function(modeling) { + + // given + modeling.appendShape(shape, newShape, { + x: 250, + y: 50 + }); + + // when + var connectedDistance = getConnectedDistance(shape, 'x'); + + // then + expect(connectedDistance).to.equal(100); + })); + + + it('should accept filter', inject(function(modeling) { + + // given + modeling.appendShape(shape, newShape, { + x: 250, + y: 50 + }); + + function filter(connection) { + return connection.target !== newShape; + } + + // when + var connectedDistance = getConnectedDistance(shape, 'x', filter); + + // then + expect(connectedDistance).to.equal(DEFAULT_DISTANCE); + })); + + + it('should accept max distance hint', inject(function(modeling) { + + // given + modeling.appendShape(shape, newShape, { + x: 500, + y: 50 + }); + + // when + var connectedDistance = getConnectedDistance(shape, 'x', null, { + maxDistance: 500 + }); + + // then + expect(connectedDistance).to.equal(350); + })); + + + describe('weighting', function() { + + beforeEach(inject(function(elementFactory, modeling) { + var shape1 = elementFactory.createShape({ + id: 'shape1', + width: 100, + height: 100 + }); + + modeling.createShape(shape1, { + x: 300, + y: 200 + }, root); + + modeling.connect(shape1, shape); + + modeling.createShape(newShape, { + x: 250, + y: 50 + }, root); + + modeling.connect(shape, newShape); + })); + + + it('should weight targets higher than sources by default', function() { + + // when + var connectedDistance = getConnectedDistance(shape, 'x'); + + // then + expect(connectedDistance).to.equal(100); + }); + + + it('should weight sources higher than targets', function() { + + // when + var connectedDistance = getConnectedDistance(shape, 'x', null, { + connectionTarget: shape + }); + + // then + expect(connectedDistance).to.equal(150); + }); + + }); + + }); + + }); + +}); diff --git a/test/spec/features/create/CreateSpec.js b/test/spec/features/create/CreateSpec.js index 3556d82ef..e7ae00f62 100644 --- a/test/spec/features/create/CreateSpec.js +++ b/test/spec/features/create/CreateSpec.js @@ -216,7 +216,7 @@ describe('features/create - Create', function() { )); - it('should append', inject(function(create, dragging, elementRegistry) { + it('should append and connect from source to new shape', inject(function(create, dragging, elementRegistry) { // given var rootGfx = elementRegistry.getGraphics('rootShape'); @@ -247,6 +247,40 @@ describe('features/create - Create', function() { })); + it('should append and connect from new shape to source', inject(function(create, dragging, elementRegistry) { + + // given + var rootGfx = elementRegistry.getGraphics('rootShape'); + + // when + create.start(canvasEvent({ x: 0, y: 0 }), newShape, { + source: childShape, + hints: { + connectionTarget: childShape + } + }); + + dragging.hover({ element: rootShape, gfx: rootGfx }); + + dragging.move(canvasEvent({ x: 500, y: 500 })); + + dragging.end(); + + // then + var createdShape = elementRegistry.get('newShape'); + + expect(createdShape).to.exist; + expect(createdShape).to.equal(newShape); + + expect(createdShape.parent).to.equal(rootShape); + expect(createdShape.outgoing).to.have.length(1); + expect(createdShape.outgoing[0].target).to.equal(childShape); + + expect(childShape.incoming).to.have.length(1); + expect(childShape.incoming[0].source).to.equal(createdShape); + })); + + it('should attach', inject(function(create, dragging, elementRegistry) { // given diff --git a/test/spec/features/create/rules/CreateRules.js b/test/spec/features/create/rules/CreateRules.js index 32285ad23..c2b4fdb62 100755 --- a/test/spec/features/create/rules/CreateRules.js +++ b/test/spec/features/create/rules/CreateRules.js @@ -30,18 +30,14 @@ CreateRules.prototype.init = function() { this.addRule('connection.create', function(context) { var source = context.source, - target = context.target, hints = context.hints; - expect(source.parent).to.exist; - expect(target.parent).not.to.exist; - expect(hints).to.have.keys([ 'targetParent', 'targetAttach' ]); - return /parent|child/.test(source.id); + return /parent|child|newShape/.test(source.id); }); diff --git a/test/spec/features/modeling/AppendShapeSpec.js b/test/spec/features/modeling/AppendShapeSpec.js index 1df708422..cc8550a20 100644 --- a/test/spec/features/modeling/AppendShapeSpec.js +++ b/test/spec/features/modeling/AppendShapeSpec.js @@ -143,6 +143,35 @@ describe('features/modeling - append shape', function() { expect(newConnection.custom).to.be.true; })); + + it('should connect from new shape to source', inject(function(elementRegistry, modeling) { + + // when + var newShape = modeling.appendShape( + childShape, + { id: 'appended', width: 50, height: 50 }, + { x: 200, y: 200 }, + diagramRoot, + { + connectionTarget: childShape + } + ); + + // then + var connection = find(newShape.outgoing, function(c) { + return c.target === childShape; + }); + + // then + expect(connection).to.exist; + expect(connection.parent).to.equal(newShape.parent); + + expect(elementRegistry.getGraphics(connection)).to.exist; + + expect(connection.source).to.equal(newShape); + expect(connection.target).to.equal(childShape); + })); + });