diff --git a/lib/features/auto-place/AutoPlace.js b/lib/features/auto-place/AutoPlace.js new file mode 100644 index 000000000..7e614f8f0 --- /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} + */ +export 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/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); + }); + + }); + + }); + + }); + +});