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
This commit is contained in:
Nico Rehwaldt 2017-05-12 16:13:07 +02:00 committed by Philipp Fromme
parent a830c1e1e0
commit 5761e01ffe
4 changed files with 574 additions and 10 deletions

View File

@ -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;
}

View File

@ -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') ]
};

View File

@ -0,0 +1,132 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI" xmlns:omgdc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" id="sid-38422fae-e03e-43a3-bef4-bd33b32041b2" targetNamespace="http://bpmn.io/bpmn" exporter="http://bpmn.io" exporterVersion="0.10.1">
<process id="Process_1" isExecutable="false">
<exclusiveGateway id="LabelBottom" name="BOTTOM" />
<exclusiveGateway id="LabelLeft" name="LEFT" />
<exclusiveGateway id="LabelTop" name="TOP">
<outgoing>SequenceFlow_1</outgoing>
</exclusiveGateway>
<exclusiveGateway id="LabelRight" name="RIGHT">
<outgoing>SequenceFlow_2</outgoing>
</exclusiveGateway>
<exclusiveGateway id="LabelBottomLeft" name="BOTTOM_LEFT" />
<exclusiveGateway id="LabelBottom_2" name="BOTTOM_2">
<incoming>SequenceFlow_2</incoming>
</exclusiveGateway>
<exclusiveGateway id="LabelBottom_3" name="BOTTOM_3">
<incoming>SequenceFlow_1</incoming>
</exclusiveGateway>
<sequenceFlow id="SequenceFlow_1" name="1" sourceRef="LabelTop" targetRef="LabelBottom_3" />
<sequenceFlow id="SequenceFlow_2" name="2" sourceRef="LabelRight" targetRef="LabelBottom_2" />
<exclusiveGateway id="LabelImpossible" name="IMPOSSIBLE">
<incoming>SequenceFlow_1qmllcx</incoming>
<incoming>SequenceFlow_0s993e4</incoming>
<incoming>SequenceFlow_022at7e</incoming>
</exclusiveGateway>
<task id="Task">
<outgoing>SequenceFlow_1qmllcx</outgoing>
<outgoing>SequenceFlow_0s993e4</outgoing>
<outgoing>SequenceFlow_022at7e</outgoing>
</task>
<sequenceFlow id="SequenceFlow_1qmllcx" sourceRef="Task" targetRef="LabelImpossible" />
<sequenceFlow id="SequenceFlow_0s993e4" sourceRef="Task" targetRef="LabelImpossible" />
<sequenceFlow id="SequenceFlow_022at7e" sourceRef="Task" targetRef="LabelImpossible" />
</process>
<bpmndi:BPMNDiagram id="BpmnDiagram_1">
<bpmndi:BPMNPlane id="BpmnPlane_1" bpmnElement="Process_1">
<bpmndi:BPMNShape id="LabelBottom_di" bpmnElement="LabelBottom" isMarkerVisible="true">
<omgdc:Bounds x="113" y="89" width="36" height="36" />
<bpmndi:BPMNLabel>
<omgdc:Bounds x="107" y="129" width="49" height="12" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="LabelLeft_di" bpmnElement="LabelLeft" isMarkerVisible="true">
<omgdc:Bounds x="309" y="82" width="50" height="50" />
<bpmndi:BPMNLabel>
<omgdc:Bounds x="263" y="101" width="30" height="24" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="LabelTop_di" bpmnElement="LabelTop" isMarkerVisible="true">
<omgdc:Bounds x="309" y="225" width="50" height="50" />
<bpmndi:BPMNLabel>
<omgdc:Bounds x="322" y="198" width="24" height="12" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="LabelRight_di" bpmnElement="LabelRight" isMarkerVisible="true">
<omgdc:Bounds x="106" y="225" width="50" height="50" />
<bpmndi:BPMNLabel>
<omgdc:Bounds x="168" y="244" width="37" height="12" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="LabelBottomLeft_di" bpmnElement="LabelBottomLeft" isMarkerVisible="true">
<omgdc:Bounds x="532" y="82" width="50" height="50" />
<bpmndi:BPMNLabel>
<omgdc:Bounds x="487" y="146" width="83" height="12" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="LabelBottom_2_di" bpmnElement="LabelBottom_2" isMarkerVisible="true">
<omgdc:Bounds x="106" y="370" width="50" height="50" />
<bpmndi:BPMNLabel>
<omgdc:Bounds x="101" y="424" width="61" height="12" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="LabelBottom_3_di" bpmnElement="LabelBottom_3" isMarkerVisible="true">
<omgdc:Bounds x="309" y="370" width="50" height="50" />
<bpmndi:BPMNLabel>
<omgdc:Bounds x="304" y="424" width="61" height="12" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="SequenceFlow_1_di" bpmnElement="SequenceFlow_1">
<omgdi:waypoint xsi:type="omgdc:Point" x="334" y="275" />
<omgdi:waypoint xsi:type="omgdc:Point" x="334" y="370" />
<bpmndi:BPMNLabel>
<omgdc:Bounds x="346" y="317" width="7" height="12" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="SequenceFlow_2_di" bpmnElement="SequenceFlow_2">
<omgdi:waypoint xsi:type="omgdc:Point" x="131" y="275" />
<omgdi:waypoint xsi:type="omgdc:Point" x="131" y="370" />
<bpmndi:BPMNLabel>
<omgdc:Bounds x="142" y="317" width="8" height="12" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="LabelImpossible_di" bpmnElement="LabelImpossible" isMarkerVisible="true">
<omgdc:Bounds x="633" y="308" width="50" height="50" />
<bpmndi:BPMNLabel>
<omgdc:Bounds x="694" y="327" width="68" height="12" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_di" bpmnElement="Task">
<omgdc:Bounds x="776" y="293" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="SequenceFlow_1qmllcx_di" bpmnElement="SequenceFlow_1qmllcx">
<omgdi:waypoint xsi:type="omgdc:Point" x="826" y="293" />
<omgdi:waypoint xsi:type="omgdc:Point" x="826" y="202" />
<omgdi:waypoint xsi:type="omgdc:Point" x="658" y="202" />
<omgdi:waypoint xsi:type="omgdc:Point" x="658" y="308" />
<bpmndi:BPMNLabel>
<omgdc:Bounds x="742" y="181" width="0" height="12" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="SequenceFlow_0s993e4_di" bpmnElement="SequenceFlow_0s993e4">
<omgdi:waypoint xsi:type="omgdc:Point" x="826" y="373" />
<omgdi:waypoint xsi:type="omgdc:Point" x="826" y="424" />
<omgdi:waypoint xsi:type="omgdc:Point" x="658" y="424" />
<omgdi:waypoint xsi:type="omgdc:Point" x="658" y="358" />
<bpmndi:BPMNLabel>
<omgdc:Bounds x="742" y="403" width="0" height="12" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="SequenceFlow_022at7e_di" bpmnElement="SequenceFlow_022at7e">
<omgdi:waypoint xsi:type="omgdc:Point" x="845" y="373" />
<omgdi:waypoint xsi:type="omgdc:Point" x="845" y="453" />
<omgdi:waypoint xsi:type="omgdc:Point" x="594" y="453" />
<omgdi:waypoint xsi:type="omgdc:Point" x="594" y="333" />
<omgdi:waypoint xsi:type="omgdc:Point" x="633" y="333" />
<bpmndi:BPMNLabel>
<omgdc:Bounds x="719.5" y="432" width="0" height="12" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</definitions>

View File

@ -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');
}));
});
});