Skip to content

Commit bc8ef74

Browse files
fix(context-pads): place inclusive gateway toggle pads on distinct flow parts
Toggle pads for an inclusive gateway's outgoing sequence flows were all anchored at the top-left corner of each flow's bounding box with a fixed offset, so with multiple outgoing flows the buttons piled up near the gateway and could not be targeted individually. Gateways commonly route several outgoing flows over a shared trunk before they branch off to their targets. Anchor each pad at the point where its flow first separates from all sibling flows (falling back to a fixed, per-flow-staggered distance when flows never diverge), so every button sits on a part of the flow that is unambiguously its own. The absolute anchor is converted to a bbox-relative overlay position and folded into the overlay hash, so moving a flow rebuilds the pad at the new spot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dfb6f13 commit bc8ef74

2 files changed

Lines changed: 213 additions & 11 deletions

File tree

lib/features/context-pads/ContextPads.js

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import {
22
isPlane
33
} from 'bpmn-js/lib/util/DrilldownUtil';
44

5+
import {
6+
getBBox
7+
} from 'diagram-js/lib/util/Elements';
8+
59
import {
610
is
711
} from 'bpmn-js/lib/util/ModelUtil';
@@ -169,19 +173,34 @@ ContextPads.prototype._getOverlays = function(hash) {
169173
ContextPads.prototype._addOverlay = function(element, options) {
170174

171175
const {
172-
handlerHash
176+
handlerHash,
177+
position: anchor
173178
} = options;
174179

175180
if (!handlerHash) {
176181
throw new Error('<handlerHash> required');
177182
}
178183

184+
let position = {
185+
top: OFFSET_TOP,
186+
left: OFFSET_LEFT
187+
};
188+
189+
// an absolute anchor (diagram coordinates) placed on a connection is
190+
// converted into an offset relative to the connection's bounding box,
191+
// which is what overlays are positioned against
192+
if (anchor && element.waypoints) {
193+
const bbox = getBBox(element);
194+
195+
position = {
196+
top: anchor.y - bbox.y + OFFSET_TOP,
197+
left: anchor.x - bbox.x + OFFSET_LEFT
198+
};
199+
}
200+
179201
const overlayId = this._overlays.add(element, 'bts-context-menu', {
180202
...options,
181-
position: {
182-
top: OFFSET_TOP,
183-
left: OFFSET_LEFT
184-
},
203+
position,
185204
show: {
186205
minZoom: 0.5
187206
}
@@ -243,11 +262,17 @@ ContextPads.prototype._updateElementContextPads = function(element, handler) {
243262
contexts: _contexts,
244263
hideContexts: _hideContexts,
245264
action: _action,
246-
html: _html
265+
html: _html,
266+
position: _position
247267
} = contextPad;
248268

269+
// include the (rounded) anchor in the hash so that moving the flow
270+
// rebuilds the overlay at its new position instead of reusing the stale one
271+
const positionKey = _position
272+
? `-${Math.round(_position.x)},${Math.round(_position.y)}`
273+
: '';
249274

250-
const hash = `${contextPad.element.id}-------${_html}`;
275+
const hash = `${contextPad.element.id}-------${_html}${positionKey}`;
251276

252277
let existingOverlay = existingOverlays.find(
253278
o => o.hash === hash
@@ -321,7 +346,8 @@ ContextPads.prototype._updateElementContextPads = function(element, handler) {
321346
this._addOverlay(element, {
322347
hash,
323348
handlerHash,
324-
html
349+
html,
350+
position: _position
325351
});
326352
}
327353

lib/features/context-pads/handler/InclusiveGatewayHandler.js

Lines changed: 179 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,28 @@ import {
88

99
import { isSequenceFlow } from '../../../simulator/util/ModelUtil';
1010

11+
// how far (px) a point on a flow must be from every sibling flow for the
12+
// flow to count as "diverged" - roughly the toggle button size, so buttons
13+
// end up on visually distinct parts of their flows
14+
const SEPARATION = 30;
15+
16+
// step used when scanning a flow for its divergence point
17+
const SAMPLE_STEP = 8;
18+
19+
// once a flow has diverged, push the anchor a little further so the button
20+
// sits clearly past the corner rather than right on it
21+
const DIVERGENCE_MARGIN = 20;
22+
23+
// fallback (flows never diverge, e.g. fully overlapping): distance along the
24+
// flow for the first button ...
25+
const FLOW_ANCHOR_DISTANCE = 40;
26+
27+
// ... and how much each subsequent button is pushed further along its flow
28+
const FLOW_ANCHOR_STEP = 32;
29+
30+
// keep the anchor off the very end of short flows
31+
const FLOW_ANCHOR_END_MARGIN = 20;
32+
1133
export default function InclusiveGatewayHandler(inclusiveGatewaySettings) {
1234
this._inclusiveGatewaySettings = inclusiveGatewaySettings;
1335
}
@@ -32,19 +54,173 @@ InclusiveGatewayHandler.prototype.createContextPads = function(element) {
3254
</button>
3355
`;
3456

35-
return nonDefaultFlows.map(sequenceFlow => {
57+
return nonDefaultFlows.map((sequenceFlow, index) => {
3658
const action = () => {
3759
this._inclusiveGatewaySettings.toggleSequenceFlow(element, sequenceFlow);
3860
};
3961

62+
const siblings = outgoingFlows.filter(flow => flow !== sequenceFlow);
63+
4064
return {
4165
action,
4266
element: sequenceFlow,
43-
html
67+
html,
68+
position: getFlowAnchor(sequenceFlow, index, siblings)
4469
};
4570
});
4671
};
4772

4873
InclusiveGatewayHandler.$inject = [
4974
'inclusiveGatewaySettings'
50-
];
75+
];
76+
77+
78+
// helpers ///////////////
79+
80+
/**
81+
* Absolute point (diagram coordinates) at which the toggle button for a
82+
* sequence flow should be centered.
83+
*
84+
* Gateways commonly route several outgoing flows over a shared trunk (e.g. a
85+
* vertical segment) before they branch off to their targets. Anchoring the
86+
* button where the flow first separates from all its siblings puts it on a
87+
* part of the flow that is unambiguously its own, so buttons no longer pile
88+
* up near the gateway.
89+
*
90+
* @param {Object} sequenceFlow
91+
* @param {Number} index
92+
* @param {Array<Object>} siblings other outgoing flows of the same gateway
93+
*
94+
* @return {{ x: Number, y: Number }|null}
95+
*/
96+
function getFlowAnchor(sequenceFlow, index, siblings) {
97+
const waypoints = sequenceFlow.waypoints;
98+
99+
if (!waypoints || waypoints.length < 2) {
100+
return null;
101+
}
102+
103+
const length = getFlowLength(waypoints);
104+
105+
// never place the anchor past the flow; keep it off the target end
106+
const maxDistance = Math.max(length - FLOW_ANCHOR_END_MARGIN, length / 2);
107+
108+
const siblingWaypoints = siblings
109+
.map(flow => flow.waypoints)
110+
.filter(wps => wps && wps.length >= 2);
111+
112+
const divergence = getDivergenceDistance(waypoints, siblingWaypoints, length);
113+
114+
const distanceAlong = divergence === null
115+
116+
// flows never separate (fully overlapping / no siblings): fall back to a
117+
// fixed, per-flow-staggered distance
118+
? FLOW_ANCHOR_DISTANCE + index * FLOW_ANCHOR_STEP
119+
120+
// place the button just past the point where the flow becomes distinct
121+
: divergence + DIVERGENCE_MARGIN;
122+
123+
return getPointAlong(waypoints, Math.min(distanceAlong, maxDistance));
124+
}
125+
126+
/**
127+
* Distance along <waypoints> at which the flow is at least SEPARATION px away
128+
* from every sibling flow, or null if that never happens.
129+
*/
130+
function getDivergenceDistance(waypoints, siblingWaypoints, length) {
131+
if (!siblingWaypoints.length) {
132+
return null;
133+
}
134+
135+
for (let d = 0; d <= length; d += SAMPLE_STEP) {
136+
const point = getPointAlong(waypoints, d);
137+
138+
const clearOfAll = siblingWaypoints.every(
139+
wps => getDistanceToPolyline(point, wps) >= SEPARATION
140+
);
141+
142+
if (clearOfAll) {
143+
return d;
144+
}
145+
}
146+
147+
return null;
148+
}
149+
150+
function distance(a, b) {
151+
return Math.sqrt(Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2));
152+
}
153+
154+
/**
155+
* Shortest distance from point <p> to a polyline.
156+
*/
157+
function getDistanceToPolyline(p, waypoints) {
158+
let min = Infinity;
159+
160+
for (let i = 0; i < waypoints.length - 1; i++) {
161+
min = Math.min(min, getDistanceToSegment(p, waypoints[i], waypoints[i + 1]));
162+
}
163+
164+
return min;
165+
}
166+
167+
/**
168+
* Shortest distance from point <p> to the segment <a>-<b>.
169+
*/
170+
function getDistanceToSegment(p, a, b) {
171+
const dx = b.x - a.x,
172+
dy = b.y - a.y;
173+
174+
const lengthSquared = dx * dx + dy * dy;
175+
176+
if (lengthSquared === 0) {
177+
return distance(p, a);
178+
}
179+
180+
// projection of p onto the segment, clamped to [0, 1]
181+
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSquared;
182+
183+
t = Math.max(0, Math.min(1, t));
184+
185+
return distance(p, { x: a.x + t * dx, y: a.y + t * dy });
186+
}
187+
188+
function getFlowLength(waypoints) {
189+
let length = 0;
190+
191+
for (let i = 0; i < waypoints.length - 1; i++) {
192+
length += distance(waypoints[i], waypoints[i + 1]);
193+
}
194+
195+
return length;
196+
}
197+
198+
/**
199+
* Point <d> px along the polyline, measured from its start
200+
* (the gateway/source side, since waypoints run source -> target).
201+
*/
202+
function getPointAlong(waypoints, d) {
203+
let remaining = d;
204+
205+
for (let i = 0; i < waypoints.length - 1; i++) {
206+
const start = waypoints[i],
207+
end = waypoints[i + 1];
208+
209+
const segment = distance(start, end);
210+
211+
if (segment >= remaining) {
212+
const t = segment === 0 ? 0 : remaining / segment;
213+
214+
return {
215+
x: start.x + (end.x - start.x) * t,
216+
y: start.y + (end.y - start.y) * t
217+
};
218+
}
219+
220+
remaining -= segment;
221+
}
222+
223+
const last = waypoints[waypoints.length - 1];
224+
225+
return { x: last.x, y: last.y };
226+
}

0 commit comments

Comments
 (0)