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> &mdash; 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> &mdash; used when a model is loaded with default positions and when the user clicks the auto-layout toolbar button. Lives in <code>modelUtils.ts &rarr; 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 &mdash; after <code>autoLayout</code> runs (either on load or via the toolbar) &mdash; 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 &mdash; 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 &rarr; action &rarr; 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 &rarr; 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> &mdash; 462 lines (most of it removed)</li> <li><code>src/utils/layoutHelpers.ts</code> &mdash; 172 lines (most of it removed)</li> <li><code>src/utils/__tests__/layoutStrategies.test.ts</code> &mdash; 598 lines (replaced)</li> <li><code>src/utils/__tests__/layoutHelpers.test.ts</code> &mdash; 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 &mdash; 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