From 5761e01ffed68b4c7f225994da6c950409b47da4 Mon Sep 17 00:00:00 2001 From: Nico Rehwaldt Date: Fri, 12 May 2017 16:13:07 +0200 Subject: [PATCH] feat(modeling): adjust label location based on free space Reacts on connection create, layout, reconnect and waypoint update to find a suitable place for the label and reposition it. Closes #738 --- .../AdaptiveLabelPositioningBehavior.js | 189 ++++++++++++++ lib/features/modeling/behavior/index.js | 22 +- .../AdaptiveLabelPositioningBehavior.bpmn | 132 ++++++++++ .../AdaptiveLabelPositioningBehaviorSpec.js | 241 ++++++++++++++++++ 4 files changed, 574 insertions(+), 10 deletions(-) create mode 100644 lib/features/modeling/behavior/AdaptiveLabelPositioningBehavior.js create mode 100644 test/spec/features/modeling/behavior/AdaptiveLabelPositioningBehavior.bpmn create mode 100644 test/spec/features/modeling/behavior/AdaptiveLabelPositioningBehaviorSpec.js diff --git a/lib/features/modeling/behavior/AdaptiveLabelPositioningBehavior.js b/lib/features/modeling/behavior/AdaptiveLabelPositioningBehavior.js new file mode 100644 index 00000000..c5d4d3ff --- /dev/null +++ b/lib/features/modeling/behavior/AdaptiveLabelPositioningBehavior.js @@ -0,0 +1,189 @@ +'use strict'; + +var inherits = require('inherits'); + +var getOrientation = require('diagram-js/lib/layout/LayoutUtil').getOrientation, + getMid = require('diagram-js/lib/layout/LayoutUtil').getMid, + asTRBL = require('diagram-js/lib/layout/LayoutUtil').asTRBL, + substract = require('diagram-js/lib/util/Math').substract; + +var LabelUtil = require('../../../util/LabelUtil'); + +var hasExternalLabel = LabelUtil.hasExternalLabel; + +var CommandInterceptor = require('diagram-js/lib/command/CommandInterceptor'); + + +/** + * A component that makes sure that external labels are added + * together with respective elements and properly updated (DI wise) + * during move. + * + * @param {EventBus} eventBus + * @param {Modeling} modeling + */ +function AdaptiveLabelPositioningBehavior(eventBus, modeling) { + + CommandInterceptor.call(this, eventBus); + + this.postExecuted([ + 'connection.create', + 'connection.layout', + 'connection.reconnectEnd', + 'connection.reconnectStart', + 'connection.updateWaypoints' + ], function(event) { + + var context = event.context, + connection = context.connection; + + var source = connection.source, + target = connection.target; + + checkLabelAdjustment(source); + checkLabelAdjustment(target); + }); + + + function checkLabelAdjustment(element) { + + // skip hidden or non-existing labels + if (!hasExternalLabel(element)) { + return; + } + + var optimalPosition = getOptimalPosition(element); + + // no optimal position found + if (!optimalPosition) { + return; + } + + adjustLabelPosition(element, optimalPosition); + } + + var ELEMENT_LABEL_DISTANCE = 10; + + function adjustLabelPosition(element, orientation) { + + var elementMid = getMid(element), + label = element.label, + labelMid = getMid(label); + + var elementTrbl = asTRBL(element); + + var newLabelMid; + + switch (orientation) { + case 'top': + newLabelMid = { + x: elementMid.x, + y: elementTrbl.top - ELEMENT_LABEL_DISTANCE - label.height / 2 + }; + + break; + + case 'left': + + newLabelMid = { + x: elementTrbl.left - ELEMENT_LABEL_DISTANCE - label.width / 2, + y: elementMid.y + }; + + break; + + case 'bottom': + + newLabelMid = { + x: elementMid.x, + y: elementTrbl.bottom + ELEMENT_LABEL_DISTANCE + label.height / 2 + }; + + break; + + case 'right': + + newLabelMid = { + x: elementTrbl.right + ELEMENT_LABEL_DISTANCE + label.width / 2, + y: elementMid.y + }; + + break; + } + + + var delta = substract(newLabelMid, labelMid); + + modeling.moveShape(label, delta); + } + +} + + +inherits(AdaptiveLabelPositioningBehavior, CommandInterceptor); + +AdaptiveLabelPositioningBehavior.$inject = [ + 'eventBus', + 'modeling' +]; + +module.exports = AdaptiveLabelPositioningBehavior; + +/** + * Return the optimal label position around an element + * or _undefined_, if none was found. + * + * @param {Shape} element + * + * @return {String} positioning identifier + */ +function getOptimalPosition(element) { + + var labelMid = getMid(element.label); + + var elementMid = getMid(element); + + var labelOrientation = getApproximateOrientation(elementMid, labelMid); + + if (!isAligned(labelOrientation)) { + return; + } + + var takenAlignments = [].concat( + element.incoming.map(function(c) { + return c.waypoints[c.waypoints.length - 2 ]; + }), + element.outgoing.map(function(c) { + return c.waypoints[1]; + }) + ).map(function(point) { + return getApproximateOrientation(elementMid, point); + }); + + var freeAlignments = ALIGNMENTS.filter(function(alignment) { + + return takenAlignments.indexOf(alignment) === -1; + }); + + // NOTHING TO DO; label already aligned a.O.K. + if (freeAlignments.indexOf(labelOrientation) !== -1) { + return; + } + + return freeAlignments[0]; +} + +var ALIGNMENTS = [ + 'top', + 'bottom', + 'left', + 'right' +]; + +function getApproximateOrientation(p0, p1) { + return getOrientation(p1, p0, 5); +} + +function isAligned(orientation) { + return ALIGNMENTS.indexOf(orientation) !== -1; +} diff --git a/lib/features/modeling/behavior/index.js b/lib/features/modeling/behavior/index.js index d1f3c914..c5c3f61f 100644 --- a/lib/features/modeling/behavior/index.js +++ b/lib/features/modeling/behavior/index.js @@ -1,44 +1,46 @@ module.exports = { __init__: [ + 'adaptiveLabelPositioningBehavior', 'appendBehavior', 'copyPasteBehavior', 'createBoundaryEventBehavior', 'createDataObjectBehavior', - 'dropOnFlowBehavior', 'createParticipantBehavior', 'dataInputAssociationBehavior', 'deleteLaneBehavior', + 'dropOnFlowBehavior', 'importDockingFix', 'labelBehavior', 'modelingFeedback', + 'removeElementBehavior', 'removeParticipantBehavior', 'replaceConnectionBehavior', 'replaceElementBehaviour', 'resizeLaneBehavior', - 'unsetDefaultFlowBehavior', - 'updateFlowNodeRefsBehavior', - 'removeElementBehavior', + 'toggleElementCollapseBehaviour', 'unclaimIdBehavior', - 'toggleElementCollapseBehaviour' + 'unsetDefaultFlowBehavior', + 'updateFlowNodeRefsBehavior' ], + adaptiveLabelPositioningBehavior: [ 'type', require('./AdaptiveLabelPositioningBehavior') ], appendBehavior: [ 'type', require('./AppendBehavior') ], copyPasteBehavior: [ 'type', require('./CopyPasteBehavior') ], createBoundaryEventBehavior: [ 'type', require('./CreateBoundaryEventBehavior') ], createDataObjectBehavior: [ 'type', require('./CreateDataObjectBehavior') ], - dropOnFlowBehavior: [ 'type', require('./DropOnFlowBehavior') ], createParticipantBehavior: [ 'type', require('./CreateParticipantBehavior') ], dataInputAssociationBehavior: [ 'type', require('./DataInputAssociationBehavior') ], deleteLaneBehavior: [ 'type', require('./DeleteLaneBehavior') ], + dropOnFlowBehavior: [ 'type', require('./DropOnFlowBehavior') ], importDockingFix: [ 'type', require('./ImportDockingFix') ], labelBehavior: [ 'type', require('./LabelBehavior') ], modelingFeedback: [ 'type', require('./ModelingFeedback') ], - removeParticipantBehavior: [ 'type', require('./RemoveParticipantBehavior') ], replaceConnectionBehavior: [ 'type', require('./ReplaceConnectionBehavior') ], + removeParticipantBehavior: [ 'type', require('./RemoveParticipantBehavior') ], replaceElementBehaviour: [ 'type', require('./ReplaceElementBehaviour') ], resizeLaneBehavior: [ 'type', require('./ResizeLaneBehavior') ], - unsetDefaultFlowBehavior: [ 'type', require('./UnsetDefaultFlowBehavior') ], - updateFlowNodeRefsBehavior: [ 'type', require('./UpdateFlowNodeRefsBehavior') ], removeElementBehavior: [ 'type', require('./RemoveElementBehavior') ], + toggleElementCollapseBehaviour : [ 'type', require('./ToggleElementCollapseBehaviour') ], unclaimIdBehavior: [ 'type', require('./UnclaimIdBehavior') ], - toggleElementCollapseBehaviour : [ 'type', require('./ToggleElementCollapseBehaviour') ] + updateFlowNodeRefsBehavior: [ 'type', require('./UpdateFlowNodeRefsBehavior') ], + unsetDefaultFlowBehavior: [ 'type', require('./UnsetDefaultFlowBehavior') ] }; diff --git a/test/spec/features/modeling/behavior/AdaptiveLabelPositioningBehavior.bpmn b/test/spec/features/modeling/behavior/AdaptiveLabelPositioningBehavior.bpmn new file mode 100644 index 00000000..abec0e11 --- /dev/null +++ b/test/spec/features/modeling/behavior/AdaptiveLabelPositioningBehavior.bpmn @@ -0,0 +1,132 @@ + + + + + + + SequenceFlow_1 + + + SequenceFlow_2 + + + + SequenceFlow_2 + + + SequenceFlow_1 + + + + + SequenceFlow_1qmllcx + SequenceFlow_0s993e4 + SequenceFlow_022at7e + + + SequenceFlow_1qmllcx + SequenceFlow_0s993e4 + SequenceFlow_022at7e + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/spec/features/modeling/behavior/AdaptiveLabelPositioningBehaviorSpec.js b/test/spec/features/modeling/behavior/AdaptiveLabelPositioningBehaviorSpec.js new file mode 100644 index 00000000..aeaa91a0 --- /dev/null +++ b/test/spec/features/modeling/behavior/AdaptiveLabelPositioningBehaviorSpec.js @@ -0,0 +1,241 @@ +'use strict'; + +require('../../../../TestHelper'); + +/* global bootstrapModeler, inject */ + +var getOrientation = require('diagram-js/lib/layout/LayoutUtil').getOrientation; + +var modelingModule = require('../../../../../lib/features/modeling'), + coreModule = require('../../../../../lib/core'); + + +describe('modeling/behavior - AdaptiveLabelPositioningBehavior', function() { + + var diagramXML = require('./AdaptiveLabelPositioningBehavior.bpmn'); + + beforeEach(bootstrapModeler(diagramXML, { + modules: [ + modelingModule, + coreModule + ] + })); + + + function expectLabelOrientation(element, expectedOrientation) { + + var label = element.label; + + // assume + expect(label).to.exist; + + // when + var orientation = getOrientation(label, element); + + // then + expect(orientation).to.eql(expectedOrientation); + } + + + describe('on connect', function() { + + it('should move label from LEFT to TOP', inject(function(elementRegistry, modeling) { + + // given + var source = elementRegistry.get('LabelBottom'), + target = elementRegistry.get('LabelLeft'); + + // when + modeling.connect(source, target); + + // then + expectLabelOrientation(source, 'bottom'); + expectLabelOrientation(target, 'top'); + })); + + + it('should move label from BOTTOM to TOP', inject(function(elementRegistry, modeling) { + + // given + var source = elementRegistry.get('LabelBottom'), + target = elementRegistry.get('LabelRight'); + + // when + modeling.connect(source, target); + + // then + expectLabelOrientation(source, 'top'); + expectLabelOrientation(target, 'right'); + })); + + + it('should move label from RIGHT to TOP', inject(function(elementRegistry, modeling) { + + // given + var source = elementRegistry.get('LabelRight'), + target = elementRegistry.get('LabelTop'); + + // when + modeling.connect(source, target); + + // then + expectLabelOrientation(source, 'top'); + expectLabelOrientation(target, 'top'); + })); + + + it('should move label from TOP to LEFT', inject(function(elementRegistry, modeling) { + + // given + var source = elementRegistry.get('LabelTop'), + target = elementRegistry.get('LabelLeft'); + + // when + modeling.connect(source, target); + + // then + expectLabelOrientation(source, 'left'); + expectLabelOrientation(target, 'left'); + })); + + + it('should move label from TOP to LEFT', inject(function(elementRegistry, modeling) { + + // given + var source = elementRegistry.get('LabelTop'), + target = elementRegistry.get('LabelLeft'); + + // when + modeling.connect(source, target); + + // then + expectLabelOrientation(source, 'left'); + expectLabelOrientation(target, 'left'); + })); + + + it('should move label from TOP to LEFT (inverse)', inject(function(elementRegistry, modeling) { + + // given + var source = elementRegistry.get('LabelLeft'), + target = elementRegistry.get('LabelTop'); + + // when + modeling.connect(source, target); + + // then + expectLabelOrientation(target, 'left'); + expectLabelOrientation(source, 'left'); + })); + + + it('should keep unaligned labels AS IS', inject(function(elementRegistry, modeling) { + + // given + var source = elementRegistry.get('LabelBottomLeft'), + target = elementRegistry.get('LabelTop'); + + // when + modeling.connect(source, target); + + // then + expectLabelOrientation(source, 'bottom'); + expectLabelOrientation(target, 'top'); + })); + + + it('should keep label where it is, if no options', inject(function(elementRegistry, modeling) { + + // given + var source = elementRegistry.get('LabelImpossible'), + target = elementRegistry.get('Task'); + + // when + modeling.connect(source, target); + + // then + expectLabelOrientation(source, 'right'); + })); + + }); + + + describe('on reconnect', function() { + + it('should move label from TOP to BOTTOM', inject(function(elementRegistry, modeling) { + + // given + var connection = elementRegistry.get('SequenceFlow_1'), + source = elementRegistry.get('LabelTop'), + target = elementRegistry.get('LabelLeft'); + + // when + modeling.reconnectEnd(connection, target, { x: target.x + target.width / 2, y: target.y }); + + // then + expectLabelOrientation(source, 'bottom'); + expectLabelOrientation(target, 'left'); + })); + + }); + + + describe('on target move / layout', function() { + + it('should move label from TOP to BOTTOM', inject(function(elementRegistry, modeling) { + + // given + var source = elementRegistry.get('LabelTop'), + target = elementRegistry.get('LabelBottom_3'); + + // when + modeling.moveElements([ source ], { x: 0, y: 300 }); + + // then + expectLabelOrientation(source, 'bottom'); + expectLabelOrientation(target, 'top'); + })); + + }); + + + describe('on waypoints update', function() { + + it('should move label from RIGHT to TOP', inject(function(elementRegistry, modeling) { + + // given + var connection = elementRegistry.get('SequenceFlow_2'), + source = elementRegistry.get('LabelRight'), + target = elementRegistry.get('LabelBottom'); + + // when + modeling.updateWaypoints(connection, [ + { + original: { x: 131, y: 248 }, + x: 131, + y: 248 + }, + { + x: 250, + y: 248 + }, + { + x: 250, + y: 394 + }, + { + original: { x: 131, y: 394 }, + x: 131, + y: 394 + }, + ]); + + // then + expectLabelOrientation(source, 'top'); + expectLabelOrientation(target, 'bottom'); + })); + + }); + + +});