Unify auto-layout with the incremental placement algorithm
>>> [!note] Migrated issue
<!-- Drupal.org comment -->
<!-- Migrated from issue #3588454. -->
Reported by: [jurgenhaas](https://www.drupal.org/user/168924)
Related to !81
>>>
<h3>Problem/Motivation</h3>
<p>The Workflow Modeler currently has two independent node-placement systems that produce visibly different layouts for the same model:</p>
<ol>
<li><strong>Incremental placement</strong> — used by quick-add, drag-to-connect, the plugin API's <code>addNode</code>/<code>addEdge</code>, and the new parallel-edge router (issue <a href="https://www.drupal.org/project/modeler/issues/3586864">#3586864</a>). Lives in <code>useNodeEdgeActions.ts</code> and <code>positionUtils.ts</code>. Uses the <code>LAYOUT</code> constants (gap-based, top-left origin). New successors are placed in their parent's column, producing a tight vertical flow.</li>
<li><strong>Auto-layout</strong> — used when a model is loaded with default positions and when the user clicks the auto-layout toolbar button. Lives in <code>modelUtils.ts → autoLayout()</code>, <code>layoutStrategies.ts</code>, and <code>layoutHelpers.ts</code>. Uses a separate <code>LAYOUT_CONFIG</code> constants block (center-to-center, row/column grid). Fans non-gateway children horizontally, then runs an <code>optimizeRowAlignment</code> pass that pulls each node 30% toward its connection centroid.</li>
</ol>
<p>The two algorithms diverge in four concrete places:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Topic</th>
<th>Incremental</th>
<th>Auto-layout</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>Spacing constants</td>
<td><code>LAYOUT.NODE_SPACING_X = 250</code>, gap-based</td>
<td><code>LAYOUT_CONFIG.HORIZONTAL_SPACING = 300</code>, center-to-center</td>
</tr>
<tr>
<td>2</td>
<td>Multi-child branching</td>
<td>New child stays in parent's column</td>
<td>Children of <em>any</em> node with 2+ successors spread horizontally, even when the parent is not a gateway</td>
</tr>
<tr>
<td>3</td>
<td>Cross-flow column reservation</td>
<td><code>findFlowAwarePosition</code> shifts neighboring flows aside</td>
<td><code>nextEventColumn = eventMaxColumn + 1</code> allocates a single column gap</td>
</tr>
<tr>
<td>4</td>
<td>Post-pass alignment</td>
<td>None</td>
<td><code>optimizeRowAlignment</code> blends ideal X by 30% and enforces 230px minimum spacing</td>
</tr>
</tbody>
</table>
<p>The user-visible consequence is that a model built by clicking quick-add looks like a tidy vertical column, but the same model — after <code>autoLayout</code> runs (either on load or via the toolbar) — comes back as a fanned-out tree. Future improvements to placement also have to be applied twice, and the two constants blocks (<code>LAYOUT</code> vs <code>LAYOUT_CONFIG</code>) are easy to confuse.</p>
<p>This issue is a follow-up to <a href="https://www.drupal.org/project/modeler/issues/3586864">#3586864</a> (parallel-edge routing). The parallel-edge router operates on edge <code>controlOffset</code> values, so it works correctly regardless of which placement system positioned the nodes — but the visual contract the router establishes (vertical flow with side-routed parallels) is undone whenever auto-layout runs.</p>
<h3>Steps to reproduce</h3>
<ol>
<li>Build a small flow by clicking quick-add: event → action → action. Observe the tidy single-column layout.</li>
<li>Save the model.</li>
<li>Reload the model with positions stripped (or simply call the <code>autoLayout</code> plugin API).</li>
<li>Observe that any node with 2+ successors now has its children fanned horizontally, even when the parent is a plain action and not a gateway.</li>
</ol>
<h3>Proposed resolution</h3>
<p>Adopt <strong>option U3</strong> from the investigation in <a href="https://www.drupal.org/project/modeler/issues/3586864">#3586864</a>: simulate the incremental build inside <code>autoLayout</code>. When <code>autoLayout</code> runs, walk the graph from each start node and place each successor through the same primitives that quick-add and drag-to-connect use today (<code>positionUtils.ts</code>, <code>requiredVerticalGap</code>, <code>shiftNodesDown</code>, <code>findFlowAwarePosition</code>). The auto-layout becomes by definition equivalent to "what the user would have seen if they had built this model node-by-node with quick-add".</p>
<p>This locks in a single source of placement truth, so any future improvement applies equally to incremental editing and to model load / auto-layout.</p>
<h4>Implementation outline</h4>
<ol>
<li>Extract the placement logic currently embedded in <code>useNodeEdgeActions.ts</code> into pure functions that take <code>(nodes, edges, newNodeId, sourceNodeId, edgeHasCondition)</code> and return updated <code>nodes</code>/<code>edges</code> arrays.</li>
<li>Rewrite <code>autoLayout(nodes, edges)</code> in <code>modelUtils.ts</code> to:
<ul>
<li>Build a topological order from the start nodes.</li>
<li>For each non-start node, simulate the incremental insertion using the extracted pure functions.</li>
</ul>
</li>
<li>Delete <code>processFlowLayout</code>, <code>convertPositionsToCoordinates</code>, <code>optimizeRowAlignment</code>, <code>enforceMinimumSpacing</code>, <code>findNonCollidingPosition</code>, <code>buildNodeOffsets</code>, <code>groupNodesByRow</code>, and the <code>LAYOUT_CONFIG</code> constants block.</li>
<li>Delete <code>layoutHelpers.ts</code> (or shrink it to the genuinely shared bits, such as <code>findStartNodes</code> and <code>findNearestEdge</code> if they are still needed).</li>
<li>Update tests:
<ul>
<li>Replace the assertions in <code>layoutStrategies.test.ts</code> with new tests that exercise the unified placement function.</li>
<li>Update <code>modelUtils.test.ts → autoLayout</code> cases to expect the new output.</li>
</ul>
</li>
<li>Re-capture the documentation screenshots produced by <code>tests/e2e/screenshots.spec.ts</code> if any of them depend on the previous auto-layout output.</li>
</ol>
<h4>Code that becomes obsolete</h4>
<p>Approximate sizes (current main):</p>
<ul>
<li><code>src/utils/layoutStrategies.ts</code> — 462 lines (most of it removed)</li>
<li><code>src/utils/layoutHelpers.ts</code> — 172 lines (most of it removed)</li>
<li><code>src/utils/__tests__/layoutStrategies.test.ts</code> — 598 lines (replaced)</li>
<li><code>src/utils/__tests__/layoutHelpers.test.ts</code> — 307 lines (mostly replaced)</li>
</ul>
<p>Total: ~1500 lines of code/tests that will either be removed or significantly simplified. The replacement is expected to be a few hundred lines because the incremental primitives already exist.</p>
<h3>Remaining tasks</h3>
<ul>
<li>Extract the incremental-placement logic from <code>useNodeEdgeActions.ts</code> into pure functions in a new file (e.g. <code>src/utils/incrementalLayout.ts</code>).</li>
<li>Rewrite <code>autoLayout()</code> in <code>modelUtils.ts</code> to use the extracted functions.</li>
<li>Delete the obsolete row/column code in <code>layoutStrategies.ts</code> and <code>layoutHelpers.ts</code>.</li>
<li>Drop the <code>LAYOUT_CONFIG</code> constants block; ensure <code>LAYOUT</code> is the single source of truth.</li>
<li>Update unit tests in <code>layoutStrategies.test.ts</code>, <code>layoutHelpers.test.ts</code>, and <code>modelUtils.test.ts</code>.</li>
<li>Update <code>useNodeEdgeActions.ts</code> to use the extracted functions (so there really is one code path).</li>
<li>Re-capture documentation screenshots if affected.</li>
<li>Verify cycles, gateway branching, and disconnected sub-graphs still lay out correctly.</li>
</ul>
<h3>User interface changes</h3>
<p>When a model is loaded with default positions, or the auto-layout toolbar button is pressed, nodes will be arranged in a tight vertical column under each start node — matching what users see when building a model incrementally with quick-add. Gateways still branch their successors horizontally, but plain action / start nodes with multiple successors will no longer fan out.</p>
<p>This is a behavior change, so existing screenshots in the documentation will need to be re-captured.</p>
<h3>API changes</h3>
<ul>
<li>The exported function <code>autoLayout(nodes, edges)</code> keeps its signature and return shape; only the produced positions change.</li>
<li>Internal helpers in <code>layoutStrategies.ts</code> and <code>layoutHelpers.ts</code> are removed. These are not part of the public plugin API surface (<code>pluginApi.ts</code>), so plugin authors are not affected.</li>
<li>The <code>LAYOUT_CONFIG</code> constants block is removed; <code>LAYOUT</code> remains.</li>
</ul>
<h3>Data model changes</h3>
<p>None. The on-disk model format (<code>exportModelData</code> in <code>modelUtils.ts</code>) is unchanged. Only the runtime placement of nodes that have default positions on load is affected.</p>
issue