feat(modeling/BpmnLayouter): handle boundary events

This adds proper connection layouting for sequence
flows leaving from boundary events.

If needed, such connections will be layoute with
an U-turn.

Closes #467
This commit is contained in:
Philipp Fromme 2018-07-04 09:13:38 +02:00 committed by Nico Rehwaldt
parent b3c05b6949
commit 220c0a73f3
3 changed files with 316 additions and 200 deletions

View File

@ -7,7 +7,8 @@ import {
import BaseLayouter from 'diagram-js/lib/layout/BaseLayouter'; import BaseLayouter from 'diagram-js/lib/layout/BaseLayouter';
import { import {
repairConnection repairConnection,
withoutRedundantPoints
} from 'diagram-js/lib/layout/ManhattanLayout'; } from 'diagram-js/lib/layout/ManhattanLayout';
import { import {
@ -15,10 +16,6 @@ import {
getOrientation getOrientation
} from 'diagram-js/lib/layout/LayoutUtil'; } from 'diagram-js/lib/layout/LayoutUtil';
import {
pointsOnLine
} from 'diagram-js/lib/util/Geometry';
import { import {
isExpanded isExpanded
} from '../../util/DiUtil'; } from '../../util/DiUtil';
@ -32,7 +29,6 @@ inherits(BpmnLayouter, BaseLayouter);
BpmnLayouter.prototype.layoutConnection = function(connection, hints) { BpmnLayouter.prototype.layoutConnection = function(connection, hints) {
hints = hints || {}; hints = hints || {};
var source = connection.source, var source = connection.source,
@ -107,29 +103,18 @@ BpmnLayouter.prototype.layoutConnection = function(connection, hints) {
// //
// except for // except for
// //
// (1) outgoing of BoundaryEvents -> layout h:v or v:h based on attach orientation // (1) outgoing of BoundaryEvents -> layout based on attach orientation and target orientation
// (2) incoming / outgoing of Gateway -> v:h (outgoing), h:v (incoming) // (2) incoming / outgoing of Gateway -> v:h (outgoing), h:v (incoming)
// //
if (is(connection, 'bpmn:SequenceFlow') || if (is(connection, 'bpmn:SequenceFlow') ||
isCompensationAssociation(connection)) { isCompensationAssociation(connection)) {
// make sure boundary event connections do
// not look ugly =:>
if (is(source, 'bpmn:BoundaryEvent')) { if (is(source, 'bpmn:BoundaryEvent')) {
var orientation = getAttachOrientation(source);
if (/left|right/.test(orientation)) {
manhattanOptions = { manhattanOptions = {
preferredLayouts: [ 'h:v' ] preferredLayouts: getBoundaryEventPreferredLayouts(source, target)
}; };
} else
if (/top|bottom/.test(orientation)) {
manhattanOptions = {
preferredLayouts: [ 'v:h' ]
};
}
} else } else
if (is(source, 'bpmn:Gateway')) { if (is(source, 'bpmn:Gateway')) {
@ -146,7 +131,6 @@ BpmnLayouter.prototype.layoutConnection = function(connection, hints) {
}; };
} }
// apply horizontal love <3
else { else {
manhattanOptions = { manhattanOptions = {
preferredLayouts: [ 'h:h' ] preferredLayouts: [ 'h:h' ]
@ -159,25 +143,14 @@ BpmnLayouter.prototype.layoutConnection = function(connection, hints) {
manhattanOptions = assign(manhattanOptions, hints); manhattanOptions = assign(manhattanOptions, hints);
updatedWaypoints = updatedWaypoints =
withoutRedundantPoints(
repairConnection( repairConnection(
source, target, source, target,
start, end, start, end,
waypoints, waypoints,
manhattanOptions); manhattanOptions
)
// filter un-needed waypoints that may be the result of );
// bundle collapsing
updatedWaypoints = updatedWaypoints && updatedWaypoints.reduce(function(points, p, idx) {
var previous = points[points.length - 1],
next = updatedWaypoints[idx + 1];
if (!pointsOnLine(previous, next, p, 0)) {
points.push(p);
}
return points;
}, []);
} }
return updatedWaypoints || [ start, end ]; return updatedWaypoints || [ start, end ];
@ -211,3 +184,150 @@ function isCompensationAssociation(connection) {
function isExpandedSubProcess(element) { function isExpandedSubProcess(element) {
return is(element, 'bpmn:SubProcess') && isExpanded(element); return is(element, 'bpmn:SubProcess') && isExpanded(element);
} }
function isSame(a, b) {
return a === b;
}
function isAnyOrientation(orientation, orientations) {
return orientations.indexOf(orientation) !== -1;
}
var oppositeOrientationMapping = {
'top': 'bottom',
'top-right': 'bottom-left',
'top-left': 'bottom-right',
'right': 'left',
'bottom': 'top',
'bottom-right': 'top-left',
'bottom-left': 'top-right',
'left': 'right'
};
var orientationDirectionMapping = {
top: 't',
right: 'r',
bottom: 'b',
left: 'l'
};
function getHorizontalOrientation(orientation) {
var matches = /right|left/.exec(orientation);
return matches && matches[0];
}
function getVerticalOrientation(orientation) {
var matches = /top|bottom/.exec(orientation);
return matches && matches[0];
}
function isOppositeOrientation(a, b) {
return oppositeOrientationMapping[a] === b;
}
function isOppositeHorizontalOrientation(a, b) {
var horizontalOrientation = getHorizontalOrientation(a);
var oppositeHorizontalOrientation = oppositeOrientationMapping[horizontalOrientation];
return b.indexOf(oppositeHorizontalOrientation) !== -1;
}
function isOppositeVerticalOrientation(a, b) {
var verticalOrientation = getVerticalOrientation(a);
var oppositeVerticalOrientation = oppositeOrientationMapping[verticalOrientation];
return b.indexOf(oppositeVerticalOrientation) !== -1;
}
function isHorizontalOrientation(orientation) {
return orientation === 'right' || orientation === 'left';
}
function getBoundaryEventPreferredLayouts(source, target) {
var sourceMid = getMid(source),
targetMid = getMid(target),
attachOrientation = getAttachOrientation(source),
sourceLayout,
targetlayout;
var isLoop = isSame(source.host, target);
var attachedToSide = isAnyOrientation(attachOrientation, [ 'top', 'right', 'bottom', 'left' ]);
var isHorizontalAttachOrientation = isHorizontalOrientation(attachOrientation);
var targetOrientation = getOrientation(targetMid, sourceMid, {
x: source.width / 2 + target.width / 2,
y: source.height / 2 + target.height / 2
});
// source layout
// attached to either top, right, bottom or left side
if (attachedToSide) {
sourceLayout = orientationDirectionMapping[
isHorizontalAttachOrientation ?
getHorizontalOrientation(attachOrientation) :
getVerticalOrientation(attachOrientation)
];
} else
// attached to either top-right, top-left, bottom-right or bottom-left corner
{
// loop, same vertical or opposite horizontal orientation
if (isLoop ||
isSame(getVerticalOrientation(attachOrientation), getVerticalOrientation(targetOrientation)) ||
isOppositeOrientation(getHorizontalOrientation(attachOrientation), getHorizontalOrientation(targetOrientation))
) {
sourceLayout = orientationDirectionMapping[getVerticalOrientation(attachOrientation)];
} else {
sourceLayout = orientationDirectionMapping[getHorizontalOrientation(attachOrientation)];
}
}
// target layout
// attached to either top, right, bottom or left side
if (attachedToSide) {
// loop or opposite horizontal/vertical orientation
if (
isLoop ||
(isHorizontalAttachOrientation ?
isOppositeHorizontalOrientation(attachOrientation, targetOrientation) :
isOppositeVerticalOrientation(attachOrientation, targetOrientation))
) {
targetlayout = isHorizontalAttachOrientation ? 'h' : 'v';
} else {
targetlayout = isHorizontalAttachOrientation ? 'v' : 'h';
}
} else
// attached to either top-right, top-left, bottom-right or bottom-left corner
{
// orientation is 'right', 'left'
// or same vertical orientation but also 'right' or 'left'
if (
isHorizontalOrientation(targetOrientation) ||
(isSame(getVerticalOrientation(attachOrientation), getVerticalOrientation(targetOrientation)) &&
getHorizontalOrientation(targetOrientation))
) {
targetlayout = 'h';
} else {
targetlayout = 'v';
}
}
return [ sourceLayout + ':' + targetlayout ];
}

View File

@ -1,64 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" id="Definitions_1" targetNamespace="http://bpmn.io/schema/bpmn"> <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" id="Definitions_1" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="1.16.0">
<bpmn:process id="Process_1" isExecutable="false"> <bpmn:process id="Process_1" isExecutable="false">
<bpmn:task id="Task_Left" />
<bpmn:subProcess id="SubProcess" /> <bpmn:subProcess id="SubProcess" />
<bpmn:boundaryEvent id="BoundaryEvent_D" name="D" attachedToRef="SubProcess" /> <bpmn:task id="Task_Right" />
<bpmn:boundaryEvent id="BoundaryEvent_C" name="C" attachedToRef="SubProcess" /> <bpmn:boundaryEvent id="BoundaryEvent_TopLeft" attachedToRef="SubProcess" />
<bpmn:boundaryEvent id="BoundaryEvent_B" name="B" attachedToRef="SubProcess" /> <bpmn:boundaryEvent id="BoundaryEvent_BottomRight" attachedToRef="SubProcess" />
<bpmn:boundaryEvent id="BoundaryEvent_A" name="A" attachedToRef="SubProcess" /> <bpmn:boundaryEvent id="BoundaryEvent_BottomLeft" attachedToRef="SubProcess" />
<bpmn:task id="Task_1" name="1" /> <bpmn:boundaryEvent id="BoundaryEvent_TopRight" attachedToRef="SubProcess" />
<bpmn:task id="Task_2" name="2" /> <bpmn:task id="Task_Bottom" />
<bpmn:task id="Task_3" name="3" /> <bpmn:task id="Task_Top" />
<bpmn:task id="Task_4" name="4" />
<bpmn:task id="Task_5" name="5" />
<bpmn:task id="Task_6" name="6" />
</bpmn:process> </bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1"> <bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_1"> <bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_1">
<bpmndi:BPMNShape id="SubProcess_di" bpmnElement="SubProcess" isExpanded="true"> <bpmndi:BPMNShape id="Task_0b6k3xo_di" bpmnElement="Task_Left">
<dc:Bounds x="505" y="258" width="350" height="200" /> <dc:Bounds x="0" y="350" width="100" height="80" />
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNShape id="BoundaryEvent_D_di" bpmnElement="BoundaryEvent_D"> <bpmndi:BPMNShape id="SubProcess_12qmapm_di" bpmnElement="SubProcess" isExpanded="true">
<dc:Bounds x="797" y="440" width="36" height="36" /> <dc:Bounds x="300" y="300" width="350" height="200" />
<bpmndi:BPMNLabel>
<dc:Bounds x="744" y="424" width="90" height="20" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNShape id="BoundaryEvent_C_di" bpmnElement="BoundaryEvent_C"> <bpmndi:BPMNShape id="Task_174r9fd_di" bpmnElement="Task_Right">
<dc:Bounds x="837" y="275" width="36" height="36" /> <dc:Bounds x="850" y="350" width="100" height="80" />
<bpmndi:BPMNLabel>
<dc:Bounds x="775" y="284" width="90" height="20" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNShape id="BoundaryEvent_B_di" bpmnElement="BoundaryEvent_B"> <bpmndi:BPMNShape id="BoundaryEvent_0s0nl1k_di" bpmnElement="BoundaryEvent_TopLeft">
<dc:Bounds x="568" y="240" width="36" height="36" /> <dc:Bounds x="282" y="320" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="540" y="280" width="90" height="20" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNShape id="BoundaryEvent_A_di" bpmnElement="BoundaryEvent_A"> <bpmndi:BPMNShape id="BoundaryEvent_0nomac7_di" bpmnElement="BoundaryEvent_BottomRight">
<dc:Bounds x="487" y="399" width="36" height="36" /> <dc:Bounds x="632" y="450" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="495" y="407" width="90" height="20" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_1_di" bpmnElement="Task_1"> <bpmndi:BPMNShape id="BoundaryEvent_1spolhy_di" bpmnElement="BoundaryEvent_BottomLeft">
<dc:Bounds x="287" y="354" width="100" height="80" /> <dc:Bounds x="282" y="482" width="36" height="36" />
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_2_di" bpmnElement="Task_2"> <bpmndi:BPMNShape id="BoundaryEvent_13iwzlu_di" bpmnElement="BoundaryEvent_TopRight">
<dc:Bounds x="362" y="503" width="100" height="80" /> <dc:Bounds x="632" y="282" width="36" height="36" />
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_3_di" bpmnElement="Task_3"> <bpmndi:BPMNShape id="Task_0ygk7bh_di" bpmnElement="Task_Bottom">
<dc:Bounds x="378" y="122" width="100" height="80" /> <dc:Bounds x="400" y="650" width="100" height="80" />
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_4_di" bpmnElement="Task_4"> <bpmndi:BPMNShape id="Task_1bc634w_di" bpmnElement="Task_Top">
<dc:Bounds x="536" y="81" width="100" height="80" /> <dc:Bounds x="400" y="0" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_5_di" bpmnElement="Task_5">
<dc:Bounds x="966" y="175" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Task_6_di" bpmnElement="Task_6">
<dc:Bounds x="991" y="443" width="100" height="80" />
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
</bpmndi:BPMNPlane> </bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram> </bpmndi:BPMNDiagram>

View File

@ -31,14 +31,139 @@ describe('features/modeling - layout', function() {
}); });
describe.skip('overall experience, boundary events', function() { describe('boundary events', function() {
var diagramXML = require('./LayoutSequenceFlowSpec.boundaryEvents.bpmn'); var diagramXML = require('./LayoutSequenceFlowSpec.boundaryEvents.bpmn');
beforeEach(bootstrapModeler(diagramXML, { modules: Modeler.prototype._modules })); var testModules = [ coreModule, modelingModule ];
beforeEach(bootstrapModeler(diagramXML, { modules: testModules }));
it('should feel awesome', inject(function() { })); describe('loops', function() {
it('attached top right', function() {
// when
var connection = connect('BoundaryEvent_TopRight', 'SubProcess');
// then
expect(connection).to.have.waypoints([
{ original: { x: 650, y: 300 }, x: 650, y: 282 },
{ x: 650, y: 262 },
{ x: 475, y: 262 },
{ original: { x: 475, y: 400 }, x: 475, y: 300 }
]);
});
it('attached bottom right', function() {
// when
var connection = connect('BoundaryEvent_BottomRight', 'SubProcess');
// then
expect(connection).to.have.waypoints([
{ original: { x: 650, y: 468 }, x: 668, y: 468 },
{ x: 688, y: 468 },
{ x: 688, y: 400 },
{ original: { x: 475, y: 400 }, x: 650, y: 400 }
]);
});
it('attached bottom left', function() {
// when
var connection = connect('BoundaryEvent_BottomLeft', 'SubProcess');
// then
expect(connection).to.have.waypoints([
{ original: { x: 300, y: 500 }, x: 300, y: 518 },
{ x: 300, y: 538 },
{ x: 475, y: 538 },
{ original: { x: 475, y: 400 }, x: 475, y: 500 }
]);
});
it('attached top left', function() {
// when
var connection = connect('BoundaryEvent_TopLeft', 'SubProcess');
// then
expect(connection).to.have.waypoints([
{ original: { x: 300, y: 338 }, x: 282, y: 338 },
{ x: 262, y: 338 },
{ x: 262, y: 400 },
{ original: { x: 475, y: 400 }, x: 300, y: 400 }
]);
});
});
describe('non-loops', function() {
it('attached top right, orientation top', function() {
// when
var connection = connect('BoundaryEvent_TopRight', 'Task_Top');
// then
expect(connection).to.have.waypoints([
{ original: { x: 650, y: 300 }, x: 650, y: 282 },
{ x: 650, y: 40 },
{ original: { x: 450, y: 40 }, x: 500, y: 40 }
]);
});
it('attached top right, orientation right', function() {
// when
var connection = connect('BoundaryEvent_TopRight', 'Task_Right');
// then
expect(connection).to.have.waypoints([
{ original: { x: 650, y: 300 }, x: 668, y: 300 },
{ x: 900, y: 300 },
{ original: { x: 900, y: 390 }, x: 900, y: 350 }
]);
});
it('attached top right, orientation bottom', function() {
// when
var connection = connect('BoundaryEvent_TopRight', 'Task_Bottom');
// then
expect(connection).to.have.waypoints([
{ original: { x: 650, y: 300 }, x: 650, y: 282 },
{ x: 650, y: 262 },
{ x: 450, y: 262 },
{ original: { x: 450, y: 690 }, x: 450, y: 650 }
]);
});
it('attached top right, orientation left', function() {
// when
var connection = connect('BoundaryEvent_TopRight', 'Task_Left');
// then
expect(connection).to.have.waypoints([
{ original: { x: 650, y: 300 }, x: 650, y: 282 },
{ x: 650, y: 262 },
{ x: 50, y: 262 },
{ original: { x: 50, y: 390 }, x: 50, y: 350 }
]);
});
});
}); });
@ -210,113 +335,4 @@ describe('features/modeling - layout', function() {
}); });
describe('boundary events', function() {
var diagramXML = require('./LayoutSequenceFlowSpec.boundaryEvents.bpmn');
var testModules = [ coreModule, modelingModule ];
beforeEach(bootstrapModeler(diagramXML, { modules: testModules }));
it('should layout h:h connecting BoundaryEvent -> left Task', inject(function() {
// when
var connection = connect('BoundaryEvent_A', 'Task_1');
// then
expect(connection).to.have.waypoints([
{ original: { x: 505, y: 417 }, x: 487, y: 417 },
{ x: 437, y: 417 },
{ x: 437, y: 394 },
{ original: { x: 337, y: 394 }, x: 387, y: 394 }
]);
}));
it('should layout h:v connecting BoundaryEvent -> bottom-left Task', inject(function() {
// when
var connection = connect('BoundaryEvent_A', 'Task_2');
// then
expect(connection).to.have.waypoints([
{ original: { x: 505, y: 417 }, x: 487, y: 417 },
{ x: 412, y: 417 },
{ original: { x: 412, y: 543 }, x: 412, y: 503 }
]);
}));
it('should layout h:v connecting BoundaryEvent -> top-right Task', inject(function() {
// when
var connection = connect('BoundaryEvent_A', 'Task_5');
// then
expect(connection).to.have.waypoints([
{ original: { x: 505, y: 417 }, x: 523, y: 417 },
{ x: 1016, y: 417 },
{ original: { x: 1016, y: 215 }, x: 1016, y: 255 }
]);
}));
it('should layout v:v connecting BoundaryEvent -> top Task', inject(function() {
// when
var connection = connect('BoundaryEvent_B', 'Task_4');
// then
expect(connection).to.have.waypoints([
{ original: { x: 586, y: 258 }, x: 586, y: 240 },
{ original: { x: 586, y: 121 }, x: 586, y: 161 }
]);
}));
it('should layout v:h connecting BoundaryEvent -> top-left Task', inject(function() {
// when
var connection = connect('BoundaryEvent_B', 'Task_3');
// then
expect(connection).to.have.waypoints([
{ original: { x: 586, y: 258 }, x: 586, y: 240 },
{ x: 586, y: 162 },
{ original: { x: 428, y: 162 }, x: 478, y: 162 }
]);
}));
it('should layout h:v connecting BoundaryEvent -> bottom-right Task', inject(function() {
// when
var connection = connect('BoundaryEvent_C', 'Task_6');
// then
expect(connection).to.have.waypoints([
{ original: { x: 855, y: 293 }, x: 873, y: 293 },
{ x: 1041, y: 293 },
{ original: { x: 1041, y: 483 }, x: 1041, y: 443 }
]);
}));
it('should layout v:h connecting BoundaryEvent -> bottom-left Task', inject(function() {
// when
var connection = connect('BoundaryEvent_D', 'Task_2');
// then
expect(connection).to.have.waypoints([
{ original: { x: 815, y: 458 }, x: 815, y: 476 },
{ x: 815, y: 543 },
{ original: { x: 412, y: 543 }, x: 462, y: 543 }
]);
}));
});
}); });