fixtures: add funcprofiler and funcprofiler_upstream examples - #337
Draft
pinin4fjords wants to merge 552 commits into
Draft
fixtures: add funcprofiler and funcprofiler_upstream examples#337pinin4fjords wants to merge 552 commits into
pinin4fjords wants to merge 552 commits into
Conversation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
docs: update README renders with current code
Three fixes to the track-per-line ordering algorithm: 1. Single-predecessor alignment: when a node has exactly one predecessor on the same line(s), snap to the predecessor's track so terminus-to- station edges run horizontally. 2. Convergent sibling equalization: group stations by successor set (not just predecessor set) so fan-in siblings feeding the same hidden hub get evenly spaced. 3. Line-grouped equalization sort: sort equalized groups by primary line order first, keeping same-line siblings together instead of interleaving them by track position. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Initial section of an nf-core/variantbenchmarking metro map recreation. Five input termini with file icons (FASTA, VCF, BED, BED, TSV) on truth and test preprocessing lines, bundled at a hidden hub station. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…offset consistency - Add `%%metro compact_offsets: true` directive for pipelines using lines as analysis stage colors rather than persistent routes. Resets line offset slots per-station based on actual inbound/outbound line counts. - Auto-insert hidden pass-through stations when a line entering a section skips internal layers, so optional processes bubble up visually. - Pull convergence nodes to the pass-through track so the trunk line stays horizontal (_align_pass_throughs). - Add section-wide offset consistency: lines entering a section maintain their relative offsets at every multi-line station and hidden pass-through, preventing crossings and overlaps. - Tighten bypass routing to use the shorter endpoint section when no intervening sections exist. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add Preprocessing, Variant Normalization, Variant Filtering, and Variant Statistics sections. Uses compact_offsets mode, bypass routes around optional sections, and TSV output termini on statistics stations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lows fixture The alignment QC reporting line should pass through Samtools Sort rather than exiting directly from Star Align, matching the actual pipeline data flow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three fixes for cleaner line routing when lines diverge and reconverge across sections: 1. Section-local offset re-indexing (offsets.py): Close gaps in line offsets when a section only has a subset of global lines. Only re-indexes when actual gaps exist in the global priority sequence to preserve cross-section continuity. 2. Reconvergence ordering (offsets.py): At sections where multiple upstream sections feed in, lines from the "primary" feeder (most lines) keep their relative offsets at the top; returning lines from diversions are appended below. Eliminates slanting at reconvergence boundaries. 3. Bypass route offset application (core.py): Apply per-line Y offsets directly in bypass routing instead of relying on the renderer's midpoint heuristic, which breaks when source and target share the same base Y. Also sort gap2 vertical channels by line priority to prevent crossings at entry ports. 4. Dead-end spur track placement (ordering.py): When a terminal node's base track is far from its predecessor (because the main line has diverged), reuse the nearest free existing track instead of snapping to the distant base track. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat: variantbenchmarking example with layout fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
chore: bump version to 0.5.1
Bump LABEL_OFFSET from 8 to 11px so station labels don't crowd the pills, and reduce terminus_font_size from 9 to 7.5 so file extension text fits more comfortably inside icons. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sections that span multiple columns (grid_col_span > 1) could have a different local bbox_x from internal layout, causing their left edge to be offset from single-span sections in the same starting column. e.g. qc_report (RL, col_span=2) was 8px left of genome_align (LR) in column 1. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…font fix: increase label spacing and reduce file icon font size
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
chore: release 0.5.2
Make section group labels more visually prominent: bold weight on section name text, larger number badge (radius 9->11, font 9->12), and bumped section_label_font_size in nfcore theme (13->16). Consistent +3pt offset over station labels in both themes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bump SECTION_Y_GAP (40->50) and PLACEMENT_Y_GAP (60->70) for more breathing room between rows. Update SECTION_HEADER_PROTRUSION (22->26) to match the larger section number badge radius. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
release: v0.5.3 - improve section label hierarchy
Two animation fixes: 1. Edge-disjoint paths: instead of the cartesian product of all diamond branches (2 binary + 1 ternary = 12 full paths), produce one path per unique branch (1 canonical + 1 per alternative = 5 paths). Each branch still gets a ball, without combinatorial blowup. 2. Synchronized cycle duration: all balls now share the longest path's duration as their cycle period. Short paths use keyTimes/keyPoints to travel at constant speed then hold at the endpoint until the cycle restarts. This prevents the "every second wave only shows short lines" visual artifact. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…300) * fix(labels): stagger labels of vertically stacked same-column stations When two stations in the same section share an X column and adjacent Y tracks (e.g. Affy and Proteus in the differentialabundance metro map), their centered labels visually crowd along the same vertical line. Adds a post-placement pass that nudges labels horizontally in opposite directions, only when the shift would not introduce a new collision. [skip ci] (cherry picked from commit 265b7e4) * fix: re-align row bboxes after port-terminus spacing Phase 11 _space_ports_from_termini calls _expand_bbox_for_y when it pushes a port outside the section bbox, which can lift a single section's bbox_y above its row-mates. Phase 11b _recompute_grid_group_bboxes then sets bbox_y to min(non_port_ys) - max_y_pad and re-expands for off-grid ports, baking the shift in. Phase 9 _top_align_row_sections runs before Phase 11 so it can't undo the resulting offset. Add a Phase 11c re-run of _top_align_row_sections so bbox tops within each row stay flush after the port-terminus spacing pass. Repro: render examples/differentialabundance with port-pushed entry on the functional section. Before: functional bbox_y=40, others 85, visible 45px vertical offset. After: all sections at bbox_y=85. (cherry picked from commit 9b5d298) * fix(layout): block station overlaps when track compaction collides cells The track allocator can return identical track values for two stations on different lines when each is the sole occupant of its line at a given layer (e.g. side-by-side terminus branches like the Affy/Proteus microarray and proteomics terminals in nf-core/differentialabundance). After coordinate assignment they land on the same (x, y), causing the pills and labels to render directly on top of one another. Add a post-placement pass in _layout_single_section that buckets real stations by their primary axis (layer column for LR/RL, row for TB) and pushes any secondary-axis duplicates apart by one spacing unit. Insertion order in sub.stations is the stable tiebreaker so the earlier-defined station keeps its slot. The pass is a no-op when there is no collision, so existing layouts are unaffected. [skip ci] (cherry picked from commit 9e97491) * fix(layout): increase SECTION_Y_PADDING for bundle margin Provides visual breathing room so line bundles passing through edge-tracked stations don't touch the section bbox border. [skip ci] (cherry picked from commit 72e8379) * fix(layout): anchor inter-section exit ports to downstream bundle Y When an LR/RL exit port's bundle materialises at a single same-row Y in the downstream section (parallel fan-out where every internal station carries the same line set), pull the exit port to that Y so the inter-section run stays horizontal. Falls back to the local-internal centre when the exit has fan-in from distinct Ys, the downstream entry is a branch fan-out, multiple downstream entries land at different Ys, or the anchor falls outside the section bbox. Eliminates the drop from differential.annotate (centred) to functional.gsea (top fan-out station) on the differentialabundance map. (cherry picked from commit edb06e4) * fix(layout): anchor fan-in exit to downstream when bundle is multi-line Phase 10d preserved centred midpoints for any 3+ source fan-in exit, which kept variantbenchmarking's filter merge correct but left the differentialabundance functional exit centred between gprofiler2 and decoupler even though all four sources carry the same four-line bundle into shinyngs and quarto. The inter-section run then curved up to the reporting entry Y instead of staying horizontal. Relax the 3+ fan-in skip when the exit carries a multi-line bundle (>=2 lines, so every source is parallel-redundant) and one source Y already matches the downstream entry: snap to that Y for a straight inter-section run. Single-line fan-ins still keep their midpoint, so the variantbenchmarking TestFanInExitPreservation assertions hold. (cherry picked from commit 7ce8a2d) * fix(layout): anchor trunk junctions on bundle line for straight trunks Trunk stations - those receiving a pre-existing bundle from at least one predecessor plus side branches - previously snapped to the predecessor centroid, bending the bundle within the section. Detect this case in both _place_single_node (for fan-in junctions) and _place_fan_out (when one fan-out node carries a strict superset of its siblings' lines) and anchor the trunk node on its primary line's base track so the bundle stays straight through the junction. (cherry picked from commit bfde7b0) * fix(layout): symmetric fan-out of trunk siblings, strictly scoped Resurrects the PR #264 idea (symmetric distribution of side stations around the section trunk Y when ``--center-ports`` is on) but scopes it tightly so it ONLY moves stations that are unambiguously fan-out side branches of a trunk junction. A column qualifies for redistribution only when it contains exactly one station whose line set equals the section's full LEFT/RIGHT bundle (the trunk junction) AND at least one sibling station whose line set is a strict subset of the bundle. Those siblings are redistributed in alternating ``+1, -1, +2, -2, ...`` slots at ``y_spacing`` pitch around the trunk Y; the trunk itself stays pinned. Everything else is left in place: file inputs, linear processing chains, fan-in structures (many sources converging on one station), columns without a unique trunk, and any sibling that carries the full bundle (linear pass-through, not a fan-out branch). This fixes the original PR #264 regression where data-import-style sections (fan-in to a Validate node, then linear to Filter matrix) had their input labels and chain stations mixed at random Ys. In nf-core/differentialabundance: - Data import: untouched (no qualifying column, only file inputs and processing chains). - Differential: limma is the trunk; DESeq2/dream/propd carry only ``{rnaseq}`` (strict subset) and now fan symmetrically above and below limma's Y. - Functional: gprofiler2/decoupler/grea carry the full bundle and are intentionally not redistributed; file inputs (Gene sets, Network) are in a different column from GSEA and stay put. - Reporting: no qualifying trunk-junction column; untouched. Default (``--no-center-ports``) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 6415807) * feat(layout): add %%metro off_track: directive to lift file inputs above line tracks File-input stations declared in the flow graph (e.g. samples_in[Samples] + %%metro file: samples_in | CSV) normally consume a line-track Y slot because each station owns a track. This makes input file icons share Y with the study-type line lanes, defeating the visual goal of an "inputs band" above the section's pipeline tracks. Add a new %%metro off_track: id1, id2, ... directive that marks stations to be lifted into a separate band above the section's topmost line track in a final post-layout phase. Section bboxes expand upward to fit the band, and the whole graph is shifted down if needed so the new top stays inside the canvas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit dca94e9) * fix(layout): align trunk Ys across same-row sections Previously sections in the same grid row could have their inter-section trunk bundles enter and exit at different Y values when the upstream section had more above-trunk content than the downstream section. The visible result was lines bending vertically between section bboxes even though the bbox tops were aligned. Add a new Phase 11ca _align_row_trunk_ys. For each contiguous column group of LR/RL sections within a grid row, identify each section's trunk station (the full-bundle station connected to an LR port) and shift content downward in sections whose trunks sit above the row's deepest trunk. Bbox tops are preserved (heights grow downward). Row-spanning sections (rowspan > 1) are skipped to avoid disturbing cross-row vertical relationships. After the shift, ports on shifted sections are re-snapped to the target trunk Y when they have a single internal station at that Y; fan-in convergence ports (2+ internal sources at distinct Ys) are preserved at their centered midpoint so Phase 10d's fan-in geometry isn't disturbed. Update the grid-alignment regression tests: first_station_y consistency and symmetric bbox padding are stricter than top alignment and are not satisfied when sections shift to align trunks. Replace with bbox-top alignment and a non-negative bottom-padding floor. (cherry picked from commit 43e387c) * fix(layout): align bbox tops after off-track lift The %%metro off_track directive lifts file inputs above the line tracks and expands the section's bbox upward. This left other sections in the same row with their bbox tops misaligned because the row top-align phase ran before off-track lifting. Add Phase 13a (_top_align_row_bboxes_only) after off-track lifting to grow row-mate bboxes upward to the row's topmost bbox. Unlike earlier top-align phases, this only adjusts bbox_y/bbox_h - station Ys stay in place so the lifted section's content is preserved and the rest of the row gets empty space at the top of its bboxes. (cherry picked from commit 2d7a663) * fix(layout): compact row sections so content sits at bbox top Phase 11ca aligns trunk Ys across row-mate sections and Phase 13a expands shorter row-mates' bbox tops to match a section whose bbox grew upward in Phase 13 to accommodate off-track inputs. Without a follow-up, the row-mates without off-track ended up with empty vertical space at the top of their bboxes. Add Phase 13b _compact_row_content_to_bbox_top. For each grid row's contiguous column group: 1. Compute the allowable upward shift per section based on: - distance from on-track content to bbox top (minus padding), and - clearance to any lifted off-track band (so on-track content doesn't collide with the lift band that Phase 13 placed ``y_spacing`` above the on-track top). Apply the minimum allowable shift across the group to every on-track station and port; off-track stations stay anchored to the lift band. Uniform shift preserves trunk alignment. 2. Shrink each section's bbox_h so the bottom slack matches section_y_padding (clamped to keep bottom ports inside the bbox). Sections with grid_row_span > 1 are excluded because their content spans multiple rows and the per-row compaction frame doesn't apply. (cherry picked from commit 2831431) * feat(icons): optional caption name on file/files/dir directives Extend `%%metro file:`, `%%metro files:` and `%%metro dir:` with an optional third field for a human-readable caption rendered directly below the icon. This avoids the previous workaround of attaching a separate naming station that rendered as a process circle. Syntax: `%%metro file: station_id | TYPE | NAME` The caption uses the theme label color/font and is included in the icon's obstacle bbox so neighbouring labels keep their distance. (cherry picked from commit c435357) * fix(layout): compact rowspan sections, skip off-track termini, pad captioned bands Three coupled fixes around off-track stations and rowspan sections: * `_compact_row_content_to_bbox_top` now compacts ``grid_row_span > 1`` sections instead of skipping them. They form their own single-section groups since rowspan sections trunk at a Y of their own and don't share a horizontal inter-section bundle with row mates. * `_space_ports_from_termini` skips off-track stations when collecting termini. They get lifted above the topmost line track later, so they no longer share a Y with the inter-section bundle; treating them as termini was pushing ports above the on-track row (and dragging the upstream port via junction propagation), causing a visible kink in the inter-section bundle. * When a captioned off-track icon sits at the bottom of the lifted band, `_compact_row_content_to_bbox_top` widens its per-section clear-shift gap by the caption extent so the optional caption text (PR #269) doesn't end up overlapping the topmost line track after compaction. (cherry picked from commit 088edd2) * fix(layout): caption scaling, inter-section port snap, top-fill fan Polishes the explicit-grid layout pipeline so trunk lines flow cleanly across section boundaries and large bbox top slack stops looking empty. * Caption text under file icons now renders at 60% of the label font size and clamps its X within the section bbox so long captions (e.g. ``MaxQuant``, ``GEO ID``) stop spilling onto the section border. Compaction reserves the smaller caption extent in the off-track gap calculation. * New Phase 13c snaps LR/RL exit port Ys to the downstream entry Y for same-row connections so a row-spanning section's trunk no longer kinks at the section boundary; junctions are re-positioned afterwards. Gated on user-supplied ``%%metro grid:`` directives to preserve auto-layout line-offset ordering. * New Phase 13d redistributes trunk-candidate siblings symmetrically around the topmost trunk station when a section retains visible empty top space (e.g. Reporting fed from Functional with off-track inputs), filling the bbox top instead of leaving it blank. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit cc28ae5) * fix(layout): skip upward fan on U-turn and dodge label-vs-diagonal Two polish fixes to PR #272's top-fill fan and the label placer: * Phase 13d (`_fan_free_content_upward`) now skips lifting a trunk candidate whose incoming bundle has multiple external feeders all at or below the row's trunk Y. Lifting in that case forces the bundle to climb against the trunk (a "U-turn") and visibly bends past sibling routes that stay at trunk Y. Reporting section in nf-core/differentialabundance: Shiny app now sits on the trunk and Quarto report stays below it. * New `_avoid_diagonal_routes` pass after label placement flips a label to the opposite side of its station when its bbox overlaps a non-horizontal route segment (and the flipped position is collision free). Trunk horizontals are deliberately ignored. Fixes the GSEA label overlapping the off-track icon descent in section 3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 30158a0) * fix(layout): snap station and port Ys to row-wide grid Final layout phase snaps every non-port station and every LEFT/RIGHT port to a clean grid pitch. Earlier phases (trunk-Y align, downstream port-snap, fold-exit alignment) compute shifts that don't respect the grid, leaving coordinates at fractional Ys (e.g. 298.785 when the pitch is 55). Group sections by row using ``_row_y_grid_info``, derive ONE shared grid origin per row from the mode of (y % pitch) across all non-port stations in the row, and snap every station and LR/RL port in the row to that origin. Sections outside any row group snap to the input ``y_spacing`` with their own origin. A per-section origin (as in the prior pass) drifted between sections in the same row when one section was on residue 0 and the next on residue 23.9, kinking trunks at section boundaries. Sharing the origin across the row keeps trunks co-linear. - bounds each snap by half a pitch so adjacency cannot flip - skips off-track stations and TOP/BOTTOM ports whose Y is fixed (cherry picked from commit b9a10ab) * fix(layout): symmetric fan for terminal full-bundle columns Terminal sections (no exit ports) sometimes hold a column of stations that all carry the full bundle and all receive from the same upstream branches, with no obvious trunk station to pin. The existing fan-out logic requires a unique trunk + strict-subset siblings, so it leaves these columns stacked tightly. Add a second pass that runs only when --center-ports is on and the section has no exit ports: when a column's stations are all full-bundle (>=2), redistribute them symmetrically around a trunk Y derived from other full-bundle stations in the section (or the LR port Y). Even counts leave the trunk row empty; odd counts keep one station on the trunk. Non-terminal sections, sub-bundle siblings, file inputs and fan-in chains are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit b2ca836) * fix(layout): symfan all full-bundle columns and pin off-track to consumer (cherry picked from commit 4a5d184) * fix(layout): pin source stations to per-line track, not trunk fan `_redistribute_fanout_siblings` (PR #266) rearranges fan-out branches symmetrically around the trunk junction of a column when `--center-ports` is on. Its scope was meant to exclude file-input columns, but in nf-core/differentialabundance the data-import section has `meta_in` carrying the full bundle and the other source inputs (matrix_in, cel_in, mq_in, geo_in, gtf_in) each carrying a strict subset. Those siblings were redistributed around `meta_in` even though they aren't a real fan-out, pushing every file icon off its line's track and forcing each consumer line to traverse the entire section to reach its input. Tighten the scope so a sibling only qualifies when it shares at least one predecessor with the trunk station. Real fan-outs (differential's limma + DESeq2/dream/propd, all fed by matrix_filter) still redistribute; columns of source stations stay on their per-line track Y so the file icon for each line sits adjacent to its consumer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit a76a21e) * fix(layout): preserve trunk-Y alignment for rowspan sections during compact `_compact_row_content_to_bbox_top` (PR #278) isolated every ``grid_row_span > 1`` section into its own group on the assumption that rowspan sections "trunk at a Y of their own". That assumption breaks for sections like nf-core/differentialabundance's data_prep, which rowspans down over a row-1 plots block but whose trunk Y still sits on the row-0 inter-section bundle. In isolation, data_prep computed an 82.5px upward shift to remove its (now legitimately large) top padding, pulling validator and matrix_filter above the row's trunk Y and breaking the horizontal bundle from data_prep into differential. Downstream ``_snap_all_y_to_grid`` then flipped the row's grid mode onto the shifted residue, snapping differential/reporting fan-out columns onto the same Y slot (dream/limma, deseq2/propd, bundle/report_html all collided). Keep the rowspan exclusion only when the rowspan section's trunk Y differs from its column-adjacent neighbour's. When trunks align, compact groups them together so the shared upward shift is bounded by the smallest tolerance in the group, preserving the inter-section bundle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 85d2114) * fix(layout): lift source-stack inputs above the trunk to fill top space `_fan_free_content_upward` only fans full-bundle trunk candidates upward, so a section whose entry column has a single full-bundle station plus subset-bundle source inputs (file icons with no inbound edges, e.g. nf-core/differentialabundance's data-import section) leaves its bbox top empty: every source sits at or below the trunk. Add a companion phase `_fan_source_inputs_upward` that detects this shape and lifts the nearest-to-trunk sources into the empty top band. Each lifted source drags its linear consumer chain (single inbound edge, identical line set, section-internal) so per-line tracks stay straight from icon to junction. Scoped to explicit `%%metro grid:` pipelines. Bounded by `min(slots, n_sources // 2)` so a majority of sources stay below the trunk; capped by `(trunk_y - bbox_y - y_spacing / 4) // y_spacing` so the topmost lifted station stays inside the bbox with a margin. U-turn risk is nil because sources have no upstream feeders. Differentialabundance section 1 now shows GTF + Matrix above the trunk and Affy CEL + MaxQuant + GEO below, filling the 132.5px of previously-empty bbox top space. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f02d705) * fix(layout): three v93 layout polish issues 1. Compact below-trunk sources after source-input lift When ``_fan_source_inputs_upward`` lifts the top N sources above the trunk (e.g. GTF, Matrix in differentialabundance section 1), the remaining below-trunk sources stayed at their original Ys, leaving a multi-slot empty band between the trunk row and the first below-trunk source. Pack the remaining sources upward at ``trunk_y + i*y_spacing`` so the gap collapses. 2. Re-fan full-bundle columns around the row's final trunk Y ``_redistribute_full_bundle_columns`` runs early using local LR port Ys as the symmetric centre. For terminal sections whose sole LR port Y differs from the row's eventual trunk Y (e.g. Reporting's entry port at the bundle-column Y rather than the row trunk Y), the fan ends up offset from the trunk row. Add Phase 13g ``_recenter_full_bundle_columns`` to re-distribute each multi-station full-bundle column around the entry/exit port station Y, which by that point sits on the row's inter-section bundle Y. 3. Stagger captions when adjacent file icons would overlap Stations carrying multiple ``%%metro file:`` directives place icons at ``terminus_width + ICON_INTER_GAP`` (32 px) intervals. Captions like ``Samples`` and ``Contrasts`` measure ~50 px wide and overlap when both sit at the same Y. Detect the overlap from the estimated caption widths and drop every other caption to a second row beneath the first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 9322198) * fix(layout): v94 polish - terminus Y, caption fit, rowspan trim - Pin a single-station downstream terminus to its sole in-section upstream's Y after full-bundle column re-centering, so file icons like the Reporting ``Bundle`` ZIP line up with their source station. - Replace the "drop alternate captions to row 2" overlap workaround with a horizontal spread: ``_render_terminus_icons`` now widens the per-icon centre-to-centre step when adjacent captions would collide, keeping all captions on one row. ``_terminus_icon_clearance`` expands the section bbox by the same amount so the wider icon row still fits. ``_build_section_subgraph`` now copies ``terminus_names`` / ``terminus_icon_types`` so the layout-side clearance check sees the captions. - Re-shrink section bboxes to their content bottom after late lifts (``_fan_source_inputs_upward``, ``_recenter_full_bundle_columns``): rowspan>1 sections like ``data_prep`` no longer keep the inflated height once content has been pulled up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f2dfda8) * fix(layout): exclude leftward bbox overhang from column width Sections whose ``bbox_x`` is pushed further left than the standard ``-SECTION_X_PADDING`` (e.g. by ``_adjust_terminus_icon_clearance`` expanding the bbox to fit file icons on source stations) had that extra overhang counted into the column width. Adjacent columns were placed at ``offset_x + col_widths[col] + section_x_gap``, so the leftward overhang inflated every gap downstream of the expanded column. On nf-core/differentialabundance this widened the gap between section 1 and section 2 from 50px to ~103px while 2-3 and 3-4 stayed at the expected 50px. Use a small ``_effective_width`` helper that re-anchors the bbox to the standard left edge (``bbox_x + bbox_w + SECTION_X_PADDING``) when measuring per-column widths and when sizing spanning sections. The leftward overhang is left in place; Phase 3b of compute_layout already bumps the global ``x_offset`` to keep that content on canvas. Standard sections (``bbox_x == -SECTION_X_PADDING``) are unaffected because the formula collapses to ``bbox_w``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 7282271) * fix(layout): tighten lower-row gap left by rowspan claims After _shrink_bboxes_to_content_bottom, recompute the slack between each lower row and the sections that actually end above it, and shift those rows up to close it. _compute_section_offsets sizes row_heights from pre-shrink bbox heights, which leaves visible empty space when the only section filling a row is a rowspan that has since shrunk to its content. Sections that extend into the lower row are excluded from the bottom measurement so the gap is governed by row-r-1 content, not by rowspan claims. (cherry picked from commit e2284af) * fix(layout): include mixed-bundle column-mates in section symfan When a column in a grid section has mostly full-bundle stations but one or two single-line side branches (e.g. Functional enrichment's gprofiler2/GSEA/decoupler carrying all study types plus a single ``rnaseq`` method like grea), the strict gate on ``_redistribute_full_bundle_columns`` skipped the column and left the minor branch stranded on its per-line track Y. The resulting layout broke the section's symmetric fan and pushed the line through the section at an uneven slope. Three narrow changes: 1. ``_redistribute_full_bundle_columns`` and its late-pass companion ``_recenter_full_bundle_columns`` now also fire on columns where at least one station is full-bundle and the rest are non-source strict-subset siblings (i.e. real fan-out branches with a predecessor, not file inputs). This mixed-mode fan only fires when the section already has another all-full-bundle column, so ``_redistribute_fanout_siblings`` keeps its sole responsibility for "unique trunk + siblings" patterns (e.g. limma + dream/DESeq2 /propd in the differential section). 2. ``_redistribute_fanout_siblings`` now ignores off-track column- mates (matching the off-track skip the full-bundle pass already has) and relaxes its sibling gate from "shares a predecessor with the trunk" to "has at least one predecessor". A subset sibling fed by a separate upstream method is still a real fan-out branch and should slot into the symmetric fan; only source files (in-degree 0) keep their per-line track Y. 3. Off-track inputs are re-anchored to their consumer twice: once after grid-snap (already present), and again after the late-pass ``_recenter_full_bundle_columns`` shifts the consumer onto the row-wide trunk Y. Without the second pass the input icon stayed at the pre-recenter Y, visibly overlapping the consumer station. All 736 existing tests still pass; v97-style "all full bundle" columns are byte-identical. v98's Functional enrichment section now renders as a clean 2x2 symmetric fan with the off-track Gene sets / Network icons visibly above the trunk line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 763f292) * fix(layout): prefer trunk feeder Y at exit ports + invariant tests When an LR/RL section's exit port collects both a full-bundle trunk station and a minor single-line side-branch feeder (e.g. propd -> grea in differentialabundance), the prior code averaged feeder Ys per line. A lone below-trunk feeder pulled rnaseq's representative Y downward, reordering the bundle at the port; horizontal reconciliation then shifted the trunk station's offsets to match, producing a visible 9px kink at the section boundary (the "limma kink"). Detect the trunk feeder (a feeder whose lines cover the port's line set) and use its Y as the per-line representative. Side branches no longer rotate the bundle; the kink stays at the side branch where it belongs. Adds tests/test_layout_invariants.py with four cross-section checks that fail on the pre-fix layout for the differentialabundance graph: - test_row_trunk_marker_cy_consistent: trunk markers share cy within each grid row - test_symfan_pairs_share_y: column-mate fan pairs are mirrored around the trunk - test_off_track_inputs_above_consumer: off-track inputs sit above their consumer - test_no_kink_at_section_boundary: adjacent exit / entry ports render at the same cy Fixtures: copies of the da_pipeline and rnaseq_sections graphs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 9d93c1f) * fix(layout): side-branch late diagonal + bbox grows with re-anchor [skip ci] Two related routing/layout fixes for the differentialabundance metro map: 1. Side-branch single-line exits no longer climb to the trunk bundle immediately after the source station. When a non-port internal station sits clearly off its section's trunk Y and its only outgoing line targets either the section's exit port or another internal station on the trunk, the diagonal placement is biased toward the target (overriding the standard fork bias). The line then stays on its own track for most of the section, only climbing to trunk Y just before exiting. Concretely fixes propd's rnaseq exit edge in section 2: pre-fix the line climbed to trunk Y at x=654 (25px past the source); now it stays at y=320 until x=860 (230px) and climbs only in the last 55px before the section's right edge. 2. ``_reanchor_off_track_to_consumer`` grows the section bbox upward when the final re-anchored off-track input position would put the icon outside the section's padding zone, and shifts the whole graph down when that growth pushes any bbox above the canvas top margin. The row top-align phase is re-run after the second re-anchor so row mates keep flush bbox tops. Concretely fixes section 3 (functional) where gmt_in's terminus icon extended 5.5px above the bbox top in v102. New invariant tests: - test_side_branch_edge_stays_off_trunk: side-branch exit edges must keep the line at the source Y for at least 30% of the source->target horizontal run. - test_section_bbox_contains_all_content: every section bbox must enclose its on-track stations and off-track icons (accounting for marker / terminus half-height). Both fail on the pre-fix code and pass with the fixes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 2f26d11) * fix(layout): auto-balance sections and converge multi-source termini Three v104 improvements with invariant regression tests: 1. _balance_section_content_around_trunk: a new layout phase that redistributes fan-out siblings to fill empty bands above the trunk. Lifts the bottommost (homogeneous-line) or topmost (heterogeneous- line) below-trunk movable into a new top slot when the bbox has room for the marker plus its above-marker label; otherwise swaps the bottommost below-trunk movable with the topmost above-trunk station to reorder the band without growing the bbox. Uses a line-aware feeder walk so single-feeder lifts aren't blocked by unrelated stations sharing a junction port. 2. _insert_terminus_convergence_stations: parser pass that inserts a hidden convergence station before any terminus with 2+ inbound sources so the routing engine allocates a column for the merge. Diagonals from the sources meet at the converge station and the final segment to the terminus marker is orthogonal. 3. Section 1 (data_prep) and Section 2 (differential) of the differentialabundance pipeline now fill their above-trunk bands instead of stacking content below the trunk. New invariant tests in test_layout_invariants.py: - test_section_top_band_filled - test_section1_input_above_trunk - test_terminus_not_directly_after_diagonal All three fail on the da-render-integration baseline and pass after the fix; the 741 existing tests continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 881fdb4) * fix(layout): keep balance-pass lift inside section bbox The v103 test_section_bbox_contains_all_content invariant requires that station markers (9.5 px half-height) and off-track file icons (16 px half-height) stay inside their section bbox. v104's `_balance_section_content_around_trunk` clamped `new_y` only against label clearance, so empty-label file inputs (e.g. `cel_in[ ]`) could land flush with `bbox_y` and overflow by half a marker. Bump the floor to `max(label_clearance, marker_half)` where `marker_half` is 16 px for off-track icons and 9.5 px for on-track markers, matching v103's invariant geometry. (cherry picked from commit 3ced84b) * feat(layout): auto half-grid placement for 2-branch symmetric fans When a section contains exactly two on-track branch stations sharing a column (no off-track inputs, no extra on-track content), place them at trunk_y +/- 0.5 * y_spacing instead of trunk_y +/- 1 * y_spacing. The section then needs only one vertical grid unit for the fan instead of two, removing wasted vertical space in topologies like the differentialabundance Plots section. Trunk Y stays on the integer grid; only the two branch stations sit at half-pitch. They are tracked in graph._half_grid_station_ids so the final snap-to-grid pass leaves them at their half-pitch offsets and ignores them when computing the row grid origin. The dedicated Phase 13d3 (_apply_half_grid_2branch_symfan) handles sections that sit alone on a row, which the per-row redistribute / recenter passes skip; the existing redistribute / recenter passes also honour the trigger for row-shared sections. Trigger is fully automatic; no directive is exposed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f6cc949) * fix(layout): bypass non-consuming stations via virtual station insert Lines that flow through a section but aren't consumed by every station-column they cross were previously routing through the non-consumer's marker bbox. The differential-abundance pipeline shows this at ``Annotate results``: ``limma`` carries rnaseq/affy/maxquant/geo, but only rnaseq+affy stop at the annotate marker -- maxquant and geo continue to the section's exit port and are pinned to the same trunk Y, crashing the annotate pill. Extend the v104 terminus-convergence pattern to handle this case: inserting a hidden virtual station ``V`` (``is_hidden=True``) in the bypassed station's section gives the routing engine a column-mate to fan to/from, so the bypass uses the same fork-out and fork-in diagonals the rest of the diagram uses for parallel branches. Trigger (in ``_insert_bypass_stations``, run after ``_resolve_sections``): 1. For each non-port, non-hidden, non-terminus station S, gather consumed_lines(S) from inbound edges. 2. For each in-section predecessor P with layer(P) < layer(S), look at outbound edges ``P -> exit_port`` carrying a line L not in consumed. 3. Require P also feeds the same exit_port with at least one line from consumed_lines(S) -- this is the signal that P and the exit port both sit on the trunk that S is on, so the non-consumed line would cross S's row. 4. Replace ``P -> exit_port (L)`` with ``P -> V (L) + V -> exit_port (L)``. Per-section layers are computed locally (intra-section subgraph, including ports) with exit ports pinned past internal stations, so longest-path quirks where two layer-equal stations sit in adjacent columns don't suppress the trigger. The hidden bypass station renders with no marker and no label (``is_hidden`` is already excluded by ``render/svg.py`` and the collision resolver), so the visual is indistinguishable from ``Annotate results``' column having a second pill above/below the trunk -- which is exactly the "parallel branch with an anonymous station" effect the design language calls for. Add ``test_non_consumed_lines_route_via_virtual_station`` over ``da_pipeline.mmd`` that: * asserts at least one ``__bypass_*`` hidden station exists; * asserts the bypass V chains ``limma -> V -> exit_port`` for both maxquant and geo; * asserts maxquant and geo rendered routes clear annotate's marker bbox (half-bbox + slack). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 384befe) * fix(layout): block balance lift onto off-track icon slot The auto-balance pass (Phase 13h2) lifted below-trunk movable siblings into the topmost empty above-trunk slot without checking whether that slot was already reserved by an off-track input icon in the same column. In the differentialabundance functional section, ``gmt_in`` (off-track, lifted to y=100) and ``grea`` (a single-line rnaseq sibling at y=265) shared column 1040.7; the lift moved ``grea`` to y=100 directly on top of ``gmt_in``'s file icon, producing a marker collision in the rendered SVG. Fix: when selecting a balance candidate, walk every below-trunk movable sibling and skip any whose column already contains a station (incl. off-track) at the prospective lift Y. Off-track icons now reserve their column slot from subsequent placement. Add a post-layout validator ``_guard_no_station_overlap`` that errors with ``Position clash: <name1> ... overlaps <name2> ...`` when any two station / file-icon marker bboxes intersect, so end users running ``nf-metro render`` against a pipeline that triggers a similar regression get a clear actionable message rather than a silently broken render. Add the matching invariant ``test_no_station_or_icon_overlap`` parametrised over ``da_pipeline.mmd`` and ``rnaseq_sections.mmd`` to lock the fix in regression coverage. (cherry picked from commit 9116778) * fix(layout): tighten bypass V hop to span only V's column The v110 bypass virtual station produced a diagonal route immediately at the predecessor station (e.g. ``limma``) instead of just before the bypass V, so the bypass line ran parallel below the trunk for most of the bypassed station's section. In the differential-analysis section the maxquant / geo lines diverged at limma's X (~629) and re-converged only at the exit port (~908), creating a long visible side-branch. When the diagonal target (or source) is a hidden ``__bypass_`` station, treat the bypass V as a join (or fork) anchor in ``_route_diagonal``. ``_compute_diagonal_placement`` then biases the diagonal toward V, so the hop diverges just before V's column (~30 px upstream) and re-converges just after V's column. The remaining trunk segment is no longer interrupted by a parallel branch. Add the matching invariant ``test_bypass_avoids_off_track_inputs`` that asserts each ``__bypass_*`` virtual station sits at least one marker clearance (~12 px) away from any off-track input icon in the same column. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 91191b3) * fix(layout): clean bypass routing, compact sections, centre fan-out Issue 1: Remove __bypass_*-specific routing in _route_diagonal. Bypass virtual stations are now indistinguishable from any other off-trunk station for routing/layout; only the SVG renderer skips their marker. Issue 2: Add below-trunk compaction in _balance_section_content_around_ trunk. When the first row below the trunk is empty but content sits deeper, lift the entire below-trunk stack up by one y_spacing slot. Honours marker-clearance and column-collision constraints. Issue 3: Add _recenter_loop_side_stations phase. Layer-based X placement biased fan-out side stations (propd, dream, DESeq2, and the bypass V itself) toward the fork side. Recompute their X as the midpoint of the two diagonal corners derived from the routing geometry so they sit centred on the loop's horizontal run. Issue 4: Shrink bbox top in _apply_half_grid_2branch_symfan after the ±27.5 placement. The bbox was sized for full ±55 spread, leaving a half-y_spacing gap above the first half-grid branch. Adds four invariant tests covering each fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 4b2265a) * fix(layout): tighten bypass V hop, shift sparse loop stations clear Issue 1: The v113 cleanup (4b2265a) removed the bypass-routing override that biased ``P -> V`` diagonals toward V (treating V as a join) and ``V -> T`` diagonals away from V (treating V as a fork). Without the override the limma -> V -> exit_port loop in the differential section diverged just past limma (at the fork bias) and ran 124 px parallel below the trunk before re-converging, instead of the short 50-px hop the bypass V was designed to produce. Restore the override; the hop now spans only V's column. Issue 2: ``grea`` in the differential-functional section sat at the same Y row as ``decoupler`` (both at the section's below-trunk row), so the non-consumed lines (affy, maxquant, geo) flowing from the section entry port to decoupler crossed grea's marker bbox at y~268 (grea bbox 260-270). The bypass V mechanism only fires for direct ``pred -> exit_port`` edges and doesn't cover this same-layer sibling pattern. Add a Phase 13k that detects loop side stations with a single-line consumption set, sharing their row Y with a busier sibling in the same section, and shifts them by half a ``y_spacing`` further from the trunk. Section bbox grows to accommodate. The right invariant we lacked: ``_guard_no_station_overlap`` catches marker/marker collisions (Issue A) but not line/marker crossings (Issue B, the "breeze-past" pattern). Add the runtime validator ``_guard_no_line_crosses_non_consumer`` and the matching invariant test ``test_lines_dont_cross_non_consumer_markers`` that parse the routed polylines and assert no segment of a non-consumed line crosses any station's marker bbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit df746c4) * Revert "fix(labels): stagger labels of vertically stacked same-column stations" This reverts the label-stagger pass (originally d9f98c1, cherry-picked from 265b7e4). Subsequent layout work and the v113 invariant suite make the stagger heuristic unnecessary, and dropping it keeps the label placer simpler ahead of the v114 bypass tightening. * fix(layout): tighten bypass V hop, shift sparse loop stations clear Issue 1: The v113 cleanup (4b2265a) removed the bypass-routing override that biased ``P -> V`` diagonals toward V (treating V as a join) and ``V -> T`` diagonals away from V (treating V as a fork). Without the override the limma -> V -> exit_port loop in the differential section diverged just past limma (at the fork bias) and ran 124 px parallel below the trunk before re-converging, instead of the short 50-px hop the bypass V was designed to produce. Restore the override; the hop now spans only V's column. Issue 2: ``grea`` in the differential-functional section sat at the same Y row as ``decoupler`` (both at the section's below-trunk row), so the non-consumed lines (affy, maxquant, geo) flowing from the section entry port to decoupler crossed grea's marker bbox at y~268 (grea bbox 260-270). The bypass V mechanism only fires for direct ``pred -> exit_port`` edges and doesn't cover this same-layer sibling pattern. Add a Phase 13k that detects loop side stations with a single-line consumption set, sharing their row Y with a busier sibling in the same section, and shifts them by half a ``y_spacing`` further from the trunk. Section bbox grows to accommodate. The right invariant we lacked: ``_guard_no_station_overlap`` catches marker/marker collisions (Issue A) but not line/marker crossings (Issue B, the "breeze-past" pattern). Add the runtime validator ``_guard_no_line_crosses_non_consumer`` and the matching invariant test ``test_lines_dont_cross_non_consumer_markers`` that parse the routed polylines and assert no segment of a non-consumed line crosses any station's marker bbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit df746c4) * refactor: tighten cherry-pick fallout in clearance and icon render * _terminus_icon_clearance now delegates to caption_aware_icon_step instead of re-inlining the caption-aware step computation -- the docstring already noted it should mirror the renderer. * _render_terminus_icons: drop duplicate icon_cy / icon_types / names / caption_font_size / name_widths bindings introduced when the v114 cherry-pick landed on top of the main version of this block. * style: ruff format and remove unused ICON_INTER_GAP import ruff format reflowed three files; ruff check flagged the now-unused ICON_INTER_GAP import in engine.py (the inline caption-step loop previously used it; the function now delegates to caption_aware_icon_step). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(labels): stagger labels of vertically stacked same-column stations When two stations in the same section share an X column and adjacent Y tracks (e.g. Affy and Proteus in the differentialabundance metro map), their centered labels visually crowd along the same vertical line. Adds a post-placement pass that nudges labels horizontally in opposite directions, only when the shift would not introduce a new collision. [skip ci] (cherry picked from commit 265b7e4) * fix: re-align row bboxes after port-terminus spacing Phase 11 _space_ports_from_termini calls _expand_bbox_for_y when it pushes a port outside the section bbox, which can lift a single section's bbox_y above its row-mates. Phase 11b _recompute_grid_group_bboxes then sets bbox_y to min(non_port_ys) - max_y_pad and re-expands for off-grid ports, baking the shift in. Phase 9 _top_align_row_sections runs before Phase 11 so it can't undo the resulting offset. Add a Phase 11c re-run of _top_align_row_sections so bbox tops within each row stay flush after the port-terminus spacing pass. Repro: render examples/differentialabundance with port-pushed entry on the functional section. Before: functional bbox_y=40, others 85, visible 45px vertical offset. After: all sections at bbox_y=85. (cherry picked from commit 9b5d298) * fix(layout): block station overlaps when track compaction collides cells The track allocator can return identical track values for two stations on different lines when each is the sole occupant of its line at a given layer (e.g. side-by-side terminus branches like the Affy/Proteus microarray and proteomics terminals in nf-core/differentialabundance). After coordinate assignment they land on the same (x, y), causing the pills and labels to render directly on top of one another. Add a post-placement pass in _layout_single_section that buckets real stations by their primary axis (layer column for LR/RL, row for TB) and pushes any secondary-axis duplicates apart by one spacing unit. Insertion order in sub.stations is the stable tiebreaker so the earlier-defined station keeps its slot. The pass is a no-op when there is no collision, so existing layouts are unaffected. [skip ci] (cherry picked from commit 9e97491) * fix(layout): increase SECTION_Y_PADDING for bundle margin Provides visual breathing room so line bundles passing through edge-tracked stations don't touch the section bbox border. [skip ci] (cherry picked from commit 72e8379) * fix(layout): anchor inter-section exit ports to downstream bundle Y When an LR/RL exit port's bundle materialises at a single same-row Y in the downstream section (parallel fan-out where every internal station carries the same line set), pull the exit port to that Y so the inter-section run stays horizontal. Falls back to the local-internal centre when the exit has fan-in from distinct Ys, the downstream entry is a branch fan-out, multiple downstream entries land at different Ys, or the anchor falls outside the section bbox. Eliminates the drop from differential.annotate (centred) to functional.gsea (top fan-out station) on the differentialabundance map. (cherry picked from commit edb06e4) * fix(layout): anchor fan-in exit to downstream when bundle is multi-line Phase 10d preserved centred midpoints for any 3+ source fan-in exit, which kept variantbenchmarking's filter merge correct but left the differentialabundance functional exit centred between gprofiler2 and decoupler even though all four sources carry the same four-line bundle into shinyngs and quarto. The inter-section run then curved up to the reporting entry Y instead of staying horizontal. Relax the 3+ fan-in skip when the exit carries a multi-line bundle (>=2 lines, so every source is parallel-redundant) and one source Y already matches the downstream entry: snap to that Y for a straight inter-section run. Single-line fan-ins still keep their midpoint, so the variantbenchmarking TestFanInExitPreservation assertions hold. (cherry picked from commit 7ce8a2d) * fix(layout): anchor trunk junctions on bundle line for straight trunks Trunk stations - those receiving a pre-existing bundle from at least one predecessor plus side branches - previously snapped to the predecessor centroid, bending the bundle within the section. Detect this case in both _place_single_node (for fan-in junctions) and _place_fan_out (when one fan-out node carries a strict superset of its siblings' lines) and anchor the trunk node on its primary line's base track so the bundle stays straight through the junction. (cherry picked from commit bfde7b0) * fix(layout): symmetric fan-out of trunk siblings, strictly scoped Resurrects the PR #264 idea (symmetric distribution of side stations around the section trunk Y when ``--center-ports`` is on) but scopes it tightly so it ONLY moves stations that are unambiguously fan-out side branches of a trunk junction. A column qualifies for redistribution only when it contains exactly one station whose line set equals the section's full LEFT/RIGHT bundle (the trunk junction) AND at least one sibling station whose line set is a strict subset of the bundle. Those siblings are redistributed in alternating ``+1, -1, +2, -2, ...`` slots at ``y_spacing`` pitch around the trunk Y; the trunk itself stays pinned. Everything else is left in place: file inputs, linear processing chains, fan-in structures (many sources converging on one station), columns without a unique trunk, and any sibling that carries the full bundle (linear pass-through, not a fan-out branch). This fixes the original PR #264 regression where data-import-style sections (fan-in to a Validate node, then linear to Filter matrix) had their input labels and chain stations mixed at random Ys. In nf-core/differentialabundance: - Data import: untouched (no qualifying column, only file inputs and processing chains). - Differential: limma is the trunk; DESeq2/dream/propd carry only ``{rnaseq}`` (strict subset) and now fan symmetrically above and below limma's Y. - Functional: gprofiler2/decoupler/grea carry the full bundle and are intentionally not redistributed; file inputs (Gene sets, Network) are in a different column from GSEA and stay put. - Reporting: no qualifying trunk-junction column; untouched. Default (``--no-center-ports``) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 6415807) * feat(layout): add %%metro off_track: directive to lift file inputs above line tracks File-input stations declared in the flow graph (e.g. samples_in[Samples] + %%metro file: samples_in | CSV) normally consume a line-track Y slot because each station owns a track. This makes input file icons share Y with the study-type line lanes, defeating the visual goal of an "inputs band" above the section's pipeline tracks. Add a new %%metro off_track: id1, id2, ... directive that marks stations to be lifted into a separate band above the section's topmost line track in a final post-layout phase. Section bboxes expand upward to fit the band, and the whole graph is shifted down if needed so the new top stays inside the canvas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit dca94e9) * fix(layout): align trunk Ys across same-row sections Previously sections in the same grid row could have their inter-section trunk bundles enter and exit at different Y values when the upstream section had more above-trunk content than the downstream section. The visible result was lines bending vertically between section bboxes even though the bbox tops were aligned. Add a new Phase 11ca _align_row_trunk_ys. For each contiguous column group of LR/RL sections within a grid row, identify each section's trunk station (the full-bundle station connected to an LR port) and shift content downward in sections whose trunks sit above the row's deepest trunk. Bbox tops are preserved (heights grow downward). Row-spanning sections (rowspan > 1) are skipped to avoid disturbing cross-row vertical relationships. After the shift, ports on shifted sections are re-snapped to the target trunk Y when they have a single internal station at that Y; fan-in convergence ports (2+ internal sources at distinct Ys) are preserved at their centered midpoint so Phase 10d's fan-in geometry isn't disturbed. Update the grid-alignment regression tests: first_station_y consistency and symmetric bbox padding are stricter than top alignment and are not satisfied when sections shift to align trunks. Replace with bbox-top alignment and a non-negative bottom-padding floor. (cherry picked from commit 43e387c) * fix(layout): align bbox tops after off-track lift The %%metro off_track directive lifts file inputs above the line tracks and expands the section's bbox upward. This left other sections in the same row with their bbox tops misaligned because the row top-align phase ran before off-track lifting. Add Phase 13a (_top_align_row_bboxes_only) after off-track lifting to grow row-mate bboxes upward to the row's topmost bbox. Unlike earlier top-align phases, this only adjusts bbox_y/bbox_h - station Ys stay in place so the lifted section's content is preserved and the rest of the row gets empty space at the top of its bboxes. (cherry picked from commit 2d7a663) * fix(layout): compact row sections so content sits at bbox top Phase 11ca aligns trunk Ys across row-mate sections and Phase 13a expands shorter row-mates' bbox tops to match a section whose bbox grew upward in Phase 13 to accommodate off-track inputs. Without a follow-up, the row-mates without off-track ended up with empty vertical space at the top of their bboxes. Add Phase 13b _compact_row_content_to_bbox_top. For each grid row's contiguous column group: 1. Compute the allowable upward shift per section based on: - distance from on-track content to bbox top (minus padding), and - clearance to any lifted off-track band (so on-track content doesn't collide with the lift band that Phase 13 placed ``y_spacing`` above the on-track top). Apply the minimum allowable shift across the group to every on-track station and port; off-track stations stay anchored to the lift band. Uniform shift preserves trunk alignment. 2. Shrink each section's bbox_h so the bottom slack matches section_y_padding (clamped to keep bottom ports inside the bbox). Sections with grid_row_span > 1 are excluded because their content spans multiple rows and the per-row compaction frame doesn't apply. (cherry picked from commit 2831431) * feat(icons): optional caption name on file/files/dir directives Extend `%%metro file:`, `%%metro files:` and `%%metro dir:` with an optional third field for a human-readable caption rendered directly below the icon. This avoids the previous workaround of attaching a separate naming station that rendered as a process circle. Syntax: `%%metro file: station_id | TYPE | NAME` The caption uses the theme label color/font and is included in the icon's obstacle bbox so neighbouring labels keep their distance. (cherry picked from commit c435357) * fix(layout): compact rowspan sections, skip off-track termini, pad captioned bands Three coupled fixes around off-track stations and rowspan sections: * `_compact_row_content_to_bbox_top` now compacts ``grid_row_span > 1`` sections instead of skipping them. They form their own single-section groups since rowspan sections trunk at a Y of their own and don't share a horizontal inter-section bundle with row mates. * `_space_ports_from_termini` skips off-track stations when collecting termini. They get lifted above the topmost line track later, so they no longer share a Y with the inter-section bundle; treating them as termini was pushing ports above the on-track row (and dragging the upstream port via junction propagation), causing a visible kink in the inter-section bundle. * When a captioned off-track icon sits at the bottom of the lifted band, `_compact_row_content_to_bbox_top` widens its per-section clear-shift gap by the caption extent so the optional caption text (PR #269) doesn't end up overlapping the topmost line track after compaction. (cherry picked from commit 088edd2) * fix(layout): caption scaling, inter-section port snap, top-fill fan Polishes the explicit-grid layout pipeline so trunk lines flow cleanly across section boundaries and large bbox top slack stops looking empty. * Caption text under file icons now renders at 60% of the label font size and clamps its X within the section bbox so long captions (e.g. ``MaxQuant``, ``GEO ID``) stop spilling onto the section border. Compaction reserves the smaller caption extent in the off-track gap calculation. * New Phase 13c snaps LR/RL exit port Ys to the downstream entry Y for same-row connections so a row-spanning section's trunk no longer kinks at the section boundary; junctions are re-positioned afterwards. Gated on user-supplied ``%%metro grid:`` directives to preserve auto-layout line-offset ordering. * New Phase 13d redistributes trunk-candidate siblings symmetrically around the topmost trunk station when a section retains visible empty top space (e.g. Reporting fed from Functional with off-track inputs), filling the bbox top instead of leaving it blank. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit cc28ae5) * fix(layout): skip upward fan on U-turn and dodge label-vs-diagonal Two polish fixes to PR #272's top-fill fan and the label placer: * Phase 13d (`_fan_free_content_upward`) now skips lifting a trunk candidate whose incoming bundle has multiple external feeders all at or below the row's trunk Y. Lifting in that case forces the bundle to climb against the trunk (a "U-turn") and visibly bends past sibling routes that stay at trunk Y. Reporting section in nf-core/differentialabundance: Shiny app now sits on the trunk and Quarto report stays below it. * New `_avoid_diagonal_routes` pass after label placement flips a label to the opposite side of its station when its bbox overlaps a non-horizontal route segment (and the flipped position is collision free). Trunk horizontals are deliberately ignored. Fixes the GSEA label overlapping the off-track icon descent in section 3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 30158a0) * fix(layout): snap station and port Ys to row-wide grid Final layout phase snaps every non-port station and every LEFT/RIGHT port to a clean grid pitch. Earlier phases (trunk-Y align, downstream port-snap, fold-exit alignment) compute shifts that don't respect the grid, leaving coordinates at fractional Ys (e.g. 298.785 when the pitch is 55). Group sections by row using ``_row_y_grid_info``, derive ONE shared grid origin per row from the mode of (y % pitch) across all non-port stations in the row, and snap every station and LR/RL port in the row to that origin. Sections outside any row group snap to the input ``y_spacing`` with their own origin. A per-section origin (as in the prior pass) drifted between sections in the same row when one section was on residue 0 and the next on residue 23.9, kinking trunks at section boundaries. Sharing the origin across the row keeps trunks co-linear. - bounds each snap by half a pitch so adjacency cannot flip - skips off-track stations and TOP/BOTTOM ports whose Y is fixed (cherry picked from commit b9a10ab) * fix(layout): symmetric fan for terminal full-bundle columns Terminal sections (no exit ports) sometimes hold a column of stations that all carry the full bundle and all receive from the same upstream branches, with no obvious trunk station to pin. The existing fan-out logic requires a unique trunk + strict-subset siblings, so it leaves these columns stacked tightly. Add a second pass that runs only when --center-ports is on and the section has no exit ports: when a column's stations are all full-bundle (>=2), redistribute them symmetrically around a trunk Y derived from other full-bundle stations in the section (or the LR port Y). Even counts leave the trunk row empty; odd counts keep one station on the trunk. Non-terminal sections, sub-bundle siblings, file inputs and fan-in chains are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit b2ca836) * fix(layout): symfan all full-bundle columns and pin off-track to consumer (cherry picked from commit 4a5d184) * fix(layout): pin source stations to per-line track, not trunk fan `_redistribute_fanout_siblings` (PR #266) rearranges fan-out branches symmetrically around the trunk junction of a column when `--center-ports` is on. Its scope was meant to exclude file-input columns, but in nf-core/differentialabundance the data-import section has `meta_in` carrying the full bundle and the other source inputs (matrix_in, cel_in, mq_in, geo_in, gtf_in) each carrying a strict subset. Those siblings were redistributed around `meta_in` even though they aren't a real fan-out, pushing every file icon off its line's track and forcing each consumer line to traverse the entire section to reach its input. Tighten the scope so a sibling only qualifies when it shares at least one predecessor with the trunk station. Real fan-outs (differential's limma + DESeq2/dream/propd, all fed by matrix_filter) still redistribute; columns of source stations stay on their per-line track Y so the file icon for each line sits adjacent to its consumer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit a76a21e) * fix(layout): preserve trunk-Y alignment for rowspan sections during compact `_compact_row_content_to_bbox_top` (PR #278) isolated every ``grid_row_span > 1`` section into its own group on the assumption that rowspan sections "trunk at a Y of their own". That assumption breaks for sections like nf-core/differentialabundance's data_prep, which rowspans down over a row-1 plots block but whose trunk Y still sits on the row-0 inter-section bundle. In isolation, data_prep computed an 82.5px upward shift to remove its (now legitimately large) top padding, pulling validator and matrix_filter above the row's trunk Y and breaking the horizontal bundle from data_prep into differential. Downstream ``_snap_all_y_to_grid`` then flipped the row's grid mode onto the shifted residue, snapping differential/reporting fan-out columns onto the same Y slot (dream/limma, deseq2/propd, bundle/report_html all collided). Keep the rowspan exclusion only when the rowspan section's trunk Y differs from its column-adjacent neighbour's. When trunks align, compact groups them together so the shared upward shift is bounded by the smallest tolerance in the group, preserving the inter-section bundle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 85d2114) * fix(layout): lift source-stack inputs above the trunk to fill top space `_fan_free_content_upward` only fans full-bundle trunk candidates upward, so a section whose entry column has a single full-bundle station plus subset-bundle source inputs (file icons with no inbound edges, e.g. nf-core/differentialabundance's data-import section) leaves its bbox top empty: every source sits at or below the trunk. Add a companion phase `_fan_source_inputs_upward` that detects this shape and lifts the nearest-to-trunk sources into the empty top band. Each lifted source drags its linear consumer chain (single inbound edge, identical line set, section-internal) so per-line tracks stay straight from icon to junction. Scoped to explicit `%%metro grid:` pipelines. Bounded by `min(slots, n_sources // 2)` so a majority of sources stay below the trunk; capped by `(trunk_y - bbox_y - y_spacing / 4) // y_spacing` so the topmost lifted station stays inside the bbox with a margin. U-turn risk is nil because sources have no upstream feeders. Differentialabundance section 1 now shows GTF + Matrix above the trunk and Affy CEL + MaxQuant + GEO below, filling the 132.5px of previously-empty bbox top space. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f02d705) * fix(layout): three v93 layout polish issues 1. Compact below-trunk sources after source-input lift When ``_fan_source_inputs_upward`` lifts the top N sources above the trunk (e.g. GTF, Matrix in differentialabundance section 1), the remaining below-trunk sources stayed at their original Ys, leaving a multi-slot empty band between the trunk row and the first below-trunk source. Pack the remaining sources upward at ``trunk_y + i*y_spacing`` so the gap collapses. 2. Re-fan full-bundle columns around the row's final trunk Y ``_redistribute_full_bundle_columns`` runs early using local LR port Ys as the symmetric centre. For terminal sections whose sole LR port Y differs from the row's eventual trunk Y (e.g. Reporting's entry port at the bundle-column Y rather than the row trunk Y), the fan ends up offset from the trunk row. Add Phase 13g ``_recenter_full_bundle_columns`` to re-distribute each multi-station full-bundle column around the entry/exit port station Y, which by that point sits on the row's inter-section bundle Y. 3. Stagger captions when adjacent file icons would overlap Stations carrying multiple ``%%metro file:`` directives place icons at ``terminus_width + ICON_INTER_GAP`` (32 px) intervals. Captions like ``Samples`` and ``Contrasts`` measure ~50 px wide and overlap when both sit at the same Y. Detect the overlap from the estimated caption widths and drop every other caption to a second row beneath the first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 9322198) * fix(layout): v94 polish - terminus Y, caption fit, rowspan trim - Pin a single-station downstream terminus to its sole in-section upstream's Y after full-bundle column re-centering, so file icons like the Reporting ``Bundle`` ZIP line up with their source station. - Replace the "drop alternate captions to row 2" overlap workaround with a horizontal spread: ``_render_terminus_icons`` now widens the per-icon centre-to-centre step when adjacent captions would collide, keeping all captions on one row. ``_terminus_icon_clearance`` expands the section bbox by the same amount so the wider icon row still fits. ``_build_section_subgraph`` now copies ``terminus_names`` / ``terminus_icon_types`` so the layout-side clearance check sees the captions. - Re-shrink section bboxes to their content bottom after late lifts (``_fan_source_inputs_upward``, ``_recenter_full_bundle_columns``): rowspan>1 sections like ``data_prep`` no longer keep the inflated height once content has been pulled up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f2dfda8) * fix(layout): exclude leftward bbox overhang from column width Sections whose ``bbox_x`` is pushed further left than the standard ``-SECTION_X_PADDING`` (e.g. by ``_adjust_terminus_icon_clearance`` expanding the bbox to fit file icons on source stations) had that extra overhang counted into the column width. Adjacent columns were placed at ``offset_x + col_widths[col] + section_x_gap``, so the leftward overhang inflated every gap downstream of the expanded column. On nf-core/differentialabundance this widened the gap between section 1 and section 2 from 50px to ~103px while 2-3 and 3-4 stayed at the expected 50px. Use a small ``_effective_width`` helper that re-anchors the bbox to the standard left edge (``bbox_x + bbox_w + SECTION_X_PADDING``) when measuring per-column widths and when sizing spanning sections. The leftward overhang is left in place; Phase 3b of compute_layout already bumps the global ``x_offset`` to keep that content on canvas. Standard sections (``bbox_x == -SECTION_X_PADDING``) are unaffected because the formula collapses to ``bbox_w``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 7282271) * fix(layout): tighten lower-row gap left by rowspan claims After _shrink_bboxes_to_content_bottom, recompute the slack between each lower row and the sections that actually end above it, and shift those rows up to close it. _compute_section_offsets sizes row_heights from pre-shrink bbox heights, which leaves visible empty space when the only section filling a row is a rowspan that has since shrunk to its content. Sections that extend into the lower row are excluded from the bottom measurement so the gap is governed by row-r-1 content, not by rowspan claims. (cherry picked from commit e2284af) * fix(layout): include mixed-bundle column-mates in section symfan When a column in a grid section has mostly full-bundle stations but one or two single-line side branches (e.g. Functional enrichment's gprofiler2/GSEA/decoupler carrying all study types plus a single ``rnaseq`` method like grea), the strict gate on ``_redistribute_full_bundle_columns`` skipped the column and left the minor branch stranded on its per-line track Y. The resulting layout broke the section's symmetric fan and pushed the line through the section at an uneven slope. Three narrow changes: 1. ``_redistribute_full_bundle_columns`` and its late-pass companion ``_recenter_full_bundle_columns`` now also fire on columns where at least one station is full-bundle and the rest are non-source strict-subset siblings (i.e. real fan-out branches with a predecessor, not file inputs). This mixed-mode fan only fires when the section already has another all-full-bundle column, so ``_redistribute_fanout_siblings`` keeps its sole responsibility for "unique trunk + siblings" patterns (e.g. limma + dream/DESeq2 /propd in the differential section). 2. ``_redistribute_fanout_siblings`` now ignores off-track column- mates (matching the off-track skip the full-bundle pass already has) and relaxes its sibling gate from "shares a predecessor with the trunk" to "has at least one predecessor". A subset sibling fed by a separate upstream method is still a real fan-out branch and should slot into the symmetric fan; only source files (in-degree 0) keep their per-line track Y. 3. Off-track inputs are re-anchored to their consumer twice: once after grid-snap (already present), and again after the late-pass ``_recenter_full_bundle_columns`` shifts the consumer onto the row-wide trunk Y. Without the second pass the input icon stayed at the pre-recenter Y, visibly overlapping the consumer station. All 736 existing tests still pass; v97-style "all full bundle" columns are byte-identical. v98's Functional enrichment section now renders as a clean 2x2 symmetric fan with the off-track Gene sets / Network icons visibly above the trunk line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 763f292) * fix(layout): prefer trunk feeder Y at exit ports + invariant tests When an LR/RL section's exit port collects both a full-bundle trunk station and a minor single-line side-branch feeder (e.g. propd -> grea in differentialabundance), the prior code averaged feeder Ys per line. A lone below-trunk feeder pulled rnaseq's representative Y downward, reordering the bundle at the port; horizontal reconciliation then shifted the trunk station's offsets to match, producing a visible 9px kink at the section boundary (the "limma kink"). Detect the trunk feeder (a feeder whose lines cover the port's line set) and use its Y as the per-line representative. Side branches no longer rotate the bundle; the kink stays at the side branch where it belongs. Adds tests/test_layout_invariants.py with four cross-section checks that fail on the pre-fix layout for the differentialabundance graph: - test_row_trunk_marker_cy_consistent: trunk markers share cy within each grid row - test_symfan_pairs_share_y: column-mate fan pairs are mirrored around the trunk - test_off_track_inputs_above_consumer: off-track inputs sit above their consumer - test_no_kink_at_section_boundary: adjacent exit / entry ports render at the same cy Fixtures: copies of the da_pipeline and rnaseq_sections graphs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 9d93c1f) * fix(layout): side-branch late diagonal + bbox grows with re-anchor [skip ci] Two related routing/layout fixes for the differentialabundance metro map: 1. Side-branch single-line exits no longer climb to the trunk bundle immediately after the source station. When a non-port internal station sits clearly off its section's trunk Y and its only outgoing line targets either the section's exit port or another internal station on the trunk, the diagonal placement is biased toward the target (overriding the standard fork bias). The line then stays on its own track for most of the section, only climbing to trunk Y just before exiting. Concretely fixes propd's rnaseq exit edge in section 2: pre-fix the line climbed to trunk Y at x=654 (25px past the source); now it stays at y=320 until x=860 (230px) and climbs only in the last 55px before the section's right edge. 2. ``_reanchor_off_track_to_consumer`` grows the section bbox upward when the final re-anchored off-track input position would put the icon outside the section's padding zone, and shifts the whole graph down when that growth pushes any bbox above the canvas top margin. The row top-align phase is re-run after the second re-anchor so row mates keep flush bbox tops. Concretely fixes section 3 (functional) where gmt_in's terminus icon extended 5.5px above the bbox top in v102. New invariant tests: - test_side_branch_edge_stays_off_trunk: side-branch exit edges must keep the line at the source Y for at least 30% of the source->target horizontal run. - test_section_bbox_contains_all_content: every section bbox must enclose its on-track stations and off-track icons (accounting for marker / terminus half-height). Both fail on the pre-fix code and pass with the fixes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 2f26d11) * fix(layout): auto-balance sections and converge multi-source termini Three v104 improvements with invariant regression tests: 1. _balance_section_content_around_trunk: a new layout phase that redistributes fan-out siblings to fill empty bands above the trunk. Lifts the bottommost (homogeneous-line) or topmost (heterogeneous- line) below-trunk movable into a new top slot when the bbox has room for the marker plus its above-marker label; otherwise swaps the bottommost below-trunk movable with the topmost above-trunk station to reorder the band without growing the bbox. Uses a line-aware feeder walk so single-feeder lifts aren't blocked by unrelated stations sharing a junction port. 2. _insert_terminus_convergence_stations: parser pass that inserts a hidden convergence station before any terminus with 2+ inbound sources so the routing engine allocates a column for the merge. Diagonals from the sources meet at the converge station and the final segment to the terminus marker is orthogonal. 3. Section 1 (data_prep) and Section 2 (differential) of the differentialabundance pipeline now fill their above-trunk bands instead of stacking content below the trunk. New invariant tests in test_layout_invariants.py: - test_section_top_band_filled - test_section1_input_above_trunk - test_terminus_not_directly_after_diagonal All three fail on the da-render-integration baseline and pass after the fix; the 741 existing tests continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 881fdb4) * fix(layout): keep balance-pass lift inside section bbox The v103 test_section_bbox_contains_all_content invariant requires that station markers (9.5 px half-height) and off-track file icons (16 px half-height) stay inside their section bbox. v104's `_balance_section_content_around_trunk` clamped `new_y` only against label clearance, so empty-label file inputs (e.g. `cel_in[ ]`) could land flush with `bbox_y` and overflow by half a marker. Bump the floor to `max(label_clearance, marker_half)` where `marker_half` is 16 px for off-track icons and 9.5 px for on-track markers, matching v103's invariant geometry. (cherry picked from commit 3ced84b) * feat(layout): auto half-grid placement for 2-branch symmetric fans When a section contains exactly two on-track branch stations sharing a column (no off-track inputs, no extra on-track content), place them at trunk_y +/- 0.5 * y_spacing instead of trunk_y +/- 1 * y_spacing. The section then needs only one vertical grid unit for the fan instead of two, removing wasted vertical space in topologies like the differentialabundance Plots section. Trunk Y stays on the integer grid; only the two branch stations sit at half-pitch. They are tracked in graph._half_grid_station_ids so the final snap-to-grid pass leaves them at their half-pitch offsets and ignores them when computing the row grid origin. The dedicated Phase 13d3 (_apply_half_grid_2branch_symfan) handles sections that sit alone on a row, which the per-row redistribute / recenter passes skip; the existing redistribute / recenter passes also honour the trigger for row-shared sections. Trigger is fully automatic; no directive is exposed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f6cc949) * fix(layout): bypass non-consuming stations via virtual station insert Lines that flow through a section but aren't consumed by every station-column they cross were previously routing through the non-consumer's marker bbox. The differential-abundance pipeline shows this at ``Annotate results``: ``limma`` carries rnaseq/affy/maxquant/geo, but only rnaseq+affy stop at the annotate marker -- maxquant and geo continue to the section's exit port and are pinned to the same trunk Y, crashing the annotate pill. Extend the v104 terminus-convergence pattern to handle this case: inserting a hidden virtual station ``V`` (``is_hidden=True``) in the bypassed station's section gives the routing engine a column-mate to fan to/from, so the bypass uses the same fork-out and fork-in diagonals the rest of the diagram uses for parallel branches. Trigger (in ``_insert_bypass_stations``, run after ``_resolve_sections``): 1. For each non-port, non-hidden, non-terminus station S, gather consumed_lines(S) from inbound edges. 2. For each in-section predecessor P with layer(P) < layer(S), look at outbound edges ``P -> exit_port`` carrying a line L not in consumed. 3. Require P also feeds the same exit_port with at least one line from consumed_lines(S) -- this is the signal that P and the exit port both sit on the trunk that S is on, so the non-consumed line would cross S's row. 4. Replace ``P -> exit_port (L)`` with ``P -> V (L) + V -> exit_port (L)``. Per-section layers are computed locally (intra-section subgraph, including ports) with exit ports pinned past internal stations, so longest-path quirks where two layer-equal stations sit in adjacent columns don't suppress the trigger. The hidden bypass station renders with no marker and no label (``is_hidden`` is already excluded by ``render/svg.py`` and the collision resolver), so the visual is indistinguishable from ``Annotate results``' column having a second pill above/below the trunk -- which is exactly the "parallel branch with an anonymous station" effect the design language calls for. Add ``test_non_consumed_lines_route_via_virtual_station`` over ``da_pipeline.mmd`` that: * asserts at least one ``__bypass_*`` hidden station exists; * asserts the bypass V chains ``limma -> V -> exit_port`` for both maxquant and geo; * asserts maxquant and geo rendered routes clear annotate's marker bbox (half-bbox + slack). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 384befe) * fix(layout): block balance lift onto off-track icon slot The auto-balance pass (Phase 13h2) lifted below-trunk movable siblings into the topmost empty above-trunk slot without checking whether that slot was already reserved by an off-track input icon in the same column. In the differentialabundance functional section, ``gmt_in`` (off-track, lifted to y=100) and ``grea`` (a single-line rnaseq sibling at y=265) shared column 1040.7; the lift moved ``grea`` to y=100 directly on top of ``gmt_in``'s file icon, producing a marker collision in the rendered SVG. Fix: when selecting a balance candidate, walk every below-trunk movable sibling and skip any whose column already contains a station (incl. off-track) at the prospective lift Y. Off-track icons now reserve their column slot from subsequent placement. Add a post-layout validator ``_guard_no_station_overlap`` that errors with ``Position clash: <name1> ... overlaps <name2> ...`` when any two station / file-icon marker bboxes intersect, so end users running ``nf-metro render`` against a pipeline that triggers a similar regression get a clear actionable message rather than a silently broken render. Add the matching invariant ``test_no_station_or_icon_overlap`` parametrised over ``da_pipeline.mmd`` and ``rnaseq_sections.mmd`` to lock the fix in regression coverage. (cherry picked from commit 9116778) * fix(layout): tighten bypass V hop to span only V's column The v110 bypass virtual station produced a diagonal route immediately at the predecessor station (e.g. ``limma``) instead of just before the bypass V, so the bypass line ran parallel below the trunk for most of the bypassed station's section. In the differential-analysis section the maxquant / geo lines diverged at limma's X (~629) and re-converged only at the exit port (~908), creating a long visible side-branch. When the diagonal target (or source) is a hidden ``__bypass_`` station, treat the bypass V as a join (or fork) anchor in ``_route_diagonal``. ``_compute_diagonal_placement`` then biases the diagonal toward V, so the hop diverges just before V's column (~30 px upstream) and re-converges just after V's column. The remaining trunk segment is no longer interrupted by a parallel branch. Add the matching invariant ``test_bypass_avoids_off_track_inputs`` that asserts each ``__bypass_*`` virtual station sits at least one marker clearance (~12 px) away from any off-track input icon in the same column. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 91191b3) * fix(layout): clean bypass routing, compact sections, centre fan-out Issue 1: Remove __bypass_*-specific routing in _route_diagonal. Bypass virtual stations are now indistinguishable from any other off-trunk station for routing/layout; only the SVG renderer skips their marker. Issue 2: Add below-trunk compaction in _balance_section_content_around_ trunk. When the first row below the trunk is empty but content sits deeper, lift the entire below-trunk stack up by one y_spacing slot. Honours marker-clearance and column-collision constraints. Issue 3: Add _recenter_loop_side_stations phase. Layer-based X placement biased fan-out side stations (propd, dream, DESeq2, and the bypass V itself) toward the fork side. Recompute their X as the midpoint of the two diagonal corners derived from the routing geometry so they sit centred on the loop's horizontal run. Issue 4: Shrink bbox top in _apply_half_grid_2branch_symfan after the ±27.5 placement. The bbox was sized for full ±55 spread, leaving a half-y_spacing gap above the first half-grid branch. Adds four invariant tests covering each fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 4b2265a) * fix(layout): tighten bypass V hop, shift sparse loop stations clear Issue 1: The v113 cleanup (4b2265a) removed the bypass-routing override that biased ``P -> V`` diagonals toward V (treating V as a join) and ``V -> T`` diagonals away from V (treating V as a fork). Without the override the limma -> V -> exit_port loop in the differential section diverged just past limma (at the fork bias) and ran 124 px parallel below the trunk before re-converging, instead of the short 50-px hop the bypass V was designed to produce. Restore the override; the hop now spans only V's column. Issue 2: ``grea`` in the differential-functional section sat at the same Y row as ``decoupler`` (both at the section's below-trunk row), so the non-consumed lines (affy, maxquant, geo) flowing from the section entry port to decoupler crossed grea's marker bbox at y~268 (grea bbox 260-270). The bypass V mechanism only fires for direct ``pred -> exit_port`` edges and doesn't cover this same-layer sibling pattern. Add a Phase 13k that detects loop side stations with a single-line consumption set, sharing their row Y with a busier sibling in the same section, and shifts them by half a ``y_spacing`` further from the trunk. Section bbox grows to accommodate. The right invariant we lacked: ``_guard_no_station_overlap`` catches marker/marker collisions (Issue A) but not line/marker crossings (Issue B, the "breeze-past" pattern). Add the runtime validator ``_guard_no_line_crosses_non_consumer`` and the matching invariant test ``test_lines_dont_cross_non_consumer_markers`` that parse the routed polylines and assert no segment of a non-consumed line crosses any station's marker bbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit df746c4) * fix(layout): snap sparse loop stations to full grid, fix bypass V kink Two v114 layout regressions: 1. ``_shift_sparse_loop_stations_to_clear_bundle`` shifted single sparse loop stations like ``grea`` by ``y_spacing / 2``, parking them mid-row between two full grid slots and marking them half-grid even when their section had no 2-branch fan. Shift by a full ``y_spacing`` and stop marking them half-grid; the half-grid offset is reserved for the auto-half-grid 2-branch symfan feature. 2. Bypass V routes formed an asymmetric U at the virtual station: the P -> V and V -> T halves were spread independently in ``_spread_diagonal_bundles`` (asymmetric X clamping at the V-side bound) and used different ``min_straight`` values when the far endpoint was a port (15px vs 10px), leaving one side of the U collapsed and the other with a visible flat. Skip the spread for bypass V hops and pin the V-side ``min_straight`` to ``MIN_STRAIGHT_EDGE`` on both halves. Adds ``test_all_stations_snap_to_grid`` and ``test_bypass_v_horizontal_segment_is_flat``; both fail on the v114 baseline and pass after the fix. (cherry picked from commit 14776f9) * Revert "fix(labels): stagger labels of vertically stacked same-column stations" This reverts commit d9f98c1. * fix(layout): snap sparse loop stations to full grid, fix bypass V kink Two v114 layout regressions: 1. ``_shift_sparse_loop_stations_to_clear_bundle`` shifted single sparse loop stations like ``grea`` by ``y_spacing / 2``, parking them mid-row between two full grid slots and marking them half-grid even when their section had no 2-branch fan. Shift by a full ``y_spacing`` and stop marking them half-grid; the half-grid offset is reserved for the auto-half-grid 2-branch symfan feature. 2. Bypass V routes formed an asymmetric U at the virtual station: the P -> V and V -> T halves were spread independently in ``_spread_diagonal_bundles`` (asymmetric X clamping at the V-side bound) and used different ``min_straight`` values when the far endpoint was a port (15px vs 10px), leaving one side of the U collapsed and the other with a visible flat. Skip the spread for bypass V hops and pin the V-side ``min_straight`` to ``MIN_STRAIGHT_EDGE`` on both halves. Adds ``test_all_stations_snap_to_grid`` and ``test_bypass_v_horizontal_segment_is_flat``; both fail on the v114 baseline and pass after the fix. (cherry picked from commit 14776f9) * refactor: tighten v115 test docstrings Drop narrative references to the v114 regression from the invariant test docstrings; the WHAT-and-WHY of each test stands on its own and historical context belongs in the commit log. [skip ci] * style: ruff format [skip ci] * ci: re-run after format fix --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#302) * fix(labels): stagger labels of vertically stacked same-column stations When two stations in the same section share an X column and adjacent Y tracks (e.g. Affy and Proteus in the differentialabundance metro map), their centered labels visually crowd along the same vertical line. Adds a post-placement pass that nudges labels horizontally in opposite directions, only when the shift would not introduce a new collision. [skip ci] (cherry picked from commit 265b7e4) * fix: re-align row bboxes after port-terminus spacing Phase 11 _space_ports_from_termini calls _expand_bbox_for_y when it pushes a port outside the section bbox, which can lift a single section's bbox_y above its row-mates. Phase 11b _recompute_grid_group_bboxes then sets bbox_y to min(non_port_ys) - max_y_pad and re-expands for off-grid ports, baking the shift in. Phase 9 _top_align_row_sections runs before Phase 11 so it can't undo the resulting offset. Add a Phase 11c re-run of _top_align_row_sections so bbox tops within each row stay flush after the port-terminus spacing pass. Repro: render examples/differentialabundance with port-pushed entry on the functional section. Before: functional bbox_y=40, others 85, visible 45px vertical offset. After: all sections at bbox_y=85. (cherry picked from commit 9b5d298) * fix(layout): block station overlaps when track compaction collides cells The track allocator can return identical track values for two stations on different lines when each is the sole occupant of its line at a given layer (e.g. side-by-side terminus branches like the Affy/Proteus microarray and proteomics terminals in nf-core/differentialabundance). After coordinate assignment they land on the same (x, y), causing the pills and labels to render directly on top of one another. Add a post-placement pass in _layout_single_section that buckets real stations by their primary axis (layer column for LR/RL, row for TB) and pushes any secondary-axis duplicates apart by one spacing unit. Insertion order in sub.stations is the stable tiebreaker so the earlier-defined station keeps its slot. The pass is a no-op when there is no collision, so existing layouts are unaffected. [skip ci] (cherry picked from commit 9e97491) * fix(layout): increase SECTION_Y_PADDING for bundle margin Provides visual breathing room so line bundles passing through edge-tracked stations don't touch the section bbox border. [skip ci] (cherry picked from commit 72e8379) * fix(layout): anchor inter-section exit ports to downstream bundle Y When an LR/RL exit port's bundle materialises at a single same-row Y in the downstream section (parallel fan-out where every internal station carries the same line set), pull the exit port to that Y so the inter-section run stays horizontal. Falls back to the local-internal centre when the exit has fan-in from distinct Ys, the downstream entry is a branch fan-out, multiple downstream entries land at different Ys, or the anchor falls outside the section bbox. Eliminates the drop from differential.annotate (centred) to functional.gsea (top fan-out station) on the differentialabundance map. (cherry picked from commit edb06e4) * fix(layout): anchor fan-in exit to downstream when bundle is multi-line Phase 10d preserved centred midpoints for any 3+ source fan-in exit, which kept variantbenchmarking's filter merge correct but left the differentialabundance functional exit centred between gprofiler2 and decoupler even though all four sources carry the same four-line bundle into shinyngs and quarto. The inter-section run then curved up to the reporting entry Y instead of staying horizontal. Relax the 3+ fan-in skip when the exit carries a multi-line bundle (>=2 lines, so every source is parallel-redundant) and one source Y already matches the downstream entry: snap to that Y for a straight inter-section run. Single-line fan-ins still keep their midpoint, so the variantbenchmarking TestFanInExitPreservation assertions hold. (cherry picked from commit 7ce8a2d) * fix(layout): anchor trunk junctions on bundle line for straight trunks Trunk stations - those receiving a pre-existing bundle from at least one predecessor plus side branches - previously snapped to the predecessor centroid, bending the bundle within the section. Detect this case in both _place_single_node (for fan-in junctions) and _place_fan_out (when one fan-out node carries a strict superset of its siblings' lines) and anchor the trunk node on its primary line's base track so the bundle stays straight through the junction. (cherry picked from commit bfde7b0) * fix(layout): symmetric fan-out of trunk siblings, strictly scoped Resurrects the PR #264 idea (symmetric distribution of side stations around the section trunk Y when ``--center-ports`` is on) but scopes it tightly so it ONLY moves stations that are unambiguously fan-out side branches of a trunk junction. A column qualifies for redistribution only when it contains exactly one station whose line set equals the section's full LEFT/RIGHT bundle (the trunk junction) AND at least one sibling station whose line set is a strict subset of the bundle. Those siblings are redistributed in alternating ``+1, -1, +2, -2, ...`` slots at ``y_spacing`` pitch around the trunk Y; the trunk itself stays pinned. Everything else is left in place: file inputs, linear processing chains, fan-in structures (many sources converging on one station), columns without a unique trunk, and any sibling that carries the full bundle (linear pass-through, not a fan-out branch). This fixes the original PR #264 regression where data-import-style sections (fan-in to a Validate node, then linear to Filter matrix) had their input labels and chain stations mixed at random Ys. In nf-core/differentialabundance: - Data import: untouched (no qualifying column, only file inputs and processing chains). - Differential: limma is the trunk; DESeq2/dream/propd carry only ``{rnaseq}`` (strict subset) and now fan symmetrically above and below limma's Y. - Functional: gprofiler2/decoupler/grea carry the full bundle and are intentionally not redistributed; file inputs (Gene sets, Network) are in a different column from GSEA and stay put. - Reporting: no qualifying trunk-junction column; untouched. Default (``--no-center-ports``) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 6415807) * feat(layout): add %%metro off_track: directive to lift file inputs above line tracks File-input stations declared in the flow graph (e.g. samples_in[Samples] + %%metro file: samples_in | CSV) normally consume a line-track Y slot because each station owns a track. This makes input file icons share Y with the study-type line lanes, defeating the visual goal of an "inputs band" above the section's pipeline tracks. Add a new %%metro off_track: id1, id2, ... directive that marks stations to be lifted into a separate band above the section's topmost line track in a final post-layout phase. Section bboxes expand upward to fit the band, and the whole graph is shifted down if needed so the new top stays inside the canvas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit dca94e9) * fix(layout): align trunk Ys across same-row sections Previously sections in the same grid row could have their inter-section trunk bundles enter and exit at different Y values when the upstream section had more above-trunk content than the downstream section. The visible result was lines bending vertically between section bboxes even though the bbox tops were aligned. Add a new Phase 11ca _align_row_trunk_ys. For each contiguous column group of LR/RL sections within a grid row, identify each section's trunk station (the full-bundle station connected to an LR port) and shift content downward in sections whose trunks sit above the row's deepest trunk. Bbox tops are preserved (heights grow downward). Row-spanning sections (rowspan > 1) are skipped to avoid disturbing cross-row vertical relationships. After the shift, ports on shifted sections are re-snapped to the target trunk Y when they have a single internal station at that Y; fan-in convergence ports (2+ internal sources at distinct Ys) are preserved at their centered midpoint so Phase 10d's fan-in geometry isn't disturbed. Update the grid-alignment regression tests: first_station_y consistency and symmetric bbox padding are stricter than top alignment and are not satisfied when sections shift to align trunks. Replace with bbox-top alignment and a non-negative bottom-padding floor. (cherry picked from commit 43e387c) * fix(layout): align bbox tops after off-track lift The %%metro off_track directive lifts file inputs above the line tracks and expands the section's bbox upward. This left other sections in the same row with their bbox tops misaligned because the row top-align phase ran before off-track lifting. Add Phase 13a (_top_align_row_bboxes_only) after off-track lifting to grow row-mate bboxes upward to the row's topmost bbox. Unlike earlier top-align phases, this only adjusts bbox_y/bbox_h - station Ys stay in place so the lifted section's content is preserved and the rest of the row gets empty space at the top of its bboxes. (cherry picked from commit 2d7a663) * fix(layout): compact row sections so content sits at bbox top Phase 11ca aligns trunk Ys across row-mate sections and Phase 13a expands shorter row-mates' bbox tops to match a section whose bbox grew upward in Phase 13 to accommodate off-track inputs. Without a follow-up, the row-mates without off-track ended up with empty vertical space at the top of their bboxes. Add Phase 13b _compact_row_content_to_bbox_top. For each grid row's contiguous column group: 1. Compute the allowable upward shift per section based on: - distance from on-track content to bbox top (minus padding), and - clearance to any lifted off-track band (so on-track content doesn't collide with the lift band that Phase 13 placed ``y_spacing`` above the on-track top). Apply the minimum allowable shift across the group to every on-track station and port; off-track stations stay anchored to the lift band. Uniform shift preserves trunk alignment. 2. Shrink each section's bbox_h so the bottom slack matches section_y_padding (clamped to keep bottom ports inside the bbox). Sections with grid_row_span > 1 are excluded because their content spans multiple rows and the per-row compaction frame doesn't apply. (cherry picked from commit 2831431) * feat(icons): optional caption name on file/files/dir directives Extend `%%metro file:`, `%%metro files:` and `%%metro dir:` with an optional third field for a human-readable caption rendered directly below the icon. This avoids the previous workaround of attaching a separate naming station that rendered as a process circle. Syntax: `%%metro file: station_id | TYPE | NAME` The caption uses the theme label color/font and is included in the icon's obstacle bbox so neighbouring labels keep their distance. (cherry picked from commit c435357) * fix(layout): compact rowspan sections, skip off-track termini, pad captioned bands Three coupled fixes around off-track stations and rowspan sections: * `_compact_row_content_to_bbox_top` now compacts ``grid_row_span > 1`` sections instead of skipping them. They form their own single-section groups since rowspan sections trunk at a Y of their own and don't share a horizontal inter-section bundle with row mates. * `_space_ports_from_termini` skips off-track stations when collecting termini. They get lifted above the topmost line track later, so they no longer share a Y with the inter-section bundle; treating them as termini was pushing ports above the on-track row (and dragging the upstream port via junction propagation), causing a visible kink in the inter-section bundle. * When a captioned off-track icon sits at the bottom of the lifted band, `_compact_row_content_to_bbox_top` widens its per-section clear-shift gap by the caption extent so the optional caption text (PR #269) doesn't end up overlapping the topmost line track after compaction. (cherry picked from commit 088edd2) * fix(layout): caption scaling, inter-section port snap, top-fill fan Polishes the explicit-grid layout pipeline so trunk lines flow cleanly across section boundaries and large bbox top slack stops looking empty. * Caption text under file icons now renders at 60% of the label font size and clamps its X within the section bbox so long captions (e.g. ``MaxQuant``, ``GEO ID``) stop spilling onto the section border. Compaction reserves the smaller caption extent in the off-track gap calculation. * New Phase 13c snaps LR/RL exit port Ys to the downstream entry Y for same-row connections so a row-spanning section's trunk no longer kinks at the section boundary; junctions are re-positioned afterwards. Gated on user-supplied ``%%metro grid:`` directives to preserve auto-layout line-offset ordering. * New Phase 13d redistributes trunk-candidate siblings symmetrically around the topmost trunk station when a section retains visible empty top space (e.g. Reporting fed from Functional with off-track inputs), filling the bbox top instead of leaving it blank. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit cc28ae5) * fix(layout): skip upward fan on U-turn and dodge label-vs-diagonal Two polish fixes to PR #272's top-fill fan and the label placer: * Phase 13d (`_fan_free_content_upward`) now skips lifting a trunk candidate whose incoming bundle has multiple external feeders all at or below the row's trunk Y. Lifting in that case forces the bundle to climb against the trunk (a "U-turn") and visibly bends past sibling routes that stay at trunk Y. Reporting section in nf-core/differentialabundance: Shiny app now sits on the trunk and Quarto report stays below it. * New `_avoid_diagonal_routes` pass after label placement flips a label to the opposite side of its station when its bbox overlaps a non-horizontal route segment (and the flipped position is collision free). Trunk horizontals are deliberately ignored. Fixes the GSEA label overlapping the off-track icon descent in section 3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 30158a0) * fix(layout): snap station and port Ys to row-wide grid Final layout phase snaps every non-port station and every LEFT/RIGHT port to a clean grid pitch. Earlier phases (trunk-Y align, downstream port-snap, fold-exit alignment) compute shifts that don't respect the grid, leaving coordinates at fractional Ys (e.g. 298.785 when the pitch is 55). Group sections by row using ``_row_y_grid_info``, derive ONE shared grid origin per row from the mode of (y % pitch) across all non-port stations in the row, and snap every station and LR/RL port in the row to that origin. Sections outside any row group snap to the input ``y_spacing`` with their own origin. A per-section origin (as in the prior pass) drifted between sections in the same row when one section was on residue 0 and the next on residue 23.9, kinking trunks at section boundaries. Sharing the origin across the row keeps trunks co-linear. - bounds each snap by half a pitch so adjacency cannot flip - skips off-track stations and TOP/BOTTOM ports whose Y is fixed (cherry picked from commit b9a10ab) * fix(layout): symmetric fan for terminal full-bundle columns Terminal sections (no exit ports) sometimes hold a column of stations that all carry the full bundle and all receive from the same upstream branches, with no obvious trunk station to pin. The existing fan-out logic requires a unique trunk + strict-subset siblings, so it leaves these columns stacked tightly. Add a second pass that runs only when --center-ports is on and the section has no exit ports: when a column's stations are all full-bundle (>=2), redistribute them symmetrically around a trunk Y derived from other full-bundle stations in the section (or the LR port Y). Even counts leave the trunk row empty; odd counts keep one station on the trunk. Non-terminal sections, sub-bundle siblings, file inputs and fan-in chains are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit b2ca836) * fix(layout): symfan all full-bundle columns and pin off-track to consumer (cherry picked from commit 4a5d184) * fix(layout): pin source stations to per-line track, not trunk fan `_redistribute_fanout_siblings` (PR #266) rearranges fan-out branches symmetrically around the trunk junction of a column when `--center-ports` is on. Its scope was meant to exclude file-input columns, but in nf-core/differentialabundance the data-import section has `meta_in` carrying the full bundle and the other source inputs (matrix_in, cel_in, mq_in, geo_in, gtf_in) each carrying a strict subset. Those siblings were redistributed around `meta_in` even though they aren't a real fan-out, pushing every file icon off its line's track and forcing each consumer line to traverse the entire section to reach its input. Tighten the scope so a sibling only qualifies when it shares at least one predecessor with the trunk station. Real fan-outs (differential's limma + DESeq2/dream/propd, all fed by matrix_filter) still redistribute; columns of source stations stay on their per-line track Y so the file icon for each line sits adjacent to its consumer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit a76a21e) * fix(layout): preserve trunk-Y alignment for rowspan sections during compact `_compact_row_content_to_bbox_top` (PR #278) isolated every ``grid_row_span > 1`` section into its own group on the assumption that rowspan sections "trunk at a Y of their own". That assumption breaks for sections like nf-core/differentialabundance's data_prep, which rowspans down over a row-1 plots block but whose trunk Y still sits on the row-0 inter-section bundle. In isolation, data_prep computed an 82.5px upward shift to remove its (now legitimately large) top padding, pulling validator and matrix_filter above the row's trunk Y and breaking the horizontal bundle from data_prep into differential. Downstream ``_snap_all_y_to_grid`` then flipped the row's grid mode onto the shifted residue, snapping differential/reporting fan-out columns onto the same Y slot (dream/limma, deseq2/propd, bundle/report_html all collided). Keep the rowspan exclusion only when the rowspan section's trunk Y differs from its column-adjacent neighbour's. When trunks align, compact groups them together so the shared upward shift is bounded by the smallest tolerance in the group, preserving the inter-section bundle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 85d2114) * fix(layout): lift source-stack inputs above the trunk to fill top space `_fan_free_content_upward` only fans full-bundle trunk candidates upward, so a section whose entry column has a single full-bundle station plus subset-bundle source inputs (file icons with no inbound edges, e.g. nf-core/differentialabundance's data-import section) leaves its bbox top empty: every source sits at or below the trunk. Add a companion phase `_fan_source_inputs_upward` that detects this shape and lifts the nearest-to-trunk sources into the empty top band. Each lifted source drags its linear consumer chain (single inbound edge, identical line set, section-internal) so per-line tracks stay straight from icon to junction. Scoped to explicit `%%metro grid:` pipelines. Bounded by `min(slots, n_sources // 2)` so a majority of sources stay below the trunk; capped by `(trunk_y - bbox_y - y_spacing / 4) // y_spacing` so the topmost lifted station stays inside the bbox with a margin. U-turn risk is nil because sources have no upstream feeders. Differentialabundance section 1 now shows GTF + Matrix above the trunk and Affy CEL + MaxQuant + GEO below, filling the 132.5px of previously-empty bbox top space. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f02d705) * fix(layout): three v93 layout polish issues 1. Compact below-trunk sources after source-input lift When ``_fan_source_inputs_upward`` lifts the top N sources above the trunk (e.g. GTF, Matrix in differentialabundance section 1), the remaining below-trunk sources stayed at their original Ys, leaving a multi-slot empty band between the trunk row and the first below-trunk source. Pack the remaining sources upward at ``trunk_y + i*y_spacing`` so the gap collapses. 2. Re-fan full-bundle columns around the row's final trunk Y ``_redistribute_full_bundle_columns`` runs early using local LR port Ys as the symmetric centre. For terminal sections whose sole LR port Y differs from the row's eventual trunk Y (e.g. Reporting's entry port at the bundle-column Y rather than the row trunk Y), the fan ends up offset from the trunk row. Add Phase 13g ``_recenter_full_bundle_columns`` to re-distribute each multi-station full-bundle column around the entry/exit port station Y, which by that point sits on the row's inter-section bundle Y. 3. Stagger captions when adjacent file icons would overlap Stations carrying multiple ``%%metro file:`` directives place icons at ``terminus_width + ICON_INTER_GAP`` (32 px) intervals. Captions like ``Samples`` and ``Contrasts`` measure ~50 px wide and overlap when both sit at the same Y. Detect the overlap from the estimated caption widths and drop every other caption to a second row beneath the first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 9322198) * fix(layout): v94 polish - terminus Y, caption fit, rowspan trim - Pin a single-station downstream terminus to its sole in-section upstream's Y after full-bundle column re-centering, so file icons like the Reporting ``Bundle`` ZIP line up with their source station. - Replace the "drop alternate captions to row 2" overlap workaround with a horizontal spread: ``_render_terminus_icons`` now widens the per-icon centre-to-centre step when adjacent captions would collide, keeping all captions on one row. ``_terminus_icon_clearance`` expands the section bbox by the same amount so the wider icon row still fits. ``_build_section_subgraph`` now copies ``terminus_names`` / ``terminus_icon_types`` so the layout-side clearance check sees the captions. - Re-shrink section bboxes to their content bottom after late lifts (``_fan_source_inputs_upward``, ``_recenter_full_bundle_columns``): rowspan>1 sections like ``data_prep`` no longer keep the inflated height once content has been pulled up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f2dfda8) * fix(layout): exclude leftward bbox overhang from column width Sections whose ``bbox_x`` is pushed further left than the standard ``-SECTION_X_PADDING`` (e.g. by ``_adjust_terminus_icon_clearance`` expanding the bbox to fit file icons on source stations) had that extra overhang counted into the column width. Adjacent columns were placed at ``offset_x + col_widths[col] + section_x_gap``, so the leftward overhang inflated every gap downstream of the expanded column. On nf-core/differentialabundance this widened the gap between section 1 and section 2 from 50px to ~103px while 2-3 and 3-4 stayed at the expected 50px. Use a small ``_effective_width`` helper that re-anchors the bbox to the standard left edge (``bbox_x + bbox_w + SECTION_X_PADDING``) when measuring per-column widths and when sizing spanning sections. The leftward overhang is left in place; Phase 3b of compute_layout already bumps the global ``x_offset`` to keep that content on canvas. Standard sections (``bbox_x == -SECTION_X_PADDING``) are unaffected because the formula collapses to ``bbox_w``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 7282271) * fix(layout): tighten lower-row gap left by rowspan claims After _shrink_bboxes_to_content_bottom, recompute the slack between each lower row and the sections that actually end above it, and shift those rows up to close it. _compute_section_offsets sizes row_heights from pre-shrink bbox heights, which leaves visible empty space when the only section filling a row is a rowspan that has since shrunk to its content. Sections that extend into the lower row are excluded from the bottom measurement so the gap is governed by row-r-1 content, not by rowspan claims. (cherry picked from commit e2284af) * fix(layout): include mixed-bundle column-mates in section symfan When a column in a grid section has mostly full-bundle stations but one or two single-line side branches (e.g. Functional enrichment's gprofiler2/GSEA/decoupler carrying all study types plus a single ``rnaseq`` method like grea), the strict gate on ``_redistribute_full_bundle_columns`` skipped the column and left the minor branch stranded on its per-line track Y. The resulting layout broke the section's symmetric fan and pushed the line through the section at an uneven slope. Three narrow changes: 1. ``_redistribute_full_bundle_columns`` and its late-pass companion ``_recenter_full_bundle_columns`` now also fire on columns where at least one station is full-bundle and the rest are non-source strict-subset siblings (i.e. real fan-out branches with a predecessor, not file inputs). This mixed-mode fan only fires when the section already has another all-full-bundle column, so ``_redistribute_fanout_siblings`` keeps its sole responsibility for "unique trunk + siblings" patterns (e.g. limma + dream/DESeq2 /propd in the differential section). 2. ``_redistribute_fanout_siblings`` now ignores off-track column- mates (matching the off-track skip the full-bundle pass already has) and relaxes its sibling gate from "shares a predecessor with the trunk" to "has at least one predecessor". A subset sibling fed by a separate upstream method is still a real fan-out branch and should slot into the symmetric fan; only source files (in-degree 0) keep their per-line track Y. 3. Off-track inputs are re-anchored to their consumer twice: once after grid-snap (already present), and again after the late-pass ``_recenter_full_bundle_columns`` shifts the consumer onto the row-wide trunk Y. Without the second pass the input icon stayed at the pre-recenter Y, visibly overlapping the consumer station. All 736 existing tests still pass; v97-style "all full bundle" columns are byte-identical. v98's Functional enrichment section now renders as a clean 2x2 symmetric fan with the off-track Gene sets / Network icons visibly above the trunk line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 763f292) * fix(layout): prefer trunk feeder Y at exit ports + invariant tests When an LR/RL section's exit port collects both a full-bundle trunk station and a minor single-line side-branch feeder (e.g. propd -> grea in differentialabundance), the prior code averaged feeder Ys per line. A lone below-trunk feeder pulled rnaseq's representative Y downward, reordering the bundle at the port; horizontal reconciliation then shifted the trunk station's offsets to match, producing a visible 9px kink at the section boundary (the "limma kink"). Detect the trunk feeder (a feeder whose lines cover the port's line set) and use its Y as the per-line representative. Side branches no longer rotate the bundle; the kink stays at the side branch where it belongs. Adds tests/test_layout_invariants.py with four cross-section checks that fail on the pre-fix layout for the differentialabundance graph: - test_row_trunk_marker_cy_consistent: trunk markers share cy within each grid row - test_symfan_pairs_share_y: column-mate fan pairs are mirrored around the trunk - test_off_track_inputs_above_consumer: off-track inputs sit above their consumer - test_no_kink_at_section_boundary: adjacent exit / entry ports render at the same cy Fixtures: copies of the da_pipeline and rnaseq_sections graphs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 9d93c1f) * fix(layout): side-branch late diagonal + bbox grows with re-anchor [skip ci] Two related routing/layout fixes for the differentialabundance metro map: 1. Side-branch single-line exits no longer climb to the trunk bundle immediately after the source station. When a non-port internal station sits clearly off its section's trunk Y and its only outgoing line targets either the section's exit port or another internal station on the trunk, the diagonal placement is biased toward the target (overriding the standard fork bias). The line then stays on its own track for most of the section, only climbing to trunk Y just before exiting. Concretely fixes propd's rnaseq exit edge in section 2: pre-fix the line climbed to trunk Y at x=654 (25px past the source); now it stays at y=320 until x=860 (230px) and climbs only in the last 55px before the section's right edge. 2. ``_reanchor_off_track_to_consumer`` grows the section bbox upward when the final re-anchored off-track input position would put the icon outside the section's padding zone, and shifts the whole graph down when that growth pushes any bbox above the canvas top margin. The row top-align phase is re-run after the second re-anchor so row mates keep flush bbox tops. Concretely fixes section 3 (functional) where gmt_in's terminus icon extended 5.5px above the bbox top in v102. New invariant tests: - test_side_branch_edge_stays_off_trunk: side-branch exit edges must keep the line at the source Y for at least 30% of the source->target horizontal run. - test_section_bbox_contains_all_content: every section bbox must enclose its on-track stations and off-track icons (accounting for marker / terminus half-height). Both fail on the pre-fix code and pass with the fixes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 2f26d11) * fix(layout): auto-balance sections and converge multi-source termini Three v104 improvements with invariant regression tests: 1. _balance_section_content_around_trunk: a new layout phase that redistributes fan-out siblings to fill empty bands above the trunk. Lifts the bottommost (homogeneous-line) or topmost (heterogeneous- line) below-trunk movable into a new top slot when the bbox has room for the marker plus its above-marker label; otherwise swaps the bottommost below-trunk movable with the topmost above-trunk station to reorder the band without growing the bbox. Uses a line-aware feeder walk so single-feeder lifts aren't blocked by unrelated stations sharing a junction port. 2. _insert_terminus_convergence_stations: parser pass that inserts a hidden convergence station before any terminus with 2+ inbound sources so the routing engine allocates a column for the merge. Diagonals from the sources meet at the converge station and the final segment to the terminus marker is orthogonal. 3. Section 1 (data_prep) and Section 2 (differential) of the differentialabundance pipeline now fill their above-trunk bands instead of stacking content below the trunk. New invariant tests in test_layout_invariants.py: - test_section_top_band_filled - test_section1_input_above_trunk - test_terminus_not_directly_after_diagonal All three fail on the da-render-integration baseline and pass after the fix; the 741 existing tests continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 881fdb4) * fix(layout): keep balance-pass lift inside section bbox The v103 test_section_bbox_contains_all_content invariant requires that station markers (9.5 px half-height) and off-track file icons (16 px half-height) stay inside their section bbox. v104's `_balance_section_content_around_trunk` clamped `new_y` only against label clearance, so empty-label file inputs (e.g. `cel_in[ ]`) could land flush with `bbox_y` and overflow by half a marker. Bump the floor to `max(label_clearance, marker_half)` where `marker_half` is 16 px for off-track icons and 9.5 px for on-track markers, matching v103's invariant geometry. (cherry picked from commit 3ced84b) * feat(layout): auto half-grid placement for 2-branch symmetric fans When a section contains exactly two on-track branch stations sharing a column (no off-track inputs, no extra on-track content), place them at trunk_y +/- 0.5 * y_spacing instead of trunk_y +/- 1 * y_spacing. The section then needs only one vertical grid unit for the fan instead of two, removing wasted vertical space in topologies like the differentialabundance Plots section. Trunk Y stays on the integer grid; only the two branch stations sit at half-pitch. They are tracked in graph._half_grid_station_ids so the final snap-to-grid pass leaves them at their half-pitch offsets and ignores them when computing the row grid origin. The dedicated Phase 13d3 (_apply_half_grid_2branch_symfan) handles sections that sit alone on a row, which the per-row redistribute / recenter passes skip; the existing redistribute / recenter passes also honour the trigger for row-shared sections. Trigger is fully automatic; no directive is exposed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f6cc949) * fix(layout): bypass non-consuming stations via virtual station insert Lines that flow through a section but aren't consumed by every station-column they cross were previously routing through the non-consumer's marker bbox. The differential-abundance pipeline shows this at ``Annotate results``: ``limma`` carries rnaseq/affy/maxquant/geo, but only rnaseq+affy stop at the annotate marker -- maxquant and geo continue to the section's exit port and are pinned to the same trunk Y, crashing the annotate pill. Extend the v104 terminus-convergence pattern to handle this case: inserting a hidden virtual station ``V`` (``is_hidden=True``) in the bypassed station's section gives the routing engine a column-mate to fan to/from, so the bypass uses the same fork-out and fork-in diagonals the rest of the diagram uses for parallel branches. Trigger (in ``_insert_bypass_stations``, run after ``_resolve_sections``): 1. For each non-port, non-hidden, non-terminus station S, gather consumed_lines(S) from inbound edges. 2. For each in-section predecessor P with layer(P) < layer(S), look at outbound edges ``P -> exit_port`` carrying a line L not in consumed. 3. Require P also feeds the same exit_port with at least one line from consumed_lines(S) -- this is the signal that P and the exit port both sit on the trunk that S is on, so the non-consumed line would cross S's row. 4. Replace ``P -> exit_port (L)`` with ``P -> V (L) + V -> exit_port (L)``. Per-section layers are computed locally (intra-section subgraph, including ports) with exit ports pinned past internal stations, so longest-path quirks where two layer-equal stations sit in adjacent columns don't suppress the trigger. The hidden bypass station renders with no marker and no label (``is_hidden`` is already excluded by ``render/svg.py`` and the collision resolver), so the visual is indistinguishable from ``Annotate results``' column having a second pill above/below the trunk -- which is exactly the "parallel branch with an anonymous station" effect the design language calls for. Add ``test_non_consumed_lines_route_via_virtual_station`` over ``da_pipeline.mmd`` that: * asserts at least one ``__bypass_*`` hidden station exists; * asserts the bypass V chains ``limma -> V -> exit_port`` for both maxquant and geo; * asserts maxquant and geo rendered routes clear annotate's marker bbox (half-bbox + slack). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 384befe) * fix(layout): block balance lift onto off-track icon slot The auto-balance pass (Phase 13h2) lifted below-trunk movable siblings into the topmost empty above-trunk slot without checking whether that slot was already reserved by an off-track input icon in the same column. In the differentialabundance functional section, ``gmt_in`` (off-track, lifted to y=100) and ``grea`` (a single-line rnaseq sibling at y=265) shared column 1040.7; the lift moved ``grea`` to y=100 directly on top of ``gmt_in``'s file icon, producing a marker collision in the rendered SVG. Fix: when selecting a balance candidate, walk every below-trunk movable sibling and skip any whose column already contains a station (incl. off-track) at the prospective lift Y. Off-track icons now reserve their column slot from subsequent placement. Add a post-layout validator ``_guard_no_station_overlap`` that errors with ``Position clash: <name1> ... overlaps <name2> ...`` when any two station / file-icon marker bboxes intersect, so end users running ``nf-metro render`` against a pipeline that triggers a similar regression get a clear actionable message rather than a silently broken render. Add the matching invariant ``test_no_station_or_icon_overlap`` parametrised over ``da_pipeline.mmd`` and ``rnaseq_sections.mmd`` to lock the fix in regression coverage. (cherry picked from commit 9116778) * fix(layout): tighten bypass V hop to span only V's column The v110 bypass virtual station produced a diagonal route immediately at the predecessor station (e.g. ``limma``) instead of just before the bypass V, so the bypass line ran parallel below the trunk for most of the bypassed station's section. In the differential-analysis section the maxquant / geo lines diverged at limma's X (~629) and re-converged only at the exit port (~908), creating a long visible side-branch. When the diagonal target (or source) is a hidden ``__bypass_`` station, treat the bypass V as a join (or fork) anchor in ``_route_diagonal``. ``_compute_diagonal_placement`` then biases the diagonal toward V, so the hop diverges just before V's column (~30 px upstream) and re-converges just after V's column. The remaining trunk segment is no longer interrupted by a parallel branch. Add the matching invariant ``test_bypass_avoids_off_track_inputs`` that asserts each ``__bypass_*`` virtual station sits at least one marker clearance (~12 px) away from any off-track input icon in the same column. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 91191b3) * fix(layout): clean bypass routing, compact sections, centre fan-out Issue 1: Remove __bypass_*-specific routing in _route_diagonal. Bypass virtual stations are now indistinguishable from any other off-trunk station for routing/layout; only the SVG renderer skips their marker. Issue 2: Add below-trunk compaction in _balance_section_content_around_ trunk. When the first row below the trunk is empty but content sits deeper, lift the entire below-trunk stack up by one y_spacing slot. Honours marker-clearance and column-collision constraints. Issue 3: Add _recenter_loop_side_stations phase. Layer-based X placement biased fan-out side stations (propd, dream, DESeq2, and the bypass V itself) toward the fork side. Recompute their X as the midpoint of the two diagonal corners derived from the routing geometry so they sit centred on the loop's horizontal run. Issue 4: Shrink bbox top in _apply_half_grid_2branch_symfan after the ±27.5 placement. The bbox was sized for full ±55 spread, leaving a half-y_spacing gap above the first half-grid branch. Adds four invariant tests covering each fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 4b2265a) * fix(layout): tighten bypass V hop, shift sparse loop stations clear Issue 1: The v113 cleanup (4b2265a) removed the bypass-routing override that biased ``P -> V`` diagonals toward V (treating V as a join) and ``V -> T`` diagonals away from V (treating V as a fork). Without the override the limma -> V -> exit_port loop in the differential section diverged just past limma (at the fork bias) and ran 124 px parallel below the trunk before re-converging, instead of the short 50-px hop the bypass V was designed to produce. Restore the override; the hop now spans only V's column. Issue 2: ``grea`` in the differential-functional section sat at the same Y row as ``decoupler`` (both at the section's below-trunk row), so the non-consumed lines (affy, maxquant, geo) flowing from the section entry port to decoupler crossed grea's marker bbox at y~268 (grea bbox 260-270). The bypass V mechanism only fires for direct ``pred -> exit_port`` edges and doesn't cover this same-layer sibling pattern. Add a Phase 13k that detects loop side stations with a single-line consumption set, sharing their row Y with a busier sibling in the same section, and shifts them by half a ``y_spacing`` further from the trunk. Section bbox grows to accommodate. The right invariant we lacked: ``_guard_no_station_overlap`` catches marker/marker collisions (Issue A) but not line/marker crossings (Issue B, the "breeze-past" pattern). Add the runtime validator ``_guard_no_line_crosses_non_consumer`` and the matching invariant test ``test_lines_dont_cross_non_consumer_markers`` that parse the routed polylines and assert no segment of a non-consumed line crosses any station's marker bbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit df746c4) * fix(layout): snap sparse loop stations to full grid, fix bypass V kink Two v114 layout regressions: 1. ``_shift_sparse_loop_stations_to_clear_bundle`` shifted single sparse loop stations like ``grea`` by ``y_spacing / 2``, parking them mid-row between two full grid slots and marking them half-grid even when their section had no 2-branch fan. Shift by a full ``y_spacing`` and stop marking them half-grid; the half-grid offset is reserved for the auto-half-grid 2-branch symfan feature. 2. Bypass V routes formed an asymmetric U at the virtual station: the P -> V and V -> T halves were spread independently in ``_spread_diagonal_bundles`` (asymmetric X clamping at the V-side bound) and used different ``min_straight`` values when the far endpoint was a port (15px vs 10px), leaving one side of the U collapsed and the other with a visible flat. Skip the spread for bypass V hops and pin the V-side ``min_straight`` to ``MIN_STRAIGHT_EDGE`` on both halves. Adds ``test_all_stations_snap_to_grid`` and ``test_bypass_v_horizontal_segment_is_flat``; both fail on the v114 baseline and pass after the fix. (cherry picked from commit 14776f9) * fix(layout): align trunk loop-column stations with off-trunk siblings v113 added _recenter_loop_side_stations to centre off-trunk fan-out stations on the loop's midpoint, but left the trunk-row station (e.g. limma in the differentialabundance differential section) at its raw layer X. This produced a visible X mismatch: limma at 629.4 vs propd/dream/DESeq2 at 648.6 in the same loop column. Add a second pass that snaps each trunk-Y station to the mean X of its "clean" off-trunk loop-column siblings (single inbound + single outbound edge, both endpoints on trunk Y). Pass 1 already places those siblings at the column's geometric midpoint, so the trunk station inherits the same X and column-mates line up. The pass only fires when the station's visible neighbours are all on trunk Y -- off-track inputs (e.g. gmt_in feeding gsea) keep their consumer anchored to the input column, not the loop midpoint. Add test_loop_column_stations_share_x: groups stations into loop columns by (rightmost trunk-pred-X, leftmost trunk-succ-X) and asserts column-mates share X within 1px. Fails on the v115 baseline (limma 600.08 vs deseq2/dream 617.58 in the fixture) and passes after the fix. (cherry picked from commit 450bd18) * Revert "fix(labels): stagger labels of vertically stacked same-column stations" This reverts commit d9f98c1. * fix(layout): align trunk loop-column stations with off-trunk siblings v113 added _recenter_loop_side_stations to centre off-trunk fan-out stations on the loop's midpoint, but left the trunk-row station (e.g. limma in the differentialabundance differential section) at its raw layer X. This produced a visible X mismatch: limma at 629.4 vs propd/dream/DESeq2 at 648.6 in the same loop column. Add a second pass that snaps each trunk-Y station to the mean X of its "clean" off-trunk loop-column siblings (single inbound + single outbound edge, both endpoints on trunk Y). Pass 1 already places those siblings at the column's geometric midpoint, so the trunk station inherits the same X and column-mates line up. The pass only fires when the station's visible neighbours are all on trunk Y -- off-track inputs (e.g. gmt_in feeding gsea) keep their consumer anchored to the input column, not the loop midpoint. Add test_loop_column_stations_share_x: groups stations into loop columns by (rightmost trunk-pred-X, leftmost trunk-succ-X) and asserts column-mates share X within 1px. Fails on the v115 baseline (limma 600.08 vs deseq2/dream 617.58 in the fixture) and passes after the fix. (cherry picked from commit 450bd18) * refactor: tighten pass-2 trunk-column alignment comments Remove narrative comments referencing specific stations (limma, propd, gmt_in) and concrete X coordinates; the commit message captures the 'why'. Keep one-line summaries on each pass plus the subtle invariants on the anchor filter and visible-extent helper. [skip ci] * style: apply ruff format and fix E501 in invariant assertion message [skip ci] * chore: retrigger CI after lint fix --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…7) (#303) * fix(labels): stagger labels of vertically stacked same-column stations When two stations in the same section share an X column and adjacent Y tracks (e.g. Affy and Proteus in the differentialabundance metro map), their centered labels visually crowd along the same vertical line. Adds a post-placement pass that nudges labels horizontally in opposite directions, only when the shift would not introduce a new collision. [skip ci] (cherry picked from commit 265b7e4) * fix: re-align row bboxes after port-terminus spacing Phase 11 _space_ports_from_termini calls _expand_bbox_for_y when it pushes a port outside the section bbox, which can lift a single section's bbox_y above its row-mates. Phase 11b _recompute_grid_group_bboxes then sets bbox_y to min(non_port_ys) - max_y_pad and re-expands for off-grid ports, baking the shift in. Phase 9 _top_align_row_sections runs before Phase 11 so it can't undo the resulting offset. Add a Phase 11c re-run of _top_align_row_sections so bbox tops within each row stay flush after the port-terminus spacing pass. Repro: render examples/differentialabundance with port-pushed entry on the functional section. Before: functional bbox_y=40, others 85, visible 45px vertical offset. After: all sections at bbox_y=85. (cherry picked from commit 9b5d298) * fix(layout): block station overlaps when track compaction collides cells The track allocator can return identical track values for two stations on different lines when each is the sole occupant of its line at a given layer (e.g. side-by-side terminus branches like the Affy/Proteus microarray and proteomics terminals in nf-core/differentialabundance). After coordinate assignment they land on the same (x, y), causing the pills and labels to render directly on top of one another. Add a post-placement pass in _layout_single_section that buckets real stations by their primary axis (layer column for LR/RL, row for TB) and pushes any secondary-axis duplicates apart by one spacing unit. Insertion order in sub.stations is the stable tiebreaker so the earlier-defined station keeps its slot. The pass is a no-op when there is no collision, so existing layouts are unaffected. [skip ci] (cherry picked from commit 9e97491) * fix(layout): increase SECTION_Y_PADDING for bundle margin Provides visual breathing room so line bundles passing through edge-tracked stations don't touch the section bbox border. [skip ci] (cherry picked from commit 72e8379) * fix(layout): anchor inter-section exit ports to downstream bundle Y When an LR/RL exit port's bundle materialises at a single same-row Y in the downstream section (parallel fan-out where every internal station carries the same line set), pull the exit port to that Y so the inter-section run stays horizontal. Falls back to the local-internal centre when the exit has fan-in from distinct Ys, the downstream entry is a branch fan-out, multiple downstream entries land at different Ys, or the anchor falls outside the section bbox. Eliminates the drop from differential.annotate (centred) to functional.gsea (top fan-out station) on the differentialabundance map. (cherry picked from commit edb06e4) * fix(layout): anchor fan-in exit to downstream when bundle is multi-line Phase 10d preserved centred midpoints for any 3+ source fan-in exit, which kept variantbenchmarking's filter merge correct but left the differentialabundance functional exit centred between gprofiler2 and decoupler even though all four sources carry the same four-line bundle into shinyngs and quarto. The inter-section run then curved up to the reporting entry Y instead of staying horizontal. Relax the 3+ fan-in skip when the exit carries a multi-line bundle (>=2 lines, so every source is parallel-redundant) and one source Y already matches the downstream entry: snap to that Y for a straight inter-section run. Single-line fan-ins still keep their midpoint, so the variantbenchmarking TestFanInExitPreservation assertions hold. (cherry picked from commit 7ce8a2d) * fix(layout): anchor trunk junctions on bundle line for straight trunks Trunk stations - those receiving a pre-existing bundle from at least one predecessor plus side branches - previously snapped to the predecessor centroid, bending the bundle within the section. Detect this case in both _place_single_node (for fan-in junctions) and _place_fan_out (when one fan-out node carries a strict superset of its siblings' lines) and anchor the trunk node on its primary line's base track so the bundle stays straight through the junction. (cherry picked from commit bfde7b0) * fix(layout): symmetric fan-out of trunk siblings, strictly scoped Resurrects the PR #264 idea (symmetric distribution of side stations around the section trunk Y when ``--center-ports`` is on) but scopes it tightly so it ONLY moves stations that are unambiguously fan-out side branches of a trunk junction. A column qualifies for redistribution only when it contains exactly one station whose line set equals the section's full LEFT/RIGHT bundle (the trunk junction) AND at least one sibling station whose line set is a strict subset of the bundle. Those siblings are redistributed in alternating ``+1, -1, +2, -2, ...`` slots at ``y_spacing`` pitch around the trunk Y; the trunk itself stays pinned. Everything else is left in place: file inputs, linear processing chains, fan-in structures (many sources converging on one station), columns without a unique trunk, and any sibling that carries the full bundle (linear pass-through, not a fan-out branch). This fixes the original PR #264 regression where data-import-style sections (fan-in to a Validate node, then linear to Filter matrix) had their input labels and chain stations mixed at random Ys. In nf-core/differentialabundance: - Data import: untouched (no qualifying column, only file inputs and processing chains). - Differential: limma is the trunk; DESeq2/dream/propd carry only ``{rnaseq}`` (strict subset) and now fan symmetrically above and below limma's Y. - Functional: gprofiler2/decoupler/grea carry the full bundle and are intentionally not redistributed; file inputs (Gene sets, Network) are in a different column from GSEA and stay put. - Reporting: no qualifying trunk-junction column; untouched. Default (``--no-center-ports``) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 6415807) * feat(layout): add %%metro off_track: directive to lift file inputs above line tracks File-input stations declared in the flow graph (e.g. samples_in[Samples] + %%metro file: samples_in | CSV) normally consume a line-track Y slot because each station owns a track. This makes input file icons share Y with the study-type line lanes, defeating the visual goal of an "inputs band" above the section's pipeline tracks. Add a new %%metro off_track: id1, id2, ... directive that marks stations to be lifted into a separate band above the section's topmost line track in a final post-layout phase. Section bboxes expand upward to fit the band, and the whole graph is shifted down if needed so the new top stays inside the canvas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit dca94e9) * fix(layout): align trunk Ys across same-row sections Previously sections in the same grid row could have their inter-section trunk bundles enter and exit at different Y values when the upstream section had more above-trunk content than the downstream section. The visible result was lines bending vertically between section bboxes even though the bbox tops were aligned. Add a new Phase 11ca _align_row_trunk_ys. For each contiguous column group of LR/RL sections within a grid row, identify each section's trunk station (the full-bundle station connected to an LR port) and shift content downward in sections whose trunks sit above the row's deepest trunk. Bbox tops are preserved (heights grow downward). Row-spanning sections (rowspan > 1) are skipped to avoid disturbing cross-row vertical relationships. After the shift, ports on shifted sections are re-snapped to the target trunk Y when they have a single internal station at that Y; fan-in convergence ports (2+ internal sources at distinct Ys) are preserved at their centered midpoint so Phase 10d's fan-in geometry isn't disturbed. Update the grid-alignment regression tests: first_station_y consistency and symmetric bbox padding are stricter than top alignment and are not satisfied when sections shift to align trunks. Replace with bbox-top alignment and a non-negative bottom-padding floor. (cherry picked from commit 43e387c) * fix(layout): align bbox tops after off-track lift The %%metro off_track directive lifts file inputs above the line tracks and expands the section's bbox upward. This left other sections in the same row with their bbox tops misaligned because the row top-align phase ran before off-track lifting. Add Phase 13a (_top_align_row_bboxes_only) after off-track lifting to grow row-mate bboxes upward to the row's topmost bbox. Unlike earlier top-align phases, this only adjusts bbox_y/bbox_h - station Ys stay in place so the lifted section's content is preserved and the rest of the row gets empty space at the top of its bboxes. (cherry picked from commit 2d7a663) * fix(layout): compact row sections so content sits at bbox top Phase 11ca aligns trunk Ys across row-mate sections and Phase 13a expands shorter row-mates' bbox tops to match a section whose bbox grew upward in Phase 13 to accommodate off-track inputs. Without a follow-up, the row-mates without off-track ended up with empty vertical space at the top of their bboxes. Add Phase 13b _compact_row_content_to_bbox_top. For each grid row's contiguous column group: 1. Compute the allowable upward shift per section based on: - distance from on-track content to bbox top (minus padding), and - clearance to any lifted off-track band (so on-track content doesn't collide with the lift band that Phase 13 placed ``y_spacing`` above the on-track top). Apply the minimum allowable shift across the group to every on-track station and port; off-track stations stay anchored to the lift band. Uniform shift preserves trunk alignment. 2. Shrink each section's bbox_h so the bottom slack matches section_y_padding (clamped to keep bottom ports inside the bbox). Sections with grid_row_span > 1 are excluded because their content spans multiple rows and the per-row compaction frame doesn't apply. (cherry picked from commit 2831431) * feat(icons): optional caption name on file/files/dir directives Extend `%%metro file:`, `%%metro files:` and `%%metro dir:` with an optional third field for a human-readable caption rendered directly below the icon. This avoids the previous workaround of attaching a separate naming station that rendered as a process circle. Syntax: `%%metro file: station_id | TYPE | NAME` The caption uses the theme label color/font and is included in the icon's obstacle bbox so neighbouring labels keep their distance. (cherry picked from commit c435357) * fix(layout): compact rowspan sections, skip off-track termini, pad captioned bands Three coupled fixes around off-track stations and rowspan sections: * `_compact_row_content_to_bbox_top` now compacts ``grid_row_span > 1`` sections instead of skipping them. They form their own single-section groups since rowspan sections trunk at a Y of their own and don't share a horizontal inter-section bundle with row mates. * `_space_ports_from_termini` skips off-track stations when collecting termini. They get lifted above the topmost line track later, so they no longer share a Y with the inter-section bundle; treating them as termini was pushing ports above the on-track row (and dragging the upstream port via junction propagation), causing a visible kink in the inter-section bundle. * When a captioned off-track icon sits at the bottom of the lifted band, `_compact_row_content_to_bbox_top` widens its per-section clear-shift gap by the caption extent so the optional caption text (PR #269) doesn't end up overlapping the topmost line track after compaction. (cherry picked from commit 088edd2) * fix(layout): caption scaling, inter-section port snap, top-fill fan Polishes the explicit-grid layout pipeline so trunk lines flow cleanly across section boundaries and large bbox top slack stops looking empty. * Caption text under file icons now renders at 60% of the label font size and clamps its X within the section bbox so long captions (e.g. ``MaxQuant``, ``GEO ID``) stop spilling onto the section border. Compaction reserves the smaller caption extent in the off-track gap calculation. * New Phase 13c snaps LR/RL exit port Ys to the downstream entry Y for same-row connections so a row-spanning section's trunk no longer kinks at the section boundary; junctions are re-positioned afterwards. Gated on user-supplied ``%%metro grid:`` directives to preserve auto-layout line-offset ordering. * New Phase 13d redistributes trunk-candidate siblings symmetrically around the topmost trunk station when a section retains visible empty top space (e.g. Reporting fed from Functional with off-track inputs), filling the bbox top instead of leaving it blank. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit cc28ae5) * fix(layout): skip upward fan on U-turn and dodge label-vs-diagonal Two polish fixes to PR #272's top-fill fan and the label placer: * Phase 13d (`_fan_free_content_upward`) now skips lifting a trunk candidate whose incoming bundle has multiple external feeders all at or below the row's trunk Y. Lifting in that case forces the bundle to climb against the trunk (a "U-turn") and visibly bends past sibling routes that stay at trunk Y. Reporting section in nf-core/differentialabundance: Shiny app now sits on the trunk and Quarto report stays below it. * New `_avoid_diagonal_routes` pass after label placement flips a label to the opposite side of its station when its bbox overlaps a non-horizontal route segment (and the flipped position is collision free). Trunk horizontals are deliberately ignored. Fixes the GSEA label overlapping the off-track icon descent in section 3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 30158a0) * fix(layout): snap station and port Ys to row-wide grid Final layout phase snaps every non-port station and every LEFT/RIGHT port to a clean grid pitch. Earlier phases (trunk-Y align, downstream port-snap, fold-exit alignment) compute shifts that don't respect the grid, leaving coordinates at fractional Ys (e.g. 298.785 when the pitch is 55). Group sections by row using ``_row_y_grid_info``, derive ONE shared grid origin per row from the mode of (y % pitch) across all non-port stations in the row, and snap every station and LR/RL port in the row to that origin. Sections outside any row group snap to the input ``y_spacing`` with their own origin. A per-section origin (as in the prior pass) drifted between sections in the same row when one section was on residue 0 and the next on residue 23.9, kinking trunks at section boundaries. Sharing the origin across the row keeps trunks co-linear. - bounds each snap by half a pitch so adjacency cannot flip - skips off-track stations and TOP/BOTTOM ports whose Y is fixed (cherry picked from commit b9a10ab) * fix(layout): symmetric fan for terminal full-bundle columns Terminal sections (no exit ports) sometimes hold a column of stations that all carry the full bundle and all receive from the same upstream branches, with no obvious trunk station to pin. The existing fan-out logic requires a unique trunk + strict-subset siblings, so it leaves these columns stacked tightly. Add a second pass that runs only when --center-ports is on and the section has no exit ports: when a column's stations are all full-bundle (>=2), redistribute them symmetrically around a trunk Y derived from other full-bundle stations in the section (or the LR port Y). Even counts leave the trunk row empty; odd counts keep one station on the trunk. Non-terminal sections, sub-bundle siblings, file inputs and fan-in chains are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit b2ca836) * fix(layout): symfan all full-bundle columns and pin off-track to consumer (cherry picked from commit 4a5d184) * fix(layout): pin source stations to per-line track, not trunk fan `_redistribute_fanout_siblings` (PR #266) rearranges fan-out branches symmetrically around the trunk junction of a column when `--center-ports` is on. Its scope was meant to exclude file-input columns, but in nf-core/differentialabundance the data-import section has `meta_in` carrying the full bundle and the other source inputs (matrix_in, cel_in, mq_in, geo_in, gtf_in) each carrying a strict subset. Those siblings were redistributed around `meta_in` even though they aren't a real fan-out, pushing every file icon off its line's track and forcing each consumer line to traverse the entire section to reach its input. Tighten the scope so a sibling only qualifies when it shares at least one predecessor with the trunk station. Real fan-outs (differential's limma + DESeq2/dream/propd, all fed by matrix_filter) still redistribute; columns of source stations stay on their per-line track Y so the file icon for each line sits adjacent to its consumer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit a76a21e) * fix(layout): preserve trunk-Y alignment for rowspan sections during compact `_compact_row_content_to_bbox_top` (PR #278) isolated every ``grid_row_span > 1`` section into its own group on the assumption that rowspan sections "trunk at a Y of their own". That assumption breaks for sections like nf-core/differentialabundance's data_prep, which rowspans down over a row-1 plots block but whose trunk Y still sits on the row-0 inter-section bundle. In isolation, data_prep computed an 82.5px upward shift to remove its (now legitimately large) top padding, pulling validator and matrix_filter above the row's trunk Y and breaking the horizontal bundle from data_prep into differential. Downstream ``_snap_all_y_to_grid`` then flipped the row's grid mode onto the shifted residue, snapping differential/reporting fan-out columns onto the same Y slot (dream/limma, deseq2/propd, bundle/report_html all collided). Keep the rowspan exclusion only when the rowspan section's trunk Y differs from its column-adjacent neighbour's. When trunks align, compact groups them together so the shared upward shift is bounded by the smallest tolerance in the group, preserving the inter-section bundle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 85d2114) * fix(layout): lift source-stack inputs above the trunk to fill top space `_fan_free_content_upward` only fans full-bundle trunk candidates upward, so a section whose entry column has a single full-bundle station plus subset-bundle source inputs (file icons with no inbound edges, e.g. nf-core/differentialabundance's data-import section) leaves its bbox top empty: every source sits at or below the trunk. Add a companion phase `_fan_source_inputs_upward` that detects this shape and lifts the nearest-to-trunk sources into the empty top band. Each lifted source drags its linear consumer chain (single inbound edge, identical line set, section-internal) so per-line tracks stay straight from icon to junction. Scoped to explicit `%%metro grid:` pipelines. Bounded by `min(slots, n_sources // 2)` so a majority of sources stay below the trunk; capped by `(trunk_y - bbox_y - y_spacing / 4) // y_spacing` so the topmost lifted station stays inside the bbox with a margin. U-turn risk is nil because sources have no upstream feeders. Differentialabundance section 1 now shows GTF + Matrix above the trunk and Affy CEL + MaxQuant + GEO below, filling the 132.5px of previously-empty bbox top space. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f02d705) * fix(layout): three v93 layout polish issues 1. Compact below-trunk sources after source-input lift When ``_fan_source_inputs_upward`` lifts the top N sources above the trunk (e.g. GTF, Matrix in differentialabundance section 1), the remaining below-trunk sources stayed at their original Ys, leaving a multi-slot empty band between the trunk row and the first below-trunk source. Pack the remaining sources upward at ``trunk_y + i*y_spacing`` so the gap collapses. 2. Re-fan full-bundle columns around the row's final trunk Y ``_redistribute_full_bundle_columns`` runs early using local LR port Ys as the symmetric centre. For terminal sections whose sole LR port Y differs from the row's eventual trunk Y (e.g. Reporting's entry port at the bundle-column Y rather than the row trunk Y), the fan ends up offset from the trunk row. Add Phase 13g ``_recenter_full_bundle_columns`` to re-distribute each multi-station full-bundle column around the entry/exit port station Y, which by that point sits on the row's inter-section bundle Y. 3. Stagger captions when adjacent file icons would overlap Stations carrying multiple ``%%metro file:`` directives place icons at ``terminus_width + ICON_INTER_GAP`` (32 px) intervals. Captions like ``Samples`` and ``Contrasts`` measure ~50 px wide and overlap when both sit at the same Y. Detect the overlap from the estimated caption widths and drop every other caption to a second row beneath the first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 9322198) * fix(layout): v94 polish - terminus Y, caption fit, rowspan trim - Pin a single-station downstream terminus to its sole in-section upstream's Y after full-bundle column re-centering, so file icons like the Reporting ``Bundle`` ZIP line up with their source station. - Replace the "drop alternate captions to row 2" overlap workaround with a horizontal spread: ``_render_terminus_icons`` now widens the per-icon centre-to-centre step when adjacent captions would collide, keeping all captions on one row. ``_terminus_icon_clearance`` expands the section bbox by the same amount so the wider icon row still fits. ``_build_section_subgraph`` now copies ``terminus_names`` / ``terminus_icon_types`` so the layout-side clearance check sees the captions. - Re-shrink section bboxes to their content bottom after late lifts (``_fan_source_inputs_upward``, ``_recenter_full_bundle_columns``): rowspan>1 sections like ``data_prep`` no longer keep the inflated height once content has been pulled up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f2dfda8) * fix(layout): exclude leftward bbox overhang from column width Sections whose ``bbox_x`` is pushed further left than the standard ``-SECTION_X_PADDING`` (e.g. by ``_adjust_terminus_icon_clearance`` expanding the bbox to fit file icons on source stations) had that extra overhang counted into the column width. Adjacent columns were placed at ``offset_x + col_widths[col] + section_x_gap``, so the leftward overhang inflated every gap downstream of the expanded column. On nf-core/differentialabundance this widened the gap between section 1 and section 2 from 50px to ~103px while 2-3 and 3-4 stayed at the expected 50px. Use a small ``_effective_width`` helper that re-anchors the bbox to the standard left edge (``bbox_x + bbox_w + SECTION_X_PADDING``) when measuring per-column widths and when sizing spanning sections. The leftward overhang is left in place; Phase 3b of compute_layout already bumps the global ``x_offset`` to keep that content on canvas. Standard sections (``bbox_x == -SECTION_X_PADDING``) are unaffected because the formula collapses to ``bbox_w``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 7282271) * fix(layout): tighten lower-row gap left by rowspan claims After _shrink_bboxes_to_content_bottom, recompute the slack between each lower row and the sections that actually end above it, and shift those rows up to close it. _compute_section_offsets sizes row_heights from pre-shrink bbox heights, which leaves visible empty space when the only section filling a row is a rowspan that has since shrunk to its content. Sections that extend into the lower row are excluded from the bottom measurement so the gap is governed by row-r-1 content, not by rowspan claims. (cherry picked from commit e2284af) * fix(layout): include mixed-bundle column-mates in section symfan When a column in a grid section has mostly full-bundle stations but one or two single-line side branches (e.g. Functional enrichment's gprofiler2/GSEA/decoupler carrying all study types plus a single ``rnaseq`` method like grea), the strict gate on ``_redistribute_full_bundle_columns`` skipped the column and left the minor branch stranded on its per-line track Y. The resulting layout broke the section's symmetric fan and pushed the line through the section at an uneven slope. Three narrow changes: 1. ``_redistribute_full_bundle_columns`` and its late-pass companion ``_recenter_full_bundle_columns`` now also fire on columns where at least one station is full-bundle and the rest are non-source strict-subset siblings (i.e. real fan-out branches with a predecessor, not file inputs). This mixed-mode fan only fires when the section already has another all-full-bundle column, so ``_redistribute_fanout_siblings`` keeps its sole responsibility for "unique trunk + siblings" patterns (e.g. limma + dream/DESeq2 /propd in the differential section). 2. ``_redistribute_fanout_siblings`` now ignores off-track column- mates (matching the off-track skip the full-bundle pass already has) and relaxes its sibling gate from "shares a predecessor with the trunk" to "has at least one predecessor". A subset sibling fed by a separate upstream method is still a real fan-out branch and should slot into the symmetric fan; only source files (in-degree 0) keep their per-line track Y. 3. Off-track inputs are re-anchored to their consumer twice: once after grid-snap (already present), and again after the late-pass ``_recenter_full_bundle_columns`` shifts the consumer onto the row-wide trunk Y. Without the second pass the input icon stayed at the pre-recenter Y, visibly overlapping the consumer station. All 736 existing tests still pass; v97-style "all full bundle" columns are byte-identical. v98's Functional enrichment section now renders as a clean 2x2 symmetric fan with the off-track Gene sets / Network icons visibly above the trunk line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 763f292) * fix(layout): prefer trunk feeder Y at exit ports + invariant tests When an LR/RL section's exit port collects both a full-bundle trunk station and a minor single-line side-branch feeder (e.g. propd -> grea in differentialabundance), the prior code averaged feeder Ys per line. A lone below-trunk feeder pulled rnaseq's representative Y downward, reordering the bundle at the port; horizontal reconciliation then shifted the trunk station's offsets to match, producing a visible 9px kink at the section boundary (the "limma kink"). Detect the trunk feeder (a feeder whose lines cover the port's line set) and use its Y as the per-line representative. Side branches no longer rotate the bundle; the kink stays at the side branch where it belongs. Adds tests/test_layout_invariants.py with four cross-section checks that fail on the pre-fix layout for the differentialabundance graph: - test_row_trunk_marker_cy_consistent: trunk markers share cy within each grid row - test_symfan_pairs_share_y: column-mate fan pairs are mirrored around the trunk - test_off_track_inputs_above_consumer: off-track inputs sit above their consumer - test_no_kink_at_section_boundary: adjacent exit / entry ports render at the same cy Fixtures: copies of the da_pipeline and rnaseq_sections graphs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 9d93c1f) * fix(layout): side-branch late diagonal + bbox grows with re-anchor [skip ci] Two related routing/layout fixes for the differentialabundance metro map: 1. Side-branch single-line exits no longer climb to the trunk bundle immediately after the source station. When a non-port internal station sits clearly off its section's trunk Y and its only outgoing line targets either the section's exit port or another internal station on the trunk, the diagonal placement is biased toward the target (overriding the standard fork bias). The line then stays on its own track for most of the section, only climbing to trunk Y just before exiting. Concretely fixes propd's rnaseq exit edge in section 2: pre-fix the line climbed to trunk Y at x=654 (25px past the source); now it stays at y=320 until x=860 (230px) and climbs only in the last 55px before the section's right edge. 2. ``_reanchor_off_track_to_consumer`` grows the section bbox upward when the final re-anchored off-track input position would put the icon outside the section's padding zone, and shifts the whole graph down when that growth pushes any bbox above the canvas top margin. The row top-align phase is re-run after the second re-anchor so row mates keep flush bbox tops. Concretely fixes section 3 (functional) where gmt_in's terminus icon extended 5.5px above the bbox top in v102. New invariant tests: - test_side_branch_edge_stays_off_trunk: side-branch exit edges must keep the line at the source Y for at least 30% of the source->target horizontal run. - test_section_bbox_contains_all_content: every section bbox must enclose its on-track stations and off-track icons (accounting for marker / terminus half-height). Both fail on the pre-fix code and pass with the fixes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 2f26d11) * fix(layout): auto-balance sections and converge multi-source termini Three v104 improvements with invariant regression tests: 1. _balance_section_content_around_trunk: a new layout phase that redistributes fan-out siblings to fill empty bands above the trunk. Lifts the bottommost (homogeneous-line) or topmost (heterogeneous- line) below-trunk movable into a new top slot when the bbox has room for the marker plus its above-marker label; otherwise swaps the bottommost below-trunk movable with the topmost above-trunk station to reorder the band without growing the bbox. Uses a line-aware feeder walk so single-feeder lifts aren't blocked by unrelated stations sharing a junction port. 2. _insert_terminus_convergence_stations: parser pass that inserts a hidden convergence station before any terminus with 2+ inbound sources so the routing engine allocates a column for the merge. Diagonals from the sources meet at the converge station and the final segment to the terminus marker is orthogonal. 3. Section 1 (data_prep) and Section 2 (differential) of the differentialabundance pipeline now fill their above-trunk bands instead of stacking content below the trunk. New invariant tests in test_layout_invariants.py: - test_section_top_band_filled - test_section1_input_above_trunk - test_terminus_not_directly_after_diagonal All three fail on the da-render-integration baseline and pass after the fix; the 741 existing tests continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 881fdb4) * fix(layout): keep balance-pass lift inside section bbox The v103 test_section_bbox_contains_all_content invariant requires that station markers (9.5 px half-height) and off-track file icons (16 px half-height) stay inside their section bbox. v104's `_balance_section_content_around_trunk` clamped `new_y` only against label clearance, so empty-label file inputs (e.g. `cel_in[ ]`) could land flush with `bbox_y` and overflow by half a marker. Bump the floor to `max(label_clearance, marker_half)` where `marker_half` is 16 px for off-track icons and 9.5 px for on-track markers, matching v103's invariant geometry. (cherry picked from commit 3ced84b) * feat(layout): auto half-grid placement for 2-branch symmetric fans When a section contains exactly two on-track branch stations sharing a column (no off-track inputs, no extra on-track content), place them at trunk_y +/- 0.5 * y_spacing instead of trunk_y +/- 1 * y_spacing. The section then needs only one vertical grid unit for the fan instead of two, removing wasted vertical space in topologies like the differentialabundance Plots section. Trunk Y stays on the integer grid; only the two branch stations sit at half-pitch. They are tracked in graph._half_grid_station_ids so the final snap-to-grid pass leaves them at their half-pitch offsets and ignores them when computing the row grid origin. The dedicated Phase 13d3 (_apply_half_grid_2branch_symfan) handles sections that sit alone on a row, which the per-row redistribute / recenter passes skip; the existing redistribute / recenter passes also honour the trigger for row-shared sections. Trigger is fully automatic; no directive is exposed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f6cc949) * fix(layout): bypass non-consuming stations via virtual station insert Lines that flow through a section but aren't consumed by every station-column they cross were previously routing through the non-consumer's marker bbox. The differential-abundance pipeline shows this at ``Annotate results``: ``limma`` carries rnaseq/affy/maxquant/geo, but only rnaseq+affy stop at the annotate marker -- maxquant and geo continue to the section's exit port and are pinned to the same trunk Y, crashing the annotate pill. Extend the v104 terminus-convergence pattern to handle this case: inserting a hidden virtual station ``V`` (``is_hidden=True``) in the bypassed station's section gives the routing engine a column-mate to fan to/from, so the bypass uses the same fork-out and fork-in diagonals the rest of the diagram uses for parallel branches. Trigger (in ``_insert_bypass_stations``, run after ``_resolve_sections``): 1. For each non-port, non-hidden, non-terminus station S, gather consumed_lines(S) from inbound edges. 2. For each in-section predecessor P with layer(P) < layer(S), look at outbound edges ``P -> exit_port`` carrying a line L not in consumed. 3. Require P also feeds the same exit_port with at least one line from consumed_lines(S) -- this is the signal that P and the exit port both sit on the trunk that S is on, so the non-consumed line would cross S's row. 4. Replace ``P -> exit_port (L)`` with ``P -> V (L) + V -> exit_port (L)``. Per-section layers are computed locally (intra-section subgraph, including ports) with exit ports pinned past internal stations, so longest-path quirks where two layer-equal stations sit in adjacent columns don't suppress the trigger. The hidden bypass station renders with no marker and no label (``is_hidden`` is already excluded by ``render/svg.py`` and the collision resolver), so the visual is indistinguishable from ``Annotate results``' column having a second pill above/below the trunk -- which is exactly the "parallel branch with an anonymous station" effect the design language calls for. Add ``test_non_consumed_lines_route_via_virtual_station`` over ``da_pipeline.mmd`` that: * asserts at least one ``__bypass_*`` hidden station exists; * asserts the bypass V chains ``limma -> V -> exit_port`` for both maxquant and geo; * asserts maxquant and geo rendered routes clear annotate's marker bbox (half-bbox + slack). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 384befe) * fix(layout): block balance lift onto off-track icon slot The auto-balance pass (Phase 13h2) lifted below-trunk movable siblings into the topmost empty above-trunk slot without checking whether that slot was already reserved by an off-track input icon in the same column. In the differentialabundance functional section, ``gmt_in`` (off-track, lifted to y=100) and ``grea`` (a single-line rnaseq sibling at y=265) shared column 1040.7; the lift moved ``grea`` to y=100 directly on top of ``gmt_in``'s file icon, producing a marker collision in the rendered SVG. Fix: when selecting a balance candidate, walk every below-trunk movable sibling and skip any whose column already contains a station (incl. off-track) at the prospective lift Y. Off-track icons now reserve their column slot from subsequent placement. Add a post-layout validator ``_guard_no_station_overlap`` that errors with ``Position clash: <name1> ... overlaps <name2> ...`` when any two station / file-icon marker bboxes intersect, so end users running ``nf-metro render`` against a pipeline that triggers a similar regression get a clear actionable message rather than a silently broken render. Add the matching invariant ``test_no_station_or_icon_overlap`` parametrised over ``da_pipeline.mmd`` and ``rnaseq_sections.mmd`` to lock the fix in regression coverage. (cherry picked from commit 9116778) * fix(layout): tighten bypass V hop to span only V's column The v110 bypass virtual station produced a diagonal route immediately at the predecessor station (e.g. ``limma``) instead of just before the bypass V, so the bypass line ran parallel below the trunk for most of the bypassed station's section. In the differential-analysis section the maxquant / geo lines diverged at limma's X (~629) and re-converged only at the exit port (~908), creating a long visible side-branch. When the diagonal target (or source) is a hidden ``__bypass_`` station, treat the bypass V as a join (or fork) anchor in ``_route_diagonal``. ``_compute_diagonal_placement`` then biases the diagonal toward V, so the hop diverges just before V's column (~30 px upstream) and re-converges just after V's column. The remaining trunk segment is no longer interrupted by a parallel branch. Add the matching invariant ``test_bypass_avoids_off_track_inputs`` that asserts each ``__bypass_*`` virtual station sits at least one marker clearance (~12 px) away from any off-track input icon in the same column. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 91191b3) * fix(layout): clean bypass routing, compact sections, centre fan-out Issue 1: Remove __bypass_*-specific routing in _route_diagonal. Bypass virtual stations are now indistinguishable from any other off-trunk station for routing/layout; only the SVG renderer skips their marker. Issue 2: Add below-trunk compaction in _balance_section_content_around_ trunk. When the first row below the trunk is empty but content sits deeper, lift the entire below-trunk stack up by one y_spacing slot. Honours marker-clearance and column-collision constraints. Issue 3: Add _recenter_loop_side_stations phase. Layer-based X placement biased fan-out side stations (propd, dream, DESeq2, and the bypass V itself) toward the fork side. Recompute their X as the midpoint of the two diagonal corners derived from the routing geometry so they sit centred on the loop's horizontal run. Issue 4: Shrink bbox top in _apply_half_grid_2branch_symfan after the ±27.5 placement. The bbox was sized for full ±55 spread, leaving a half-y_spacing gap above the first half-grid branch. Adds four invariant tests covering each fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 4b2265a) * fix(layout): tighten bypass V hop, shift sparse loop stations clear Issue 1: The v113 cleanup (4b2265a) removed the bypass-routing override that biased ``P -> V`` diagonals toward V (treating V as a join) and ``V -> T`` diagonals away from V (treating V as a fork). Without the override the limma -> V -> exit_port loop in the differential section diverged just past limma (at the fork bias) and ran 124 px parallel below the trunk before re-converging, instead of the short 50-px hop the bypass V was designed to produce. Restore the override; the hop now spans only V's column. Issue 2: ``grea`` in the differential-functional section sat at the same Y row as ``decoupler`` (both at the section's below-trunk row), so the non-consumed lines (affy, maxquant, geo) flowing from the section entry port to decoupler crossed grea's marker bbox at y~268 (grea bbox 260-270). The bypass V mechanism only fires for direct ``pred -> exit_port`` edges and doesn't cover this same-layer sibling pattern. Add a Phase 13k that detects loop side stations with a single-line consumption set, sharing their row Y with a busier sibling in the same section, and shifts them by half a ``y_spacing`` further from the trunk. Section bbox grows to accommodate. The right invariant we lacked: ``_guard_no_station_overlap`` catches marker/marker collisions (Issue A) but not line/marker crossings (Issue B, the "breeze-past" pattern). Add the runtime validator ``_guard_no_line_crosses_non_consumer`` and the matching invariant test ``test_lines_dont_cross_non_consumer_markers`` that parse the routed polylines and assert no segment of a non-consumed line crosses any station's marker bbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit df746c4) * fix(layout): snap sparse loop stations to full grid, fix bypass V kink Two v114 layout regressions: 1. ``_shift_sparse_loop_stations_to_clear_bundle`` shifted single sparse loop stations like ``grea`` by ``y_spacing / 2``, parking them mid-row between two full grid slots and marking them half-grid even when their section had no 2-branch fan. Shift by a full ``y_spacing`` and stop marking them half-grid; the half-grid offset is reserved for the auto-half-grid 2-branch symfan feature. 2. Bypass V routes formed an asymmetric U at the virtual station: the P -> V and V -> T halves were spread independently in ``_spread_diagonal_bundles`` (asymmetric X clamping at the V-side bound) and used different ``min_straight`` values when the far endpoint was a port (15px vs 10px), leaving one side of the U collapsed and the other with a visible flat. Skip the spread for bypass V hops and pin the V-side ``min_straight`` to ``MIN_STRAIGHT_EDGE`` on both halves. Adds ``test_all_stations_snap_to_grid`` and ``test_bypass_v_horizontal_segment_is_flat``; both fail on the v114 baseline and pass after the fix. (cherry picked from commit 14776f9) * fix(layout): align trunk loop-column stations with off-trunk siblings v113 added _recenter_loop_side_stations to centre off-trunk fan-out stations on the loop's midpoint, but left the trunk-row station (e.g. limma in the differentialabundance differential section) at its raw layer X. This produced a visible X mismatch: limma at 629.4 vs propd/dream/DESeq2 at 648.6 in the same loop column. Add a second pass that snaps each trunk-Y station to the mean X of its "clean" off-trunk loop-column siblings (single inbound + single outbound edge, both endpoints on trunk Y). Pass 1 already places those siblings at the column's geometric midpoint, so the trunk station inherits the same X and column-mates line up. The pass only fires when the station's visible neighbours are all on trunk Y -- off-track inputs (e.g. gmt_in feeding gsea) keep their consumer anchored to the input column, not the loop midpoint. Add test_loop_column_stations_share_x: groups stations into loop columns by (rightmost trunk-pred-X, leftmost trunk-succ-X) and asserts column-mates share X within 1px. Fails on the v115 baseline (limma 600.08 vs deseq2/dream 617.58 in the fixture) and passes after the fix. (cherry picked from commit 450bd18) * fix(layout): pad section bbox below shifted sparse loop stations After Phase 13k shifts a sparse loop station (e.g. ``grea``) one ``y_spacing`` further from the trunk, the section bbox grew by only ``STATION_RADIUS_APPROX`` (5px) instead of the standard ``section_y_padding`` (50px). The bbox bottom ended up flush against the marker, leaving no visual padding below the station and crowding the row 0 -> row 1 transition for downstream sections (e.g. ``plots`` in the differentialabundance pipeline). Grow the bbox by ``section_y_padding`` after the shift, and add a new Phase 13l (``_push_lower_rows_after_bbox_grow``) that pushes sections in lower rows down so the gap to the grown bbox bottom stays at least ``section_y_gap``. Mirrors ``_tighten_lower_rows_after_shrink`` but in the opposite direction. Add invariant tests: - ``test_section_bbox_has_bottom_padding``: every section's bbox bottom sits at least ``section_y_padding`` below the lowest internal station's centre Y. - ``test_row_gap_accommodates_bypass``: every column-overlapping row r -> row r + 1 pair has a bbox-to-bbox gap of at least ``section_y_gap`` (tested at ``y_spacing=55`` to match the production render that exposed the regression). (cherry picked from commit 427fa31) * fix(layout): scope v117 row-push to column-overlapping pairs only ``_push_lower_rows_after_bbox_grow`` was added in v117 to ensure a ``section_y_gap`` clearance between a row r-1 section whose bbox grew downward (via the sparse-loop shift in Phase 13k) and any row r section. The implementation took the global maximum bbox bottom of row r-1 against the global minimum bbox top of row r, ignoring whether the two sections actually share columns. That over-fires on examples like ``fold_double`` and ``rnaseq_lite`` where a tall rowspan section in row r-1 sits in a different column from the row r sections. The push then injected 100-200px of empty vertical space between rows, breaking the visual layout for several gallery renders. Restrict the deficit calculation to column-overlapping (upper, lower) section pairs, mirroring the logic already in ``test_row_gap_accommodates_bypass``. Non-overlapping pairs no longer contribute to the push. Verified: - DA render (``y_spacing=55``, ``--center-ports``) at the savepoint matches v119 byte-for-byte. - ``test_row_gap_accommodates_bypass`` and ``test_section_bbox_has_bottom_padding`` still pass. - Full test suite (788 tests) green. - fold_double / rnaseq_lite / variantbenchmarking[_auto] gallery renders return to pre-v117 layout. * Revert "fix(labels): stagger labels of vertically stacked same-column stations" This reverts commit d9f98c1. * style: ruff format --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(labels): stagger labels of vertically stacked same-column stations When two stations in the same section share an X column and adjacent Y tracks (e.g. Affy and Proteus in the differentialabundance metro map), their centered labels visually crowd along the same vertical line. Adds a post-placement pass that nudges labels horizontally in opposite directions, only when the shift would not introduce a new collision. [skip ci] (cherry picked from commit 265b7e4) * fix: re-align row bboxes after port-terminus spacing Phase 11 _space_ports_from_termini calls _expand_bbox_for_y when it pushes a port outside the section bbox, which can lift a single section's bbox_y above its row-mates. Phase 11b _recompute_grid_group_bboxes then sets bbox_y to min(non_port_ys) - max_y_pad and re-expands for off-grid ports, baking the shift in. Phase 9 _top_align_row_sections runs before Phase 11 so it can't undo the resulting offset. Add a Phase 11c re-run of _top_align_row_sections so bbox tops within each row stay flush after the port-terminus spacing pass. Repro: render examples/differentialabundance with port-pushed entry on the functional section. Before: functional bbox_y=40, others 85, visible 45px vertical offset. After: all sections at bbox_y=85. (cherry picked from commit 9b5d298) * fix(layout): block station overlaps when track compaction collides cells The track allocator can return identical track values for two stations on different lines when each is the sole occupant of its line at a given layer (e.g. side-by-side terminus branches like the Affy/Proteus microarray and proteomics terminals in nf-core/differentialabundance). After coordinate assignment they land on the same (x, y), causing the pills and labels to render directly on top of one another. Add a post-placement pass in _layout_single_section that buckets real stations by their primary axis (layer column for LR/RL, row for TB) and pushes any secondary-axis duplicates apart by one spacing unit. Insertion order in sub.stations is the stable tiebreaker so the earlier-defined station keeps its slot. The pass is a no-op when there is no collision, so existing layouts are unaffected. [skip ci] (cherry picked from commit 9e97491) * fix(layout): increase SECTION_Y_PADDING for bundle margin Provides visual breathing room so line bundles passing through edge-tracked stations don't touch the section bbox border. [skip ci] (cherry picked from commit 72e8379) * fix(layout): anchor inter-section exit ports to downstream bundle Y When an LR/RL exit port's bundle materialises at a single same-row Y in the downstream section (parallel fan-out where every internal station carries the same line set), pull the exit port to that Y so the inter-section run stays horizontal. Falls back to the local-internal centre when the exit has fan-in from distinct Ys, the downstream entry is a branch fan-out, multiple downstream entries land at different Ys, or the anchor falls outside the section bbox. Eliminates the drop from differential.annotate (centred) to functional.gsea (top fan-out station) on the differentialabundance map. (cherry picked from commit edb06e4) * fix(layout): anchor fan-in exit to downstream when bundle is multi-line Phase 10d preserved centred midpoints for any 3+ source fan-in exit, which kept variantbenchmarking's filter merge correct but left the differentialabundance functional exit centred between gprofiler2 and decoupler even though all four sources carry the same four-line bundle into shinyngs and quarto. The inter-section run then curved up to the reporting entry Y instead of staying horizontal. Relax the 3+ fan-in skip when the exit carries a multi-line bundle (>=2 lines, so every source is parallel-redundant) and one source Y already matches the downstream entry: snap to that Y for a straight inter-section run. Single-line fan-ins still keep their midpoint, so the variantbenchmarking TestFanInExitPreservation assertions hold. (cherry picked from commit 7ce8a2d) * fix(layout): anchor trunk junctions on bundle line for straight trunks Trunk stations - those receiving a pre-existing bundle from at least one predecessor plus side branches - previously snapped to the predecessor centroid, bending the bundle within the section. Detect this case in both _place_single_node (for fan-in junctions) and _place_fan_out (when one fan-out node carries a strict superset of its siblings' lines) and anchor the trunk node on its primary line's base track so the bundle stays straight through the junction. (cherry picked from commit bfde7b0) * fix(layout): symmetric fan-out of trunk siblings, strictly scoped Resurrects the PR #264 idea (symmetric distribution of side stations around the section trunk Y when ``--center-ports`` is on) but scopes it tightly so it ONLY moves stations that are unambiguously fan-out side branches of a trunk junction. A column qualifies for redistribution only when it contains exactly one station whose line set equals the section's full LEFT/RIGHT bundle (the trunk junction) AND at least one sibling station whose line set is a strict subset of the bundle. Those siblings are redistributed in alternating ``+1, -1, +2, -2, ...`` slots at ``y_spacing`` pitch around the trunk Y; the trunk itself stays pinned. Everything else is left in place: file inputs, linear processing chains, fan-in structures (many sources converging on one station), columns without a unique trunk, and any sibling that carries the full bundle (linear pass-through, not a fan-out branch). This fixes the original PR #264 regression where data-import-style sections (fan-in to a Validate node, then linear to Filter matrix) had their input labels and chain stations mixed at random Ys. In nf-core/differentialabundance: - Data import: untouched (no qualifying column, only file inputs and processing chains). - Differential: limma is the trunk; DESeq2/dream/propd carry only ``{rnaseq}`` (strict subset) and now fan symmetrically above and below limma's Y. - Functional: gprofiler2/decoupler/grea carry the full bundle and are intentionally not redistributed; file inputs (Gene sets, Network) are in a different column from GSEA and stay put. - Reporting: no qualifying trunk-junction column; untouched. Default (``--no-center-ports``) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 6415807) * feat(layout): add %%metro off_track: directive to lift file inputs above line tracks File-input stations declared in the flow graph (e.g. samples_in[Samples] + %%metro file: samples_in | CSV) normally consume a line-track Y slot because each station owns a track. This makes input file icons share Y with the study-type line lanes, defeating the visual goal of an "inputs band" above the section's pipeline tracks. Add a new %%metro off_track: id1, id2, ... directive that marks stations to be lifted into a separate band above the section's topmost line track in a final post-layout phase. Section bboxes expand upward to fit the band, and the whole graph is shifted down if needed so the new top stays inside the canvas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit dca94e9) * fix(layout): align trunk Ys across same-row sections Previously sections in the same grid row could have their inter-section trunk bundles enter and exit at different Y values when the upstream section had more above-trunk content than the downstream section. The visible result was lines bending vertically between section bboxes even though the bbox tops were aligned. Add a new Phase 11ca _align_row_trunk_ys. For each contiguous column group of LR/RL sections within a grid row, identify each section's trunk station (the full-bundle station connected to an LR port) and shift content downward in sections whose trunks sit above the row's deepest trunk. Bbox tops are preserved (heights grow downward). Row-spanning sections (rowspan > 1) are skipped to avoid disturbing cross-row vertical relationships. After the shift, ports on shifted sections are re-snapped to the target trunk Y when they have a single internal station at that Y; fan-in convergence ports (2+ internal sources at distinct Ys) are preserved at their centered midpoint so Phase 10d's fan-in geometry isn't disturbed. Update the grid-alignment regression tests: first_station_y consistency and symmetric bbox padding are stricter than top alignment and are not satisfied when sections shift to align trunks. Replace with bbox-top alignment and a non-negative bottom-padding floor. (cherry picked from commit 43e387c) * fix(layout): align bbox tops after off-track lift The %%metro off_track directive lifts file inputs above the line tracks and expands the section's bbox upward. This left other sections in the same row with their bbox tops misaligned because the row top-align phase ran before off-track lifting. Add Phase 13a (_top_align_row_bboxes_only) after off-track lifting to grow row-mate bboxes upward to the row's topmost bbox. Unlike earlier top-align phases, this only adjusts bbox_y/bbox_h - station Ys stay in place so the lifted section's content is preserved and the rest of the row gets empty space at the top of its bboxes. (cherry picked from commit 2d7a663) * fix(layout): compact row sections so content sits at bbox top Phase 11ca aligns trunk Ys across row-mate sections and Phase 13a expands shorter row-mates' bbox tops to match a section whose bbox grew upward in Phase 13 to accommodate off-track inputs. Without a follow-up, the row-mates without off-track ended up with empty vertical space at the top of their bboxes. Add Phase 13b _compact_row_content_to_bbox_top. For each grid row's contiguous column group: 1. Compute the allowable upward shift per section based on: - distance from on-track content to bbox top (minus padding), and - clearance to any lifted off-track band (so on-track content doesn't collide with the lift band that Phase 13 placed ``y_spacing`` above the on-track top). Apply the minimum allowable shift across the group to every on-track station and port; off-track stations stay anchored to the lift band. Uniform shift preserves trunk alignment. 2. Shrink each section's bbox_h so the bottom slack matches section_y_padding (clamped to keep bottom ports inside the bbox). Sections with grid_row_span > 1 are excluded because their content spans multiple rows and the per-row compaction frame doesn't apply. (cherry picked from commit 2831431) * feat(icons): optional caption name on file/files/dir directives Extend `%%metro file:`, `%%metro files:` and `%%metro dir:` with an optional third field for a human-readable caption rendered directly below the icon. This avoids the previous workaround of attaching a separate naming station that rendered as a process circle. Syntax: `%%metro file: station_id | TYPE | NAME` The caption uses the theme label color/font and is included in the icon's obstacle bbox so neighbouring labels keep their distance. (cherry picked from commit c435357) * fix(layout): compact rowspan sections, skip off-track termini, pad captioned bands Three coupled fixes around off-track stations and rowspan sections: * `_compact_row_content_to_bbox_top` now compacts ``grid_row_span > 1`` sections instead of skipping them. They form their own single-section groups since rowspan sections trunk at a Y of their own and don't share a horizontal inter-section bundle with row mates. * `_space_ports_from_termini` skips off-track stations when collecting termini. They get lifted above the topmost line track later, so they no longer share a Y with the inter-section bundle; treating them as termini was pushing ports above the on-track row (and dragging the upstream port via junction propagation), causing a visible kink in the inter-section bundle. * When a captioned off-track icon sits at the bottom of the lifted band, `_compact_row_content_to_bbox_top` widens its per-section clear-shift gap by the caption extent so the optional caption text (PR #269) doesn't end up overlapping the topmost line track after compaction. (cherry picked from commit 088edd2) * fix(layout): caption scaling, inter-section port snap, top-fill fan Polishes the explicit-grid layout pipeline so trunk lines flow cleanly across section boundaries and large bbox top slack stops looking empty. * Caption text under file icons now renders at 60% of the label font size and clamps its X within the section bbox so long captions (e.g. ``MaxQuant``, ``GEO ID``) stop spilling onto the section border. Compaction reserves the smaller caption extent in the off-track gap calculation. * New Phase 13c snaps LR/RL exit port Ys to the downstream entry Y for same-row connections so a row-spanning section's trunk no longer kinks at the section boundary; junctions are re-positioned afterwards. Gated on user-supplied ``%%metro grid:`` directives to preserve auto-layout line-offset ordering. * New Phase 13d redistributes trunk-candidate siblings symmetrically around the topmost trunk station when a section retains visible empty top space (e.g. Reporting fed from Functional with off-track inputs), filling the bbox top instead of leaving it blank. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit cc28ae5) * fix(layout): skip upward fan on U-turn and dodge label-vs-diagonal Two polish fixes to PR #272's top-fill fan and the label placer: * Phase 13d (`_fan_free_content_upward`) now skips lifting a trunk candidate whose incoming bundle has multiple external feeders all at or below the row's trunk Y. Lifting in that case forces the bundle to climb against the trunk (a "U-turn") and visibly bends past sibling routes that stay at trunk Y. Reporting section in nf-core/differentialabundance: Shiny app now sits on the trunk and Quarto report stays below it. * New `_avoid_diagonal_routes` pass after label placement flips a label to the opposite side of its station when its bbox overlaps a non-horizontal route segment (and the flipped position is collision free). Trunk horizontals are deliberately ignored. Fixes the GSEA label overlapping the off-track icon descent in section 3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 30158a0) * fix(layout): snap station and port Ys to row-wide grid Final layout phase snaps every non-port station and every LEFT/RIGHT port to a clean grid pitch. Earlier phases (trunk-Y align, downstream port-snap, fold-exit alignment) compute shifts that don't respect the grid, leaving coordinates at fractional Ys (e.g. 298.785 when the pitch is 55). Group sections by row using ``_row_y_grid_info``, derive ONE shared grid origin per row from the mode of (y % pitch) across all non-port stations in the row, and snap every station and LR/RL port in the row to that origin. Sections outside any row group snap to the input ``y_spacing`` with their own origin. A per-section origin (as in the prior pass) drifted between sections in the same row when one section was on residue 0 and the next on residue 23.9, kinking trunks at section boundaries. Sharing the origin across the row keeps trunks co-linear. - bounds each snap by half a pitch so adjacency cannot flip - skips off-track stations and TOP/BOTTOM ports whose Y is fixed (cherry picked from commit b9a10ab) * fix(layout): symmetric fan for terminal full-bundle columns Terminal sections (no exit ports) sometimes hold a column of stations that all carry the full bundle and all receive from the same upstream branches, with no obvious trunk station to pin. The existing fan-out logic requires a unique trunk + strict-subset siblings, so it leaves these columns stacked tightly. Add a second pass that runs only when --center-ports is on and the section has no exit ports: when a column's stations are all full-bundle (>=2), redistribute them symmetrically around a trunk Y derived from other full-bundle stations in the section (or the LR port Y). Even counts leave the trunk row empty; odd counts keep one station on the trunk. Non-terminal sections, sub-bundle siblings, file inputs and fan-in chains are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit b2ca836) * fix(layout): symfan all full-bundle columns and pin off-track to consumer (cherry picked from commit 4a5d184) * fix(layout): pin source stations to per-line track, not trunk fan `_redistribute_fanout_siblings` (PR #266) rearranges fan-out branches symmetrically around the trunk junction of a column when `--center-ports` is on. Its scope was meant to exclude file-input columns, but in nf-core/differentialabundance the data-import section has `meta_in` carrying the full bundle and the other source inputs (matrix_in, cel_in, mq_in, geo_in, gtf_in) each carrying a strict subset. Those siblings were redistributed around `meta_in` even though they aren't a real fan-out, pushing every file icon off its line's track and forcing each consumer line to traverse the entire section to reach its input. Tighten the scope so a sibling only qualifies when it shares at least one predecessor with the trunk station. Real fan-outs (differential's limma + DESeq2/dream/propd, all fed by matrix_filter) still redistribute; columns of source stations stay on their per-line track Y so the file icon for each line sits adjacent to its consumer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit a76a21e) * fix(layout): preserve trunk-Y alignment for rowspan sections during compact `_compact_row_content_to_bbox_top` (PR #278) isolated every ``grid_row_span > 1`` section into its own group on the assumption that rowspan sections "trunk at a Y of their own". That assumption breaks for sections like nf-core/differentialabundance's data_prep, which rowspans down over a row-1 plots block but whose trunk Y still sits on the row-0 inter-section bundle. In isolation, data_prep computed an 82.5px upward shift to remove its (now legitimately large) top padding, pulling validator and matrix_filter above the row's trunk Y and breaking the horizontal bundle from data_prep into differential. Downstream ``_snap_all_y_to_grid`` then flipped the row's grid mode onto the shifted residue, snapping differential/reporting fan-out columns onto the same Y slot (dream/limma, deseq2/propd, bundle/report_html all collided). Keep the rowspan exclusion only when the rowspan section's trunk Y differs from its column-adjacent neighbour's. When trunks align, compact groups them together so the shared upward shift is bounded by the smallest tolerance in the group, preserving the inter-section bundle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 85d2114) * fix(layout): lift source-stack inputs above the trunk to fill top space `_fan_free_content_upward` only fans full-bundle trunk candidates upward, so a section whose entry column has a single full-bundle station plus subset-bundle source inputs (file icons with no inbound edges, e.g. nf-core/differentialabundance's data-import section) leaves its bbox top empty: every source sits at or below the trunk. Add a companion phase `_fan_source_inputs_upward` that detects this shape and lifts the nearest-to-trunk sources into the empty top band. Each lifted source drags its linear consumer chain (single inbound edge, identical line set, section-internal) so per-line tracks stay straight from icon to junction. Scoped to explicit `%%metro grid:` pipelines. Bounded by `min(slots, n_sources // 2)` so a majority of sources stay below the trunk; capped by `(trunk_y - bbox_y - y_spacing / 4) // y_spacing` so the topmost lifted station stays inside the bbox with a margin. U-turn risk is nil because sources have no upstream feeders. Differentialabundance section 1 now shows GTF + Matrix above the trunk and Affy CEL + MaxQuant + GEO below, filling the 132.5px of previously-empty bbox top space. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f02d705) * fix(layout): three v93 layout polish issues 1. Compact below-trunk sources after source-input lift When ``_fan_source_inputs_upward`` lifts the top N sources above the trunk (e.g. GTF, Matrix in differentialabundance section 1), the remaining below-trunk sources stayed at their original Ys, leaving a multi-slot empty band between the trunk row and the first below-trunk source. Pack the remaining sources upward at ``trunk_y + i*y_spacing`` so the gap collapses. 2. Re-fan full-bundle columns around the row's final trunk Y ``_redistribute_full_bundle_columns`` runs early using local LR port Ys as the symmetric centre. For terminal sections whose sole LR port Y differs from the row's eventual trunk Y (e.g. Reporting's entry port at the bundle-column Y rather than the row trunk Y), the fan ends up offset from the trunk row. Add Phase 13g ``_recenter_full_bundle_columns`` to re-distribute each multi-station full-bundle column around the entry/exit port station Y, which by that point sits on the row's inter-section bundle Y. 3. Stagger captions when adjacent file icons would overlap Stations carrying multiple ``%%metro file:`` directives place icons at ``terminus_width + ICON_INTER_GAP`` (32 px) intervals. Captions like ``Samples`` and ``Contrasts`` measure ~50 px wide and overlap when both sit at the same Y. Detect the overlap from the estimated caption widths and drop every other caption to a second row beneath the first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 9322198) * fix(layout): v94 polish - terminus Y, caption fit, rowspan trim - Pin a single-station downstream terminus to its sole in-section upstream's Y after full-bundle column re-centering, so file icons like the Reporting ``Bundle`` ZIP line up with their source station. - Replace the "drop alternate captions to row 2" overlap workaround with a horizontal spread: ``_render_terminus_icons`` now widens the per-icon centre-to-centre step when adjacent captions would collide, keeping all captions on one row. ``_terminus_icon_clearance`` expands the section bbox by the same amount so the wider icon row still fits. ``_build_section_subgraph`` now copies ``terminus_names`` / ``terminus_icon_types`` so the layout-side clearance check sees the captions. - Re-shrink section bboxes to their content bottom after late lifts (``_fan_source_inputs_upward``, ``_recenter_full_bundle_columns``): rowspan>1 sections like ``data_prep`` no longer keep the inflated height once content has been pulled up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f2dfda8) * fix(layout): exclude leftward bbox overhang from column width Sections whose ``bbox_x`` is pushed further left than the standard ``-SECTION_X_PADDING`` (e.g. by ``_adjust_terminus_icon_clearance`` expanding the bbox to fit file icons on source stations) had that extra overhang counted into the column width. Adjacent columns were placed at ``offset_x + col_widths[col] + section_x_gap``, so the leftward overhang inflated every gap downstream of the expanded column. On nf-core/differentialabundance this widened the gap between section 1 and section 2 from 50px to ~103px while 2-3 and 3-4 stayed at the expected 50px. Use a small ``_effective_width`` helper that re-anchors the bbox to the standard left edge (``bbox_x + bbox_w + SECTION_X_PADDING``) when measuring per-column widths and when sizing spanning sections. The leftward overhang is left in place; Phase 3b of compute_layout already bumps the global ``x_offset`` to keep that content on canvas. Standard sections (``bbox_x == -SECTION_X_PADDING``) are unaffected because the formula collapses to ``bbox_w``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 7282271) * fix(layout): tighten lower-row gap left by rowspan claims After _shrink_bboxes_to_content_bottom, recompute the slack between each lower row and the sections that actually end above it, and shift those rows up to close it. _compute_section_offsets sizes row_heights from pre-shrink bbox heights, which leaves visible empty space when the only section filling a row is a rowspan that has since shrunk to its content. Sections that extend into the lower row are excluded from the bottom measurement so the gap is governed by row-r-1 content, not by rowspan claims. (cherry picked from commit e2284af) * fix(layout): include mixed-bundle column-mates in section symfan When a column in a grid section has mostly full-bundle stations but one or two single-line side branches (e.g. Functional enrichment's gprofiler2/GSEA/decoupler carrying all study types plus a single ``rnaseq`` method like grea), the strict gate on ``_redistribute_full_bundle_columns`` skipped the column and left the minor branch stranded on its per-line track Y. The resulting layout broke the section's symmetric fan and pushed the line through the section at an uneven slope. Three narrow changes: 1. ``_redistribute_full_bundle_columns`` and its late-pass companion ``_recenter_full_bundle_columns`` now also fire on columns where at least one station is full-bundle and the rest are non-source strict-subset siblings (i.e. real fan-out branches with a predecessor, not file inputs). This mixed-mode fan only fires when the section already has another all-full-bundle column, so ``_redistribute_fanout_siblings`` keeps its sole responsibility for "unique trunk + siblings" patterns (e.g. limma + dream/DESeq2 /propd in the differential section). 2. ``_redistribute_fanout_siblings`` now ignores off-track column- mates (matching the off-track skip the full-bundle pass already has) and relaxes its sibling gate from "shares a predecessor with the trunk" to "has at least one predecessor". A subset sibling fed by a separate upstream method is still a real fan-out branch and should slot into the symmetric fan; only source files (in-degree 0) keep their per-line track Y. 3. Off-track inputs are re-anchored to their consumer twice: once after grid-snap (already present), and again after the late-pass ``_recenter_full_bundle_columns`` shifts the consumer onto the row-wide trunk Y. Without the second pass the input icon stayed at the pre-recenter Y, visibly overlapping the consumer station. All 736 existing tests still pass; v97-style "all full bundle" columns are byte-identical. v98's Functional enrichment section now renders as a clean 2x2 symmetric fan with the off-track Gene sets / Network icons visibly above the trunk line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 763f292) * fix(layout): prefer trunk feeder Y at exit ports + invariant tests When an LR/RL section's exit port collects both a full-bundle trunk station and a minor single-line side-branch feeder (e.g. propd -> grea in differentialabundance), the prior code averaged feeder Ys per line. A lone below-trunk feeder pulled rnaseq's representative Y downward, reordering the bundle at the port; horizontal reconciliation then shifted the trunk station's offsets to match, producing a visible 9px kink at the section boundary (the "limma kink"). Detect the trunk feeder (a feeder whose lines cover the port's line set) and use its Y as the per-line representative. Side branches no longer rotate the bundle; the kink stays at the side branch where it belongs. Adds tests/test_layout_invariants.py with four cross-section checks that fail on the pre-fix layout for the differentialabundance graph: - test_row_trunk_marker_cy_consistent: trunk markers share cy within each grid row - test_symfan_pairs_share_y: column-mate fan pairs are mirrored around the trunk - test_off_track_inputs_above_consumer: off-track inputs sit above their consumer - test_no_kink_at_section_boundary: adjacent exit / entry ports render at the same cy Fixtures: copies of the da_pipeline and rnaseq_sections graphs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 9d93c1f) * fix(layout): side-branch late diagonal + bbox grows with re-anchor [skip ci] Two related routing/layout fixes for the differentialabundance metro map: 1. Side-branch single-line exits no longer climb to the trunk bundle immediately after the source station. When a non-port internal station sits clearly off its section's trunk Y and its only outgoing line targets either the section's exit port or another internal station on the trunk, the diagonal placement is biased toward the target (overriding the standard fork bias). The line then stays on its own track for most of the section, only climbing to trunk Y just before exiting. Concretely fixes propd's rnaseq exit edge in section 2: pre-fix the line climbed to trunk Y at x=654 (25px past the source); now it stays at y=320 until x=860 (230px) and climbs only in the last 55px before the section's right edge. 2. ``_reanchor_off_track_to_consumer`` grows the section bbox upward when the final re-anchored off-track input position would put the icon outside the section's padding zone, and shifts the whole graph down when that growth pushes any bbox above the canvas top margin. The row top-align phase is re-run after the second re-anchor so row mates keep flush bbox tops. Concretely fixes section 3 (functional) where gmt_in's terminus icon extended 5.5px above the bbox top in v102. New invariant tests: - test_side_branch_edge_stays_off_trunk: side-branch exit edges must keep the line at the source Y for at least 30% of the source->target horizontal run. - test_section_bbox_contains_all_content: every section bbox must enclose its on-track stations and off-track icons (accounting for marker / terminus half-height). Both fail on the pre-fix code and pass with the fixes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 2f26d11) * fix(layout): auto-balance sections and converge multi-source termini Three v104 improvements with invariant regression tests: 1. _balance_section_content_around_trunk: a new layout phase that redistributes fan-out siblings to fill empty bands above the trunk. Lifts the bottommost (homogeneous-line) or topmost (heterogeneous- line) below-trunk movable into a new top slot when the bbox has room for the marker plus its above-marker label; otherwise swaps the bottommost below-trunk movable with the topmost above-trunk station to reorder the band without growing the bbox. Uses a line-aware feeder walk so single-feeder lifts aren't blocked by unrelated stations sharing a junction port. 2. _insert_terminus_convergence_stations: parser pass that inserts a hidden convergence station before any terminus with 2+ inbound sources so the routing engine allocates a column for the merge. Diagonals from the sources meet at the converge station and the final segment to the terminus marker is orthogonal. 3. Section 1 (data_prep) and Section 2 (differential) of the differentialabundance pipeline now fill their above-trunk bands instead of stacking content below the trunk. New invariant tests in test_layout_invariants.py: - test_section_top_band_filled - test_section1_input_above_trunk - test_terminus_not_directly_after_diagonal All three fail on the da-render-integration baseline and pass after the fix; the 741 existing tests continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 881fdb4) * fix(layout): keep balance-pass lift inside section bbox The v103 test_section_bbox_contains_all_content invariant requires that station markers (9.5 px half-height) and off-track file icons (16 px half-height) stay inside their section bbox. v104's `_balance_section_content_around_trunk` clamped `new_y` only against label clearance, so empty-label file inputs (e.g. `cel_in[ ]`) could land flush with `bbox_y` and overflow by half a marker. Bump the floor to `max(label_clearance, marker_half)` where `marker_half` is 16 px for off-track icons and 9.5 px for on-track markers, matching v103's invariant geometry. (cherry picked from commit 3ced84b) * feat(layout): auto half-grid placement for 2-branch symmetric fans When a section contains exactly two on-track branch stations sharing a column (no off-track inputs, no extra on-track content), place them at trunk_y +/- 0.5 * y_spacing instead of trunk_y +/- 1 * y_spacing. The section then needs only one vertical grid unit for the fan instead of two, removing wasted vertical space in topologies like the differentialabundance Plots section. Trunk Y stays on the integer grid; only the two branch stations sit at half-pitch. They are tracked in graph._half_grid_station_ids so the final snap-to-grid pass leaves them at their half-pitch offsets and ignores them when computing the row grid origin. The dedicated Phase 13d3 (_apply_half_grid_2branch_symfan) handles sections that sit alone on a row, which the per-row redistribute / recenter passes skip; the existing redistribute / recenter passes also honour the trigger for row-shared sections. Trigger is fully automatic; no directive is exposed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f6cc949) * fix(layout): bypass non-consuming stations via virtual station insert Lines that flow through a section but aren't consumed by every station-column they cross were previously routing through the non-consumer's marker bbox. The differential-abundance pipeline shows this at ``Annotate results``: ``limma`` carries rnaseq/affy/maxquant/geo, but only rnaseq+affy stop at the annotate marker -- maxquant and geo continue to the section's exit port and are pinned to the same trunk Y, crashing the annotate pill. Extend the v104 terminus-convergence pattern to handle this case: inserting a hidden virtual station ``V`` (``is_hidden=True``) in the bypassed station's section gives the routing engine a column-mate to fan to/from, so the bypass uses the same fork-out and fork-in diagonals the rest of the diagram uses for parallel branches. Trigger (in ``_insert_bypass_stations``, run after ``_resolve_sections``): 1. For each non-port, non-hidden, non-terminus station S, gather consumed_lines(S) from inbound edges. 2. For each in-section predecessor P with layer(P) < layer(S), look at outbound edges ``P -> exit_port`` carrying a line L not in consumed. 3. Require P also feeds the same exit_port with at least one line from consumed_lines(S) -- this is the signal that P and the exit port both sit on the trunk that S is on, so the non-consumed line would cross S's row. 4. Replace ``P -> exit_port (L)`` with ``P -> V (L) + V -> exit_port (L)``. Per-section layers are computed locally (intra-section subgraph, including ports) with exit ports pinned past internal stations, so longest-path quirks where two layer-equal stations sit in adjacent columns don't suppress the trigger. The hidden bypass station renders with no marker and no label (``is_hidden`` is already excluded by ``render/svg.py`` and the collision resolver), so the visual is indistinguishable from ``Annotate results``' column having a second pill above/below the trunk -- which is exactly the "parallel branch with an anonymous station" effect the design language calls for. Add ``test_non_consumed_lines_route_via_virtual_station`` over ``da_pipeline.mmd`` that: * asserts at least one ``__bypass_*`` hidden station exists; * asserts the bypass V chains ``limma -> V -> exit_port`` for both maxquant and geo; * asserts maxquant and geo rendered routes clear annotate's marker bbox (half-bbox + slack). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 384befe) * fix(layout): block balance lift onto off-track icon slot The auto-balance pass (Phase 13h2) lifted below-trunk movable siblings into the topmost empty above-trunk slot without checking whether that slot was already reserved by an off-track input icon in the same column. In the differentialabundance functional section, ``gmt_in`` (off-track, lifted to y=100) and ``grea`` (a single-line rnaseq sibling at y=265) shared column 1040.7; the lift moved ``grea`` to y=100 directly on top of ``gmt_in``'s file icon, producing a marker collision in the rendered SVG. Fix: when selecting a balance candidate, walk every below-trunk movable sibling and skip any whose column already contains a station (incl. off-track) at the prospective lift Y. Off-track icons now reserve their column slot from subsequent placement. Add a post-layout validator ``_guard_no_station_overlap`` that errors with ``Position clash: <name1> ... overlaps <name2> ...`` when any two station / file-icon marker bboxes intersect, so end users running ``nf-metro render`` against a pipeline that triggers a similar regression get a clear actionable message rather than a silently broken render. Add the matching invariant ``test_no_station_or_icon_overlap`` parametrised over ``da_pipeline.mmd`` and ``rnaseq_sections.mmd`` to lock the fix in regression coverage. (cherry picked from commit 9116778) * fix(layout): tighten bypass V hop to span only V's column The v110 bypass virtual station produced a diagonal route immediately at the predecessor station (e.g. ``limma``) instead of just before the bypass V, so the bypass line ran parallel below the trunk for most of the bypassed station's section. In the differential-analysis section the maxquant / geo lines diverged at limma's X (~629) and re-converged only at the exit port (~908), creating a long visible side-branch. When the diagonal target (or source) is a hidden ``__bypass_`` station, treat the bypass V as a join (or fork) anchor in ``_route_diagonal``. ``_compute_diagonal_placement`` then biases the diagonal toward V, so the hop diverges just before V's column (~30 px upstream) and re-converges just after V's column. The remaining trunk segment is no longer interrupted by a parallel branch. Add the matching invariant ``test_bypass_avoids_off_track_inputs`` that asserts each ``__bypass_*`` virtual station sits at least one marker clearance (~12 px) away from any off-track input icon in the same column. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 91191b3) * fix(layout): clean bypass routing, compact sections, centre fan-out Issue 1: Remove __bypass_*-specific routing in _route_diagonal. Bypass virtual stations are now indistinguishable from any other off-trunk station for routing/layout; only the SVG renderer skips their marker. Issue 2: Add below-trunk compaction in _balance_section_content_around_ trunk. When the first row below the trunk is empty but content sits deeper, lift the entire below-trunk stack up by one y_spacing slot. Honours marker-clearance and column-collision constraints. Issue 3: Add _recenter_loop_side_stations phase. Layer-based X placement biased fan-out side stations (propd, dream, DESeq2, and the bypass V itself) toward the fork side. Recompute their X as the midpoint of the two diagonal corners derived from the routing geometry so they sit centred on the loop's horizontal run. Issue 4: Shrink bbox top in _apply_half_grid_2branch_symfan after the ±27.5 placement. The bbox was sized for full ±55 spread, leaving a half-y_spacing gap above the first half-grid branch. Adds four invariant tests covering each fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 4b2265a) * fix(layout): tighten bypass V hop, shift sparse loop stations clear Issue 1: The v113 cleanup (4b2265a) removed the bypass-routing override that biased ``P -> V`` diagonals toward V (treating V as a join) and ``V -> T`` diagonals away from V (treating V as a fork). Without the override the limma -> V -> exit_port loop in the differential section diverged just past limma (at the fork bias) and ran 124 px parallel below the trunk before re-converging, instead of the short 50-px hop the bypass V was designed to produce. Restore the override; the hop now spans only V's column. Issue 2: ``grea`` in the differential-functional section sat at the same Y row as ``decoupler`` (both at the section's below-trunk row), so the non-consumed lines (affy, maxquant, geo) flowing from the section entry port to decoupler crossed grea's marker bbox at y~268 (grea bbox 260-270). The bypass V mechanism only fires for direct ``pred -> exit_port`` edges and doesn't cover this same-layer sibling pattern. Add a Phase 13k that detects loop side stations with a single-line consumption set, sharing their row Y with a busier sibling in the same section, and shifts them by half a ``y_spacing`` further from the trunk. Section bbox grows to accommodate. The right invariant we lacked: ``_guard_no_station_overlap`` catches marker/marker collisions (Issue A) but not line/marker crossings (Issue B, the "breeze-past" pattern). Add the runtime validator ``_guard_no_line_crosses_non_consumer`` and the matching invariant test ``test_lines_dont_cross_non_consumer_markers`` that parse the routed polylines and assert no segment of a non-consumed line crosses any station's marker bbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit df746c4) * fix(layout): snap sparse loop stations to full grid, fix bypass V kink Two v114 layout regressions: 1. ``_shift_sparse_loop_stations_to_clear_bundle`` shifted single sparse loop stations like ``grea`` by ``y_spacing / 2``, parking them mid-row between two full grid slots and marking them half-grid even when their section had no 2-branch fan. Shift by a full ``y_spacing`` and stop marking them half-grid; the half-grid offset is reserved for the auto-half-grid 2-branch symfan feature. 2. Bypass V routes formed an asymmetric U at the virtual station: the P -> V and V -> T halves were spread independently in ``_spread_diagonal_bundles`` (asymmetric X clamping at the V-side bound) and used different ``min_straight`` values when the far endpoint was a port (15px vs 10px), leaving one side of the U collapsed and the other with a visible flat. Skip the spread for bypass V hops and pin the V-side ``min_straight`` to ``MIN_STRAIGHT_EDGE`` on both halves. Adds ``test_all_stations_snap_to_grid`` and ``test_bypass_v_horizontal_segment_is_flat``; both fail on the v114 baseline and pass after the fix. (cherry picked from commit 14776f9) * fix(layout): align trunk loop-column stations with off-trunk siblings v113 added _recenter_loop_side_stations to centre off-trunk fan-out stations on the loop's midpoint, but left the trunk-row station (e.g. limma in the differentialabundance differential section) at its raw layer X. This produced a visible X mismatch: limma at 629.4 vs propd/dream/DESeq2 at 648.6 in the same loop column. Add a second pass that snaps each trunk-Y station to the mean X of its "clean" off-trunk loop-column siblings (single inbound + single outbound edge, both endpoints on trunk Y). Pass 1 already places those siblings at the column's geometric midpoint, so the trunk station inherits the same X and column-mates line up. The pass only fires when the station's visible neighbours are all on trunk Y -- off-track inputs (e.g. gmt_in feeding gsea) keep their consumer anchored to the input column, not the loop midpoint. Add test_loop_column_stations_share_x: groups stations into loop columns by (rightmost trunk-pred-X, leftmost trunk-succ-X) and asserts column-mates share X within 1px. Fails on the v115 baseline (limma 600.08 vs deseq2/dream 617.58 in the fixture) and passes after the fix. (cherry picked from commit 450bd18) * fix(layout): pad section bbox below shifted sparse loop stations After Phase 13k shifts a sparse loop station (e.g. ``grea``) one ``y_spacing`` further from the trunk, the section bbox grew by only ``STATION_RADIUS_APPROX`` (5px) instead of the standard ``section_y_padding`` (50px). The bbox bottom ended up flush against the marker, leaving no visual padding below the station and crowding the row 0 -> row 1 transition for downstream sections (e.g. ``plots`` in the differentialabundance pipeline). Grow the bbox by ``section_y_padding`` after the shift, and add a new Phase 13l (``_push_lower_rows_after_bbox_grow``) that pushes sections in lower rows down so the gap to the grown bbox bottom stays at least ``section_y_gap``. Mirrors ``_tighten_lower_rows_after_shrink`` but in the opposite direction. Add invariant tests: - ``test_section_bbox_has_bottom_padding``: every section's bbox bottom sits at least ``section_y_padding`` below the lowest internal station's centre Y. - ``test_row_gap_accommodates_bypass``: every column-overlapping row r -> row r + 1 pair has a bbox-to-bbox gap of at least ``section_y_gap`` (tested at ``y_spacing=55`` to match the production render that exposed the regression). (cherry picked from commit 427fa31) * fix(layout): give bypass V a visible horizontal flat segment The bypass virtual station V sat exactly at the corner where two curve-smoothed diagonals met, so the visible flat THROUGH V was 0 pixels. The two halves of the bypass hop each had tgt_min/src_min of MIN_STRAIGHT_EDGE (=10), which equalled CURVE_RADIUS, so the corner curves on either side reached all the way to V.x and the visible horizontal at V's row vanished. Raise the V-side flat to CURVE_RADIUS + MIN_STATION_FLAT_LENGTH (=30) so that after each corner curve consumes CURVE_RADIUS pixels, a visible flat of MIN_STATION_FLAT_LENGTH pixels remains on each side of V. V now sits in the middle of a continuous ~40px horizontal segment, matching how regular fork/join stations (propd, dream, DESeq2) present a clear flat through their X. Add invariant test_bypass_v_has_horizontal_segment that fails on the zero-flat baseline (left/right_flat == 10px) and passes after the fix. Fall back to MIN_STRAIGHT_EDGE when the horizontal room is too narrow to also fit the diagonal_run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f7bf8ca) * Revert "fix(labels): stagger labels of vertically stacked same-column stations" This reverts commit d9f98c1. * style: ruff format --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(labels): stagger labels of vertically stacked same-column stations When two stations in the same section share an X column and adjacent Y tracks (e.g. Affy and Proteus in the differentialabundance metro map), their centered labels visually crowd along the same vertical line. Adds a post-placement pass that nudges labels horizontally in opposite directions, only when the shift would not introduce a new collision. [skip ci] (cherry picked from commit 265b7e4) * fix: re-align row bboxes after port-terminus spacing Phase 11 _space_ports_from_termini calls _expand_bbox_for_y when it pushes a port outside the section bbox, which can lift a single section's bbox_y above its row-mates. Phase 11b _recompute_grid_group_bboxes then sets bbox_y to min(non_port_ys) - max_y_pad and re-expands for off-grid ports, baking the shift in. Phase 9 _top_align_row_sections runs before Phase 11 so it can't undo the resulting offset. Add a Phase 11c re-run of _top_align_row_sections so bbox tops within each row stay flush after the port-terminus spacing pass. Repro: render examples/differentialabundance with port-pushed entry on the functional section. Before: functional bbox_y=40, others 85, visible 45px vertical offset. After: all sections at bbox_y=85. (cherry picked from commit 9b5d298) * fix(layout): block station overlaps when track compaction collides cells The track allocator can return identical track values for two stations on different lines when each is the sole occupant of its line at a given layer (e.g. side-by-side terminus branches like the Affy/Proteus microarray and proteomics terminals in nf-core/differentialabundance). After coordinate assignment they land on the same (x, y), causing the pills and labels to render directly on top of one another. Add a post-placement pass in _layout_single_section that buckets real stations by their primary axis (layer column for LR/RL, row for TB) and pushes any secondary-axis duplicates apart by one spacing unit. Insertion order in sub.stations is the stable tiebreaker so the earlier-defined station keeps its slot. The pass is a no-op when there is no collision, so existing layouts are unaffected. [skip ci] (cherry picked from commit 9e97491) * fix(layout): increase SECTION_Y_PADDING for bundle margin Provides visual breathing room so line bundles passing through edge-tracked stations don't touch the section bbox border. [skip ci] (cherry picked from commit 72e8379) * fix(layout): anchor inter-section exit ports to downstream bundle Y When an LR/RL exit port's bundle materialises at a single same-row Y in the downstream section (parallel fan-out where every internal station carries the same line set), pull the exit port to that Y so the inter-section run stays horizontal. Falls back to the local-internal centre when the exit has fan-in from distinct Ys, the downstream entry is a branch fan-out, multiple downstream entries land at different Ys, or the anchor falls outside the section bbox. Eliminates the drop from differential.annotate (centred) to functional.gsea (top fan-out station) on the differentialabundance map. (cherry picked from commit edb06e4) * fix(layout): anchor fan-in exit to downstream when bundle is multi-line Phase 10d preserved centred midpoints for any 3+ source fan-in exit, which kept variantbenchmarking's filter merge correct but left the differentialabundance functional exit centred between gprofiler2 and decoupler even though all four sources carry the same four-line bundle into shinyngs and quarto. The inter-section run then curved up to the reporting entry Y instead of staying horizontal. Relax the 3+ fan-in skip when the exit carries a multi-line bundle (>=2 lines, so every source is parallel-redundant) and one source Y already matches the downstream entry: snap to that Y for a straight inter-section run. Single-line fan-ins still keep their midpoint, so the variantbenchmarking TestFanInExitPreservation assertions hold. (cherry picked from commit 7ce8a2d) * fix(layout): anchor trunk junctions on bundle line for straight trunks Trunk stations - those receiving a pre-existing bundle from at least one predecessor plus side branches - previously snapped to the predecessor centroid, bending the bundle within the section. Detect this case in both _place_single_node (for fan-in junctions) and _place_fan_out (when one fan-out node carries a strict superset of its siblings' lines) and anchor the trunk node on its primary line's base track so the bundle stays straight through the junction. (cherry picked from commit bfde7b0) * fix(layout): symmetric fan-out of trunk siblings, strictly scoped Resurrects the PR #264 idea (symmetric distribution of side stations around the section trunk Y when ``--center-ports`` is on) but scopes it tightly so it ONLY moves stations that are unambiguously fan-out side branches of a trunk junction. A column qualifies for redistribution only when it contains exactly one station whose line set equals the section's full LEFT/RIGHT bundle (the trunk junction) AND at least one sibling station whose line set is a strict subset of the bundle. Those siblings are redistributed in alternating ``+1, -1, +2, -2, ...`` slots at ``y_spacing`` pitch around the trunk Y; the trunk itself stays pinned. Everything else is left in place: file inputs, linear processing chains, fan-in structures (many sources converging on one station), columns without a unique trunk, and any sibling that carries the full bundle (linear pass-through, not a fan-out branch). This fixes the original PR #264 regression where data-import-style sections (fan-in to a Validate node, then linear to Filter matrix) had their input labels and chain stations mixed at random Ys. In nf-core/differentialabundance: - Data import: untouched (no qualifying column, only file inputs and processing chains). - Differential: limma is the trunk; DESeq2/dream/propd carry only ``{rnaseq}`` (strict subset) and now fan symmetrically above and below limma's Y. - Functional: gprofiler2/decoupler/grea carry the full bundle and are intentionally not redistributed; file inputs (Gene sets, Network) are in a different column from GSEA and stay put. - Reporting: no qualifying trunk-junction column; untouched. Default (``--no-center-ports``) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 6415807) * feat(layout): add %%metro off_track: directive to lift file inputs above line tracks File-input stations declared in the flow graph (e.g. samples_in[Samples] + %%metro file: samples_in | CSV) normally consume a line-track Y slot because each station owns a track. This makes input file icons share Y with the study-type line lanes, defeating the visual goal of an "inputs band" above the section's pipeline tracks. Add a new %%metro off_track: id1, id2, ... directive that marks stations to be lifted into a separate band above the section's topmost line track in a final post-layout phase. Section bboxes expand upward to fit the band, and the whole graph is shifted down if needed so the new top stays inside the canvas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit dca94e9) * fix(layout): align trunk Ys across same-row sections Previously sections in the same grid row could have their inter-section trunk bundles enter and exit at different Y values when the upstream section had more above-trunk content than the downstream section. The visible result was lines bending vertically between section bboxes even though the bbox tops were aligned. Add a new Phase 11ca _align_row_trunk_ys. For each contiguous column group of LR/RL sections within a grid row, identify each section's trunk station (the full-bundle station connected to an LR port) and shift content downward in sections whose trunks sit above the row's deepest trunk. Bbox tops are preserved (heights grow downward). Row-spanning sections (rowspan > 1) are skipped to avoid disturbing cross-row vertical relationships. After the shift, ports on shifted sections are re-snapped to the target trunk Y when they have a single internal station at that Y; fan-in convergence ports (2+ internal sources at distinct Ys) are preserved at their centered midpoint so Phase 10d's fan-in geometry isn't disturbed. Update the grid-alignment regression tests: first_station_y consistency and symmetric bbox padding are stricter than top alignment and are not satisfied when sections shift to align trunks. Replace with bbox-top alignment and a non-negative bottom-padding floor. (cherry picked from commit 43e387c) * fix(layout): align bbox tops after off-track lift The %%metro off_track directive lifts file inputs above the line tracks and expands the section's bbox upward. This left other sections in the same row with their bbox tops misaligned because the row top-align phase ran before off-track lifting. Add Phase 13a (_top_align_row_bboxes_only) after off-track lifting to grow row-mate bboxes upward to the row's topmost bbox. Unlike earlier top-align phases, this only adjusts bbox_y/bbox_h - station Ys stay in place so the lifted section's content is preserved and the rest of the row gets empty space at the top of its bboxes. (cherry picked from commit 2d7a663) * fix(layout): compact row sections so content sits at bbox top Phase 11ca aligns trunk Ys across row-mate sections and Phase 13a expands shorter row-mates' bbox tops to match a section whose bbox grew upward in Phase 13 to accommodate off-track inputs. Without a follow-up, the row-mates without off-track ended up with empty vertical space at the top of their bboxes. Add Phase 13b _compact_row_content_to_bbox_top. For each grid row's contiguous column group: 1. Compute the allowable upward shift per section based on: - distance from on-track content to bbox top (minus padding), and - clearance to any lifted off-track band (so on-track content doesn't collide with the lift band that Phase 13 placed ``y_spacing`` above the on-track top). Apply the minimum allowable shift across the group to every on-track station and port; off-track stations stay anchored to the lift band. Uniform shift preserves trunk alignment. 2. Shrink each section's bbox_h so the bottom slack matches section_y_padding (clamped to keep bottom ports inside the bbox). Sections with grid_row_span > 1 are excluded because their content spans multiple rows and the per-row compaction frame doesn't apply. (cherry picked from commit 2831431) * feat(icons): optional caption name on file/files/dir directives Extend `%%metro file:`, `%%metro files:` and `%%metro dir:` with an optional third field for a human-readable caption rendered directly below the icon. This avoids the previous workaround of attaching a separate naming station that rendered as a process circle. Syntax: `%%metro file: station_id | TYPE | NAME` The caption uses the theme label color/font and is included in the icon's obstacle bbox so neighbouring labels keep their distance. (cherry picked from commit c435357) * fix(layout): compact rowspan sections, skip off-track termini, pad captioned bands Three coupled fixes around off-track stations and rowspan sections: * `_compact_row_content_to_bbox_top` now compacts ``grid_row_span > 1`` sections instead of skipping them. They form their own single-section groups since rowspan sections trunk at a Y of their own and don't share a horizontal inter-section bundle with row mates. * `_space_ports_from_termini` skips off-track stations when collecting termini. They get lifted above the topmost line track later, so they no longer share a Y with the inter-section bundle; treating them as termini was pushing ports above the on-track row (and dragging the upstream port via junction propagation), causing a visible kink in the inter-section bundle. * When a captioned off-track icon sits at the bottom of the lifted band, `_compact_row_content_to_bbox_top` widens its per-section clear-shift gap by the caption extent so the optional caption text (PR #269) doesn't end up overlapping the topmost line track after compaction. (cherry picked from commit 088edd2) * fix(layout): caption scaling, inter-section port snap, top-fill fan Polishes the explicit-grid layout pipeline so trunk lines flow cleanly across section boundaries and large bbox top slack stops looking empty. * Caption text under file icons now renders at 60% of the label font size and clamps its X within the section bbox so long captions (e.g. ``MaxQuant``, ``GEO ID``) stop spilling onto the section border. Compaction reserves the smaller caption extent in the off-track gap calculation. * New Phase 13c snaps LR/RL exit port Ys to the downstream entry Y for same-row connections so a row-spanning section's trunk no longer kinks at the section boundary; junctions are re-positioned afterwards. Gated on user-supplied ``%%metro grid:`` directives to preserve auto-layout line-offset ordering. * New Phase 13d redistributes trunk-candidate siblings symmetrically around the topmost trunk station when a section retains visible empty top space (e.g. Reporting fed from Functional with off-track inputs), filling the bbox top instead of leaving it blank. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit cc28ae5) * fix(layout): skip upward fan on U-turn and dodge label-vs-diagonal Two polish fixes to PR #272's top-fill fan and the label placer: * Phase 13d (`_fan_free_content_upward`) now skips lifting a trunk candidate whose incoming bundle has multiple external feeders all at or below the row's trunk Y. Lifting in that case forces the bundle to climb against the trunk (a "U-turn") and visibly bends past sibling routes that stay at trunk Y. Reporting section in nf-core/differentialabundance: Shiny app now sits on the trunk and Quarto report stays below it. * New `_avoid_diagonal_routes` pass after label placement flips a label to the opposite side of its station when its bbox overlaps a non-horizontal route segment (and the flipped position is collision free). Trunk horizontals are deliberately ignored. Fixes the GSEA label overlapping the off-track icon descent in section 3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 30158a0) * fix(layout): snap station and port Ys to row-wide grid Final layout phase snaps every non-port station and every LEFT/RIGHT port to a clean grid pitch. Earlier phases (trunk-Y align, downstream port-snap, fold-exit alignment) compute shifts that don't respect the grid, leaving coordinates at fractional Ys (e.g. 298.785 when the pitch is 55). Group sections by row using ``_row_y_grid_info``, derive ONE shared grid origin per row from the mode of (y % pitch) across all non-port stations in the row, and snap every station and LR/RL port in the row to that origin. Sections outside any row group snap to the input ``y_spacing`` with their own origin. A per-section origin (as in the prior pass) drifted between sections in the same row when one section was on residue 0 and the next on residue 23.9, kinking trunks at section boundaries. Sharing the origin across the row keeps trunks co-linear. - bounds each snap by half a pitch so adjacency cannot flip - skips off-track stations and TOP/BOTTOM ports whose Y is fixed (cherry picked from commit b9a10ab) * fix(layout): symmetric fan for terminal full-bundle columns Terminal sections (no exit ports) sometimes hold a column of stations that all carry the full bundle and all receive from the same upstream branches, with no obvious trunk station to pin. The existing fan-out logic requires a unique trunk + strict-subset siblings, so it leaves these columns stacked tightly. Add a second pass that runs only when --center-ports is on and the section has no exit ports: when a column's stations are all full-bundle (>=2), redistribute them symmetrically around a trunk Y derived from other full-bundle stations in the section (or the LR port Y). Even counts leave the trunk row empty; odd counts keep one station on the trunk. Non-terminal sections, sub-bundle siblings, file inputs and fan-in chains are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit b2ca836) * fix(layout): symfan all full-bundle columns and pin off-track to consumer (cherry picked from commit 4a5d184) * fix(layout): pin source stations to per-line track, not trunk fan `_redistribute_fanout_siblings` (PR #266) rearranges fan-out branches symmetrically around the trunk junction of a column when `--center-ports` is on. Its scope was meant to exclude file-input columns, but in nf-core/differentialabundance the data-import section has `meta_in` carrying the full bundle and the other source inputs (matrix_in, cel_in, mq_in, geo_in, gtf_in) each carrying a strict subset. Those siblings were redistributed around `meta_in` even though they aren't a real fan-out, pushing every file icon off its line's track and forcing each consumer line to traverse the entire section to reach its input. Tighten the scope so a sibling only qualifies when it shares at least one predecessor with the trunk station. Real fan-outs (differential's limma + DESeq2/dream/propd, all fed by matrix_filter) still redistribute; columns of source stations stay on their per-line track Y so the file icon for each line sits adjacent to its consumer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit a76a21e) * fix(layout): preserve trunk-Y alignment for rowspan sections during compact `_compact_row_content_to_bbox_top` (PR #278) isolated every ``grid_row_span > 1`` section into its own group on the assumption that rowspan sections "trunk at a Y of their own". That assumption breaks for sections like nf-core/differentialabundance's data_prep, which rowspans down over a row-1 plots block but whose trunk Y still sits on the row-0 inter-section bundle. In isolation, data_prep computed an 82.5px upward shift to remove its (now legitimately large) top padding, pulling validator and matrix_filter above the row's trunk Y and breaking the horizontal bundle from data_prep into differential. Downstream ``_snap_all_y_to_grid`` then flipped the row's grid mode onto the shifted residue, snapping differential/reporting fan-out columns onto the same Y slot (dream/limma, deseq2/propd, bundle/report_html all collided). Keep the rowspan exclusion only when the rowspan section's trunk Y differs from its column-adjacent neighbour's. When trunks align, compact groups them together so the shared upward shift is bounded by the smallest tolerance in the group, preserving the inter-section bundle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 85d2114) * fix(layout): lift source-stack inputs above the trunk to fill top space `_fan_free_content_upward` only fans full-bundle trunk candidates upward, so a section whose entry column has a single full-bundle station plus subset-bundle source inputs (file icons with no inbound edges, e.g. nf-core/differentialabundance's data-import section) leaves its bbox top empty: every source sits at or below the trunk. Add a companion phase `_fan_source_inputs_upward` that detects this shape and lifts the nearest-to-trunk sources into the empty top band. Each lifted source drags its linear consumer chain (single inbound edge, identical line set, section-internal) so per-line tracks stay straight from icon to junction. Scoped to explicit `%%metro grid:` pipelines. Bounded by `min(slots, n_sources // 2)` so a majority of sources stay below the trunk; capped by `(trunk_y - bbox_y - y_spacing / 4) // y_spacing` so the topmost lifted station stays inside the bbox with a margin. U-turn risk is nil because sources have no upstream feeders. Differentialabundance section 1 now shows GTF + Matrix above the trunk and Affy CEL + MaxQuant + GEO below, filling the 132.5px of previously-empty bbox top space. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f02d705) * fix(layout): three v93 layout polish issues 1. Compact below-trunk sources after source-input lift When ``_fan_source_inputs_upward`` lifts the top N sources above the trunk (e.g. GTF, Matrix in differentialabundance section 1), the remaining below-trunk sources stayed at their original Ys, leaving a multi-slot empty band between the trunk row and the first below-trunk source. Pack the remaining sources upward at ``trunk_y + i*y_spacing`` so the gap collapses. 2. Re-fan full-bundle columns around the row's final trunk Y ``_redistribute_full_bundle_columns`` runs early using local LR port Ys as the symmetric centre. For terminal sections whose sole LR port Y differs from the row's eventual trunk Y (e.g. Reporting's entry port at the bundle-column Y rather than the row trunk Y), the fan ends up offset from the trunk row. Add Phase 13g ``_recenter_full_bundle_columns`` to re-distribute each multi-station full-bundle column around the entry/exit port station Y, which by that point sits on the row's inter-section bundle Y. 3. Stagger captions when adjacent file icons would overlap Stations carrying multiple ``%%metro file:`` directives place icons at ``terminus_width + ICON_INTER_GAP`` (32 px) intervals. Captions like ``Samples`` and ``Contrasts`` measure ~50 px wide and overlap when both sit at the same Y. Detect the overlap from the estimated caption widths and drop every other caption to a second row beneath the first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 9322198) * fix(layout): v94 polish - terminus Y, caption fit, rowspan trim - Pin a single-station downstream terminus to its sole in-section upstream's Y after full-bundle column re-centering, so file icons like the Reporting ``Bundle`` ZIP line up with their source station. - Replace the "drop alternate captions to row 2" overlap workaround with a horizontal spread: ``_render_terminus_icons`` now widens the per-icon centre-to-centre step when adjacent captions would collide, keeping all captions on one row. ``_terminus_icon_clearance`` expands the section bbox by the same amount so the wider icon row still fits. ``_build_section_subgraph`` now copies ``terminus_names`` / ``terminus_icon_types`` so the layout-side clearance check sees the captions. - Re-shrink section bboxes to their content bottom after late lifts (``_fan_source_inputs_upward``, ``_recenter_full_bundle_columns``): rowspan>1 sections like ``data_prep`` no longer keep the inflated height once content has been pulled up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f2dfda8) * fix(layout): exclude leftward bbox overhang from column width Sections whose ``bbox_x`` is pushed further left than the standard ``-SECTION_X_PADDING`` (e.g. by ``_adjust_terminus_icon_clearance`` expanding the bbox to fit file icons on source stations) had that extra overhang counted into the column width. Adjacent columns were placed at ``offset_x + col_widths[col] + section_x_gap``, so the leftward overhang inflated every gap downstream of the expanded column. On nf-core/differentialabundance this widened the gap between section 1 and section 2 from 50px to ~103px while 2-3 and 3-4 stayed at the expected 50px. Use a small ``_effective_width`` helper that re-anchors the bbox to the standard left edge (``bbox_x + bbox_w + SECTION_X_PADDING``) when measuring per-column widths and when sizing spanning sections. The leftward overhang is left in place; Phase 3b of compute_layout already bumps the global ``x_offset`` to keep that content on canvas. Standard sections (``bbox_x == -SECTION_X_PADDING``) are unaffected because the formula collapses to ``bbox_w``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 7282271) * fix(layout): tighten lower-row gap left by rowspan claims After _shrink_bboxes_to_content_bottom, recompute the slack between each lower row and the sections that actually end above it, and shift those rows up to close it. _compute_section_offsets sizes row_heights from pre-shrink bbox heights, which leaves visible empty space when the only section filling a row is a rowspan that has since shrunk to its content. Sections that extend into the lower row are excluded from the bottom measurement so the gap is governed by row-r-1 content, not by rowspan claims. (cherry picked from commit e2284af) * fix(layout): include mixed-bundle column-mates in section symfan When a column in a grid section has mostly full-bundle stations but one or two single-line side branches (e.g. Functional enrichment's gprofiler2/GSEA/decoupler carrying all study types plus a single ``rnaseq`` method like grea), the strict gate on ``_redistribute_full_bundle_columns`` skipped the column and left the minor branch stranded on its per-line track Y. The resulting layout broke the section's symmetric fan and pushed the line through the section at an uneven slope. Three narrow changes: 1. ``_redistribute_full_bundle_columns`` and its late-pass companion ``_recenter_full_bundle_columns`` now also fire on columns where at least one station is full-bundle and the rest are non-source strict-subset siblings (i.e. real fan-out branches with a predecessor, not file inputs). This mixed-mode fan only fires when the section already has another all-full-bundle column, so ``_redistribute_fanout_siblings`` keeps its sole responsibility for "unique trunk + siblings" patterns (e.g. limma + dream/DESeq2 /propd in the differential section). 2. ``_redistribute_fanout_siblings`` now ignores off-track column- mates (matching the off-track skip the full-bundle pass already has) and relaxes its sibling gate from "shares a predecessor with the trunk" to "has at least one predecessor". A subset sibling fed by a separate upstream method is still a real fan-out branch and should slot into the symmetric fan; only source files (in-degree 0) keep their per-line track Y. 3. Off-track inputs are re-anchored to their consumer twice: once after grid-snap (already present), and again after the late-pass ``_recenter_full_bundle_columns`` shifts the consumer onto the row-wide trunk Y. Without the second pass the input icon stayed at the pre-recenter Y, visibly overlapping the consumer station. All 736 existing tests still pass; v97-style "all full bundle" columns are byte-identical. v98's Functional enrichment section now renders as a clean 2x2 symmetric fan with the off-track Gene sets / Network icons visibly above the trunk line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 763f292) * fix(layout): prefer trunk feeder Y at exit ports + invariant tests When an LR/RL section's exit port collects both a full-bundle trunk station and a minor single-line side-branch feeder (e.g. propd -> grea in differentialabundance), the prior code averaged feeder Ys per line. A lone below-trunk feeder pulled rnaseq's representative Y downward, reordering the bundle at the port; horizontal reconciliation then shifted the trunk station's offsets to match, producing a visible 9px kink at the section boundary (the "limma kink"). Detect the trunk feeder (a feeder whose lines cover the port's line set) and use its Y as the per-line representative. Side branches no longer rotate the bundle; the kink stays at the side branch where it belongs. Adds tests/test_layout_invariants.py with four cross-section checks that fail on the pre-fix layout for the differentialabundance graph: - test_row_trunk_marker_cy_consistent: trunk markers share cy within each grid row - test_symfan_pairs_share_y: column-mate fan pairs are mirrored around the trunk - test_off_track_inputs_above_consumer: off-track inputs sit above their consumer - test_no_kink_at_section_boundary: adjacent exit / entry ports render at the same cy Fixtures: copies of the da_pipeline and rnaseq_sections graphs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 9d93c1f) * fix(layout): side-branch late diagonal + bbox grows with re-anchor [skip ci] Two related routing/layout fixes for the differentialabundance metro map: 1. Side-branch single-line exits no longer climb to the trunk bundle immediately after the source station. When a non-port internal station sits clearly off its section's trunk Y and its only outgoing line targets either the section's exit port or another internal station on the trunk, the diagonal placement is biased toward the target (overriding the standard fork bias). The line then stays on its own track for most of the section, only climbing to trunk Y just before exiting. Concretely fixes propd's rnaseq exit edge in section 2: pre-fix the line climbed to trunk Y at x=654 (25px past the source); now it stays at y=320 until x=860 (230px) and climbs only in the last 55px before the section's right edge. 2. ``_reanchor_off_track_to_consumer`` grows the section bbox upward when the final re-anchored off-track input position would put the icon outside the section's padding zone, and shifts the whole graph down when that growth pushes any bbox above the canvas top margin. The row top-align phase is re-run after the second re-anchor so row mates keep flush bbox tops. Concretely fixes section 3 (functional) where gmt_in's terminus icon extended 5.5px above the bbox top in v102. New invariant tests: - test_side_branch_edge_stays_off_trunk: side-branch exit edges must keep the line at the source Y for at least 30% of the source->target horizontal run. - test_section_bbox_contains_all_content: every section bbox must enclose its on-track stations and off-track icons (accounting for marker / terminus half-height). Both fail on the pre-fix code and pass with the fixes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 2f26d11) * fix(layout): auto-balance sections and converge multi-source termini Three v104 improvements with invariant regression tests: 1. _balance_section_content_around_trunk: a new layout phase that redistributes fan-out siblings to fill empty bands above the trunk. Lifts the bottommost (homogeneous-line) or topmost (heterogeneous- line) below-trunk movable into a new top slot when the bbox has room for the marker plus its above-marker label; otherwise swaps the bottommost below-trunk movable with the topmost above-trunk station to reorder the band without growing the bbox. Uses a line-aware feeder walk so single-feeder lifts aren't blocked by unrelated stations sharing a junction port. 2. _insert_terminus_convergence_stations: parser pass that inserts a hidden convergence station before any terminus with 2+ inbound sources so the routing engine allocates a column for the merge. Diagonals from the sources meet at the converge station and the final segment to the terminus marker is orthogonal. 3. Section 1 (data_prep) and Section 2 (differential) of the differentialabundance pipeline now fill their above-trunk bands instead of stacking content below the trunk. New invariant tests in test_layout_invariants.py: - test_section_top_band_filled - test_section1_input_above_trunk - test_terminus_not_directly_after_diagonal All three fail on the da-render-integration baseline and pass after the fix; the 741 existing tests continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 881fdb4) * fix(layout): keep balance-pass lift inside section bbox The v103 test_section_bbox_contains_all_content invariant requires that station markers (9.5 px half-height) and off-track file icons (16 px half-height) stay inside their section bbox. v104's `_balance_section_content_around_trunk` clamped `new_y` only against label clearance, so empty-label file inputs (e.g. `cel_in[ ]`) could land flush with `bbox_y` and overflow by half a marker. Bump the floor to `max(label_clearance, marker_half)` where `marker_half` is 16 px for off-track icons and 9.5 px for on-track markers, matching v103's invariant geometry. (cherry picked from commit 3ced84b) * feat(layout): auto half-grid placement for 2-branch symmetric fans When a section contains exactly two on-track branch stations sharing a column (no off-track inputs, no extra on-track content), place them at trunk_y +/- 0.5 * y_spacing instead of trunk_y +/- 1 * y_spacing. The section then needs only one vertical grid unit for the fan instead of two, removing wasted vertical space in topologies like the differentialabundance Plots section. Trunk Y stays on the integer grid; only the two branch stations sit at half-pitch. They are tracked in graph._half_grid_station_ids so the final snap-to-grid pass leaves them at their half-pitch offsets and ignores them when computing the row grid origin. The dedicated Phase 13d3 (_apply_half_grid_2branch_symfan) handles sections that sit alone on a row, which the per-row redistribute / recenter passes skip; the existing redistribute / recenter passes also honour the trigger for row-shared sections. Trigger is fully automatic; no directive is exposed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f6cc949) * fix(layout): bypass non-consuming stations via virtual station insert Lines that flow through a section but aren't consumed by every station-column they cross were previously routing through the non-consumer's marker bbox. The differential-abundance pipeline shows this at ``Annotate results``: ``limma`` carries rnaseq/affy/maxquant/geo, but only rnaseq+affy stop at the annotate marker -- maxquant and geo continue to the section's exit port and are pinned to the same trunk Y, crashing the annotate pill. Extend the v104 terminus-convergence pattern to handle this case: inserting a hidden virtual station ``V`` (``is_hidden=True``) in the bypassed station's section gives the routing engine a column-mate to fan to/from, so the bypass uses the same fork-out and fork-in diagonals the rest of the diagram uses for parallel branches. Trigger (in ``_insert_bypass_stations``, run after ``_resolve_sections``): 1. For each non-port, non-hidden, non-terminus station S, gather consumed_lines(S) from inbound edges. 2. For each in-section predecessor P with layer(P) < layer(S), look at outbound edges ``P -> exit_port`` carrying a line L not in consumed. 3. Require P also feeds the same exit_port with at least one line from consumed_lines(S) -- this is the signal that P and the exit port both sit on the trunk that S is on, so the non-consumed line would cross S's row. 4. Replace ``P -> exit_port (L)`` with ``P -> V (L) + V -> exit_port (L)``. Per-section layers are computed locally (intra-section subgraph, including ports) with exit ports pinned past internal stations, so longest-path quirks where two layer-equal stations sit in adjacent columns don't suppress the trigger. The hidden bypass station renders with no marker and no label (``is_hidden`` is already excluded by ``render/svg.py`` and the collision resolver), so the visual is indistinguishable from ``Annotate results``' column having a second pill above/below the trunk -- which is exactly the "parallel branch with an anonymous station" effect the design language calls for. Add ``test_non_consumed_lines_route_via_virtual_station`` over ``da_pipeline.mmd`` that: * asserts at least one ``__bypass_*`` hidden station exists; * asserts the bypass V chains ``limma -> V -> exit_port`` for both maxquant and geo; * asserts maxquant and geo rendered routes clear annotate's marker bbox (half-bbox + slack). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 384befe) * fix(layout): block balance lift onto off-track icon slot The auto-balance pass (Phase 13h2) lifted below-trunk movable siblings into the topmost empty above-trunk slot without checking whether that slot was already reserved by an off-track input icon in the same column. In the differentialabundance functional section, ``gmt_in`` (off-track, lifted to y=100) and ``grea`` (a single-line rnaseq sibling at y=265) shared column 1040.7; the lift moved ``grea`` to y=100 directly on top of ``gmt_in``'s file icon, producing a marker collision in the rendered SVG. Fix: when selecting a balance candidate, walk every below-trunk movable sibling and skip any whose column already contains a station (incl. off-track) at the prospective lift Y. Off-track icons now reserve their column slot from subsequent placement. Add a post-layout validator ``_guard_no_station_overlap`` that errors with ``Position clash: <name1> ... overlaps <name2> ...`` when any two station / file-icon marker bboxes intersect, so end users running ``nf-metro render`` against a pipeline that triggers a similar regression get a clear actionable message rather than a silently broken render. Add the matching invariant ``test_no_station_or_icon_overlap`` parametrised over ``da_pipeline.mmd`` and ``rnaseq_sections.mmd`` to lock the fix in regression coverage. (cherry picked from commit 9116778) * fix(layout): tighten bypass V hop to span only V's column The v110 bypass virtual station produced a diagonal route immediately at the predecessor station (e.g. ``limma``) instead of just before the bypass V, so the bypass line ran parallel below the trunk for most of the bypassed station's section. In the differential-analysis section the maxquant / geo lines diverged at limma's X (~629) and re-converged only at the exit port (~908), creating a long visible side-branch. When the diagonal target (or source) is a hidden ``__bypass_`` station, treat the bypass V as a join (or fork) anchor in ``_route_diagonal``. ``_compute_diagonal_placement`` then biases the diagonal toward V, so the hop diverges just before V's column (~30 px upstream) and re-converges just after V's column. The remaining trunk segment is no longer interrupted by a parallel branch. Add the matching invariant ``test_bypass_avoids_off_track_inputs`` that asserts each ``__bypass_*`` virtual station sits at least one marker clearance (~12 px) away from any off-track input icon in the same column. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 91191b3) * fix(layout): clean bypass routing, compact sections, centre fan-out Issue 1: Remove __bypass_*-specific routing in _route_diagonal. Bypass virtual stations are now indistinguishable from any other off-trunk station for routing/layout; only the SVG renderer skips their marker. Issue 2: Add below-trunk compaction in _balance_section_content_around_ trunk. When the first row below the trunk is empty but content sits deeper, lift the entire below-trunk stack up by one y_spacing slot. Honours marker-clearance and column-collision constraints. Issue 3: Add _recenter_loop_side_stations phase. Layer-based X placement biased fan-out side stations (propd, dream, DESeq2, and the bypass V itself) toward the fork side. Recompute their X as the midpoint of the two diagonal corners derived from the routing geometry so they sit centred on the loop's horizontal run. Issue 4: Shrink bbox top in _apply_half_grid_2branch_symfan after the ±27.5 placement. The bbox was sized for full ±55 spread, leaving a half-y_spacing gap above the first half-grid branch. Adds four invariant tests covering each fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 4b2265a) * fix(layout): tighten bypass V hop, shift sparse loop stations clear Issue 1: The v113 cleanup (4b2265a) removed the bypass-routing override that biased ``P -> V`` diagonals toward V (treating V as a join) and ``V -> T`` diagonals away from V (treating V as a fork). Without the override the limma -> V -> exit_port loop in the differential section diverged just past limma (at the fork bias) and ran 124 px parallel below the trunk before re-converging, instead of the short 50-px hop the bypass V was designed to produce. Restore the override; the hop now spans only V's column. Issue 2: ``grea`` in the differential-functional section sat at the same Y row as ``decoupler`` (both at the section's below-trunk row), so the non-consumed lines (affy, maxquant, geo) flowing from the section entry port to decoupler crossed grea's marker bbox at y~268 (grea bbox 260-270). The bypass V mechanism only fires for direct ``pred -> exit_port`` edges and doesn't cover this same-layer sibling pattern. Add a Phase 13k that detects loop side stations with a single-line consumption set, sharing their row Y with a busier sibling in the same section, and shifts them by half a ``y_spacing`` further from the trunk. Section bbox grows to accommodate. The right invariant we lacked: ``_guard_no_station_overlap`` catches marker/marker collisions (Issue A) but not line/marker crossings (Issue B, the "breeze-past" pattern). Add the runtime validator ``_guard_no_line_crosses_non_consumer`` and the matching invariant test ``test_lines_dont_cross_non_consumer_markers`` that parse the routed polylines and assert no segment of a non-consumed line crosses any station's marker bbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit df746c4) * fix(layout): snap sparse loop stations to full grid, fix bypass V kink Two v114 layout regressions: 1. ``_shift_sparse_loop_stations_to_clear_bundle`` shifted single sparse loop stations like ``grea`` by ``y_spacing / 2``, parking them mid-row between two full grid slots and marking them half-grid even when their section had no 2-branch fan. Shift by a full ``y_spacing`` and stop marking them half-grid; the half-grid offset is reserved for the auto-half-grid 2-branch symfan feature. 2. Bypass V routes formed an asymmetric U at the virtual station: the P -> V and V -> T halves were spread independently in ``_spread_diagonal_bundles`` (asymmetric X clamping at the V-side bound) and used different ``min_straight`` values when the far endpoint was a port (15px vs 10px), leaving one side of the U collapsed and the other with a visible flat. Skip the spread for bypass V hops and pin the V-side ``min_straight`` to ``MIN_STRAIGHT_EDGE`` on both halves. Adds ``test_all_stations_snap_to_grid`` and ``test_bypass_v_horizontal_segment_is_flat``; both fail on the v114 baseline and pass after the fix. (cherry picked from commit 14776f9) * fix(layout): align trunk loop-column stations with off-trunk siblings v113 added _recenter_loop_side_stations to centre off-trunk fan-out stations on the loop's midpoint, but left the trunk-row station (e.g. limma in the differentialabundance differential section) at its raw layer X. This produced a visible X mismatch: limma at 629.4 vs propd/dream/DESeq2 at 648.6 in the same loop column. Add a second pass that snaps each trunk-Y station to the mean X of its "clean" off-trunk loop-column siblings (single inbound + single outbound edge, both endpoints on trunk Y). Pass 1 already places those siblings at the column's geometric midpoint, so the trunk station inherits the same X and column-mates line up. The pass only fires when the station's visible neighbours are all on trunk Y -- off-track inputs (e.g. gmt_in feeding gsea) keep their consumer anchored to the input column, not the loop midpoint. Add test_loop_column_stations_share_x: groups stations into loop columns by (rightmost trunk-pred-X, leftmost trunk-succ-X) and asserts column-mates share X within 1px. Fails on the v115 baseline (limma 600.08 vs deseq2/dream 617.58 in the fixture) and passes after the fix. (cherry picked from commit 450bd18) * fix(layout): pad section bbox below shifted sparse loop stations After Phase 13k shifts a sparse loop station (e.g. ``grea``) one ``y_spacing`` further from the trunk, the section bbox grew by only ``STATION_RADIUS_APPROX`` (5px) instead of the standard ``section_y_padding`` (50px). The bbox bottom ended up flush against the marker, leaving no visual padding below the station and crowding the row 0 -> row 1 transition for downstream sections (e.g. ``plots`` in the differentialabundance pipeline). Grow the bbox by ``section_y_padding`` after the shift, and add a new Phase 13l (``_push_lower_rows_after_bbox_grow``) that pushes sections in lower rows down so the gap to the grown bbox bottom stays at least ``section_y_gap``. Mirrors ``_tighten_lower_rows_after_shrink`` but in the opposite direction. Add invariant tests: - ``test_section_bbox_has_bottom_padding``: every section's bbox bottom sits at least ``section_y_padding`` below the lowest internal station's centre Y. - ``test_row_gap_accommodates_bypass``: every column-overlapping row r -> row r + 1 pair has a bbox-to-bbox gap of at least ``section_y_gap`` (tested at ``y_spacing=55`` to match the production render that exposed the regression). (cherry picked from commit 427fa31) * fix(layout): give bypass V a visible horizontal flat segment The bypass virtual station V sat exactly at the corner where two curve-smoothed diagonals met, so the visible flat THROUGH V was 0 pixels. The two halves of the bypass hop each had tgt_min/src_min of MIN_STRAIGHT_EDGE (=10), which equalled CURVE_RADIUS, so the corner curves on either side reached all the way to V.x and the visible horizontal at V's row vanished. Raise the V-side flat to CURVE_RADIUS + MIN_STATION_FLAT_LENGTH (=30) so that after each corner curve consumes CURVE_RADIUS pixels, a visible flat of MIN_STATION_FLAT_LENGTH pixels remains on each side of V. V now sits in the middle of a continuous ~40px horizontal segment, matching how regular fork/join stations (propd, dream, DESeq2) present a clear flat through their X. Add invariant test_bypass_v_has_horizontal_segment that fails on the zero-flat baseline (left/right_flat == 10px) and passes after the fix. Fall back to MIN_STRAIGHT_EDGE when the horizontal room is too narrow to also fit the diagonal_run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f7bf8ca) * fix(render): group edge paths by metro line for consistent z-order Previously _render_edges sorted routes by the source-point Y so the lowest line in a bundle was drawn first. That interleaved paths from different lines: a given line could end up above one neighbor at one bundle and below the same neighbor at another, producing a visually inconsistent z-order across the diagram (most visible on the rnaseq line in the differentialabundance map - on top on the way down to grea, under it on the way back). Render routes grouped by metro line, in reverse-of-definition order, so each line's paths form a single contiguous block in the SVG. The first-defined line is painted last and therefore appears on top everywhere it overlaps any other line. Each edge path/line is now tagged class="metro-line-<id>" so the property is testable. Add a TestLineZOrderConsistent invariant covering rnaseq_sections plus every topology fixture, asserting each metro line's paths are contiguous in document order. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 85946fd) * Revert "fix(labels): stagger labels of vertically stacked same-column stations" This reverts commit d9f98c1. * refactor: tighten z-order sort and contiguity test Stable sort by single key replaces the indexed-route two-key form. Contiguity check uses a one-pass scan over the line-id sequence instead of building first/last index maps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a graph-level directive so mmd files can opt into vertical centering of inter-section ports without requiring the --center-ports CLI flag. The CLI flag now defaults to None and overrides the directive when set (--center-ports forces on, --no-center-ports forces off, omitting the flag falls back to whatever the directive sets, or False).
* feat(gallery): add differentialabundance pipeline metro map Adds the nf-core/differentialabundance metro map (133-line .mmd with four input lines, off-track gene-set/network inputs, and a bypass-heavy reporting row) to examples/ and registers it as a main gallery entry. The render is now exercised by build_gallery.py and build_render_diff.py in CI, so layout regressions that affect this map will be caught automatically. * feat(gallery): enable %%metro center_ports: true on differentialabundance map Centres inter-section ports on the shorter of the two connected sections so lines enter/exit at the visual midpoint, matching the rendering used for the differentialabundance pipeline docs. Requires the %%metro center_ports: directive added in the parser PR. [skip ci] * feat(gallery): add uncentered differentialabundance variant
* iter(gallery): move differentialabundance legend (placeholder for render iteration) * fix(layout): order off-track icons by consumer Y Off-track input icons in a section were placed in mmd-declaration order via dict iteration of by_consumer, then bumped upward only to clear trunk tracks and siblings. When a later-declared icon needed an upward bump past an earlier-placed sibling, the two ended up inverted relative to their consumers (e.g. Network's icon above Gene sets' icon even though decoupler sits below GSEA on the trunk in non-center_ports layouts). Iterate consumers in descending Y order so the bottommost consumer's icon claims the closest slot first; upper-consumer icons then stack above it via the existing bump mechanism, yielding a top-to-bottom icon order that matches the consumer Y order. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(layout): pad stacked file-icon gap for under-icon labels The default station pitch (y_spacing 40px) is smaller than a captioned file icon's vertical extent (icon height 32 + caption gap 4 + caption font ~8 = 44px), so stacked file-input stations in DA section 1 (Samples/Contrasts, Matrix, GTF, CEL, MaxQuant, GEO ID) had their under-icon captions visually crashing into the next icon below. Add a late-stage layout phase that finds vertically-adjacent file-input stations in the same column and pads the gap to 2*icon_half + caption_gap + caption_font_height + clearance. Linear consumer chains follow the shift so per-line tracks stay horizontal from icon to trunk junction. Section bbox grows downward as needed and lower-row sections are pushed to keep clearance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(layout): clear station labels from column-adjacent icons Generalises Phase 13m's stacked-file-icon clearance to all label-bearing elements in a section column. Phase 13n calls place_labels with current Ys, computes the rendered top/bot extent of each station (marker plus above/below label text) and file icon (icon plus under-icon caption), then walks vertically-adjacent pairs and shifts the lower element down by any clearance deficit. Iterates until label-side decisions stabilise. Catches two regressions on the DA gallery: * gprofiler2's below-marker name label crashed into the Network TSV icon directly beneath it in section 3. * Zip bundle's above-marker name label crashed into the Shiny HTML icon's caption directly above it in section 5. The new invariant ``test_no_label_overlaps_adjacent_element_in_column`` replaces the previous icon-icon-only Phase 13m test, asserting extent-based clearance for every vertically-adjacent pair in every LR/RL section column (icon-icon, icon-station, station-icon, and station-station). Also tightens ``_section_trunk_marker_cy`` to fall back to the port-derived trunk cy when the closest full-bundle station is no longer on the trunk -- otherwise a legitimate post-clearance shift would spuriously fail the row-trunk-consistency invariant. * Revert "fix(layout): clear station labels from column-adjacent icons" This reverts commit 0d89dae. * feat(layout): auto-derive y_spacing from graph content compute_min_y_spacing scans every LR/RL section and returns the minimum centre-to-centre pitch needed so captioned file icons and labelled stations in the same column don't collide, clamped to MIN_Y_SPACING_FLOOR (30 px). compute_layout now derives y_spacing from this when the caller passes y_spacing=None, and the CLI --y-spacing default is None so any render path (CLI, gallery, pipeline doc builds, third-party tooling) picks an adaptive grid without bespoke code. Explicit numeric values still take precedence. DA examples now render at ~58 px pitch (no caption/label overlaps); content-light maps stay at the floor. * fix(layout): raise auto-spacing floor to match previous default y_spacing --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…325) Incorporates the rigor patterns developed during the differentialabundance fix chain: diagnostic-before-fix, invariant-test-first (parametrised over multiple fixtures), runtime validators, /simplify as a separate commit, whole-repo ruff, additive-only history with origin verification, render-preview verdict gating, narrowing over-applying fixes, narrative in PR description (not comments), drive end-to-end, and post-merge retarget-before-delete ordering. Cross-references pr-chain-vet for chain-shepherding (out of scope here).
* fix(layout): align X for topologically equivalent siblings Stations that share predecessors, successors, and line set are topological siblings and should align in X. ``_center_bubble_stations`` shifts each one to the midpoint of its own diagonals, which produces different X values when the surrounding geometry is asymmetric (e.g. one sibling sits on the entry port's Y so its incoming run is flat while the other has a diagonal entry consuming X). This was visible on gatk_haplotypecaller vs deepvariant in the variant_calling family. Add a post-bubble pass that groups stations by (section, preds, succs, line_ids) and snaps each multi-member class to the average X, updating route endpoints to match. Scoped to LR/RL sections because TB sections use X as the track axis -- siblings there must stay on separate columns. Closes #318 * style: ruff format routing/core.py
…329) The benchmarking sections in variantbenchmarking[_auto] had two single-line side branches (gatk4_conc carrying concordance and intersection_tool carrying intersection) placed by the fan-out track allocator at the bottom of the column. Because each line had no other stop in the section, the rendered route diagonalled down from the trunk Y to the off-trunk station and immediately diagonalled back up to the exit port - a long visual detour with no payload. Add a layout phase that snaps such stations onto the section trunk Y when: * the station carries exactly one line and is the sole stop of that line in the section, * its single predecessor and successor both sit at the trunk Y, and * no busier multi-line sibling is already on the trunk row (which would route bundles past the snapped station's marker, the grea case in da_pipeline). When multiple branches snap into the same row, the marker is staggered by a half ``x_spacing`` step toward the predecessor to avoid collisions with same-row siblings. The pass is gated on the section also having at least one non-candidate off-trunk sibling so pure fan-out diagrams (mismatched_tracks's 5 parallel branches) are left alone. Closes #317
* fix(layout): anchor fork-group distribution on trunk member `_equalize_fork_groups` previously distributed cross-line fork siblings starting at `tracks[group[0]]` (the topmost station after the primary-line-priority sort) and stepping down by `line_gap`. In columns where no member carries the section's full bundle, this pushes low-priority orphan-line stations to the bottom of the column. In variantbenchmarking's benchmarking section, the 9-station fan-out column has 4 distinct primary lines and no full-bundle anchor. The concordance-line orphan `gatk4_conc` ended up at track 7 of 9, ~275px below the trunk Y, producing the visible V-shaped detour reported in #317. Replace the topmost-anchor walk with a signed-offset distribution around a chosen anchor. Anchor preference: 1. The column member carrying the most lines (the in-column trunk). 2. Tie-break: the member closest to the average track of the group's predecessors (the trunk feeding the fork). 3. Final tie-break: the lowest current track, matching prior behaviour for symmetric cases. The anchor's track is preserved and siblings fan out by signed deltas based on their position in the sort. For fully symmetric columns (epitopeprediction, rnaseq_sections, fold_double) the result is identical to the old behaviour; for the asymmetric VB case the trunk station ends up centred and orphan-line members land within one track of the trunk. Adds `tests/test_fork_anchor_invariants.py` asserting fan-out columns with a common predecessor have Y center-of-mass within ~1 track of the predecessor's Y. Covers variantbenchmarking, variantbenchmarking_auto, differentialabundance, epitopeprediction, rnaseq_sections, and da_pipeline. Fixes #317 * refactor(layout): condense fork anchor block Trim the narrative comment in `_equalize_fork_groups` to a single block summarising the anchor key, drop the intermediate `preds_union` set in favour of a flat generator over (group, predecessors), and rename `pred_anchor` to `pred_mean` (it's a mean, not an anchor point). Render-neutral: gallery SVGs identical before and after. * fix(layout): preserve top-anchored layout for source-column fork groups When a fork-group has no predecessors (source stations forming the leftmost column), the trunk-anchor logic incorrectly recentred the column away from track 0, dragging hidden hubs and inter-section trunks down with it. Section 1 of variantbenchmarking was rendering with all input lines converging at the column centre then jogging up to the exit port at the top. Fall back to the original consecutive-from-group[0] distribution when there are no predecessors to anchor against. Refs: #317 * Revert "fix(layout): preserve top-anchored layout for source-column fork groups" This reverts commit f68761a. * fix(layout): preserve top-anchored layout for source-column fork groups When a fork-group has no predecessors (source stations forming the leftmost column), the trunk-anchor logic at ordering.py:599-607 falls back to anchoring on whichever station has the lowest current track, then redistributes the rest of the group around it with signed offsets. For variantbenchmarking's input column this dragged the hidden ``_inputs_hub`` from track 0 (matching ``samplesheet`` at the section top) to track 1.7 (the column centre), and the exit port followed. The downstream snap-and-reposition pass eventually recovered the port Y but the visible bundle still terminated at the section's vertical centre. Fall back to the original consecutive-from-group[0] distribution when there are no predecessors to anchor against. Refs: #317 * fix(layout): always reposition junctions after row compaction ``_position_junctions`` was conditionally re-run after ``_snap_inter_section_port_pairs`` only when the snap pass actually moved a port. ``_compact_row_content_to_bbox_top`` runs between the two and can shift exit-port Ys on its own; when the snap pass then finds nothing to move it returns ``False`` and the junctions stay pinned to their pre-compaction Ys, leaving them at intermediate values between the row grid lines. In variantbenchmarking with the source-column fallback restored, this produced junctions at y=120 / y=160 (between 103.2 and 143.2) and caused inter-section truth and test lines to take small diagonal detours instead of running horizontally on the row trunk. Run the snap pass and the junction reposition unconditionally; the second ``_position_junctions`` call is idempotent when junctions are already in the correct positions. Refs: #317 * refactor(layout): share anchor-driven distribution loop in fork groups Unify the source-column and predecessor-driven branches of ``_equalize_fork_groups`` so both compute ``anchor_idx`` and ``anchor_track``, then share the final signed-offset distribution loop. Source columns fall through to ``anchor_idx = 0`` (group[0]) instead of duplicating the enumerate loop. No behaviour change. Refs: #317
…335) * fix(layout): bypass non-consumer station markers in guide topologies Lines flowing ``P -> exit_port`` whose target station S sits in the layer path (``layer(P) < layer(S) < layer(exit)``) and which S neither consumes nor produces were routing through S's marker bbox in five guide-family fixtures: * examples/guide/05_file_icons.mmd, 05c_files_icon.mmd, 05d_folder_icon.mmd: ``qc`` from trim to multiqc crossed ``alignment``. * examples/guide/06a_without_hidden.mmd, 06b_with_hidden.mmd: ``prot`` from search to multiqc crossed ``quantification``. ``_insert_bypass_stations`` already supported the bypass mechanism for the da_pipeline ``annotate`` case but its trigger required P to send a line consumed by S to the exit port (the ``shared_with_consumed`` guard). That signal exists for annotate (limma sends both rnaseq/affy to annotate and to exit) but not for the guide topologies, where trim sends ``main`` to align (in-section) and ``qc`` to exit (cross-section) and the intersection is empty. Replace the predecessor-of-S iteration with a broader scan over all in-section sources P with a P->exit edge whose layer sits before S and whose target's layer sits after S. S now uses its full ``station_lines`` set (consumed | produced) rather than just consumed to filter bypassing lines, so lines a station emits don't falsely trigger a bypass of that station. The new test file ``tests/test_bypass_invariants.py`` parametrizes the non-consumer-crossing invariant over the five guide fixtures (complement of the da_pipeline / rnaseq_sections parametrization in ``tests/test_layout_invariants.py``); each fixture failed against the previous trigger and passes after this change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(parser): use graph.station_lines for bypass trigger * Drop the local ``consumed_by`` index and per-section ``station_lines`` dict in ``_insert_bypass_stations`` in favour of the cached ``graph.station_lines(sid)`` helper. * Drop the in-loop comment that paraphrases the docstring. No behaviour change; same 819 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(parser): narrow bypass trigger by section topology and trunk consumption Splits sections into single-trunk vs multi-trunk regimes and gates the bypass V insertion on the regime + the type of line S consumes: - single-trunk (05/06 guide family, da_pipeline differential): any lower-layer in-section P with a `P -> exit` edge carrying a non-station line can trigger the bypass. - multi-trunk (rnaseq_auto's genome_align, epitopeprediction's input_processing): only fan-in convergence points (S with >=2 in-section preds) trigger, and only from a direct predecessor of S. - single-trunk fixtures whose only consumed line at S is a local spur (with_subworkflows's samtools_index) are skipped - their bypass V snaps S to the trunk Y and opens an unnecessary vertical gap. Adds `test_no_bypass_inserted_for_quiet_fixtures` parametrised over rnaseq_auto and epitopeprediction to guard the over-trigger side. The existing `test_guide_lines_dont_cross_non_consumer_markers` still covers the 5 guide bypass-V improvements. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(parser): index in-section edges once per bypass-scan section Per-station lookups (in_section_preds, consumed_lines) were rebuilding the same in-section edge slice; index them once into dicts up front. Also folds the candidate_preds branch into a single ternary and drops the inline regime preamble (the function docstring already covers the two-regime trigger logic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(layout): exclude bypass V helpers from section bbox / trunk anchoring The bypass V stations inserted by ``_insert_bypass_stations`` are pure routing aids - they have no rendered marker and exist only so the routing engine can fan a non-consumer line around a consumer marker. They were previously contributing to: * Per-section bbox computation (Y range), inflating section height by one track when the V landed off-trunk. * ``_max_stations_per_layer`` row Y-grid, scaling the shared row pitch up despite no visible second-track content. * ``_section_trunk_y`` row trunk anchor, pulling neighbouring sections down to align with the V's off-trunk Y. * ``_find_connected_internal_coord`` port Y anchoring, pulling exit ports onto the V's Y and dragging downstream entry ports with them. * ``_compute_fork_join_gaps`` fork/join detection, manufacturing a fork gap at a V's predecessor when no visible peer needed it. Together these caused MultiQC and other downstream content in the 05 guide fixtures (file_icons, files_icon, folder_icon) to shift right and down compared to the pre-bypass-V baseline. This commit filters ``__bypass_``-prefixed stations from each of those layout passes while preserving the visible fork signal (a real diagonal between visible peers still triggers fork-gap insertion). [skip ci] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(layout): trace through bypass V when collecting LR exit-gap feeders After the bbox/trunk fix the 05/05c/05d guides still landed MultiQC at ``(446, 134)`` instead of base ``(427, 134)`` because ``_adjust_lr_exit_gap`` saw the bypass V's off-trunk Y as a "second feeder" and added a 36px exit gap that base didn't have. This commit teaches ``_adjust_lr_exit_gap`` to trace each bypass V feeder back to its visible predecessor and use that station's Y for the feeder-Y set. The exit diagonal still terminates at the predecessor's Y (the V is just a routing-only column), so collapsing the V back onto its predecessor faithfully matches the pre-bypass geometry. Net effect: 05/05c/05d MultiQC now byte-identical to base, and 06a/06b keep the legitimate exit-gap they had in base (align/quant/search genuinely converge from three different Ys). [skip ci] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(layout): scope fork/join owner-track inclusion to bypass-V peers Previous commit (0a355d5) folded the owner's own track into the fork/join visible track set unconditionally, which manufactured a spurious join gap at non-bypass fan-in points where every source sat on a single track different from the owner (e.g. simple_pipeline / 01_minimal fastqc layer 2: input+trim on the main track join into fastqc on the qc track). Scope the owner-inclusion to the case it was added for: a fork or join that has a bypass-V helper among its peers. Non-bypass fan-outs and fan-ins use the original peer-set track count and stay byte-identical to base. [skip ci] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style: ruff format * fix(layout): include bypass V in section bbox with reduced padding Refs: #335 * fix(layout): bias bypass-V routing to centre V on its flat segment Refs: #335 * fix(layout): centre bypass V on its loop's straight segment Allow bypass V helpers through the bubble-centering pass that positions visible stations: skip the is_hidden filter, and skip the convergence-bundle guard because V has no marker to conflict with column alignment. V now centres on its diversion loop the same way a normal station does on its loop. Refs: #335 * chore: remove debug prints --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…336) When a section's exit port is a fan-in (multiple internal sources at distinct Ys converging on the centred midpoint), _snap_inter_section_port_pairs previously skipped the snap entirely to preserve the centring. This left the downstream entry port out of alignment if the snap target Y differed, producing a visible row-trunk kink across the section boundary - e.g. filtering->stats in variantbenchmarking, where filtering's exit at y=143 (mean of filter_contigs/bcftools_filter/ survivor_filter) and stats's entry at y=103 differed by a grid unit. Reverse the direction for fan-in cases: keep the exit port at the centred midpoint, and snap the downstream entry port to match. The intra-section diagonal at the entry is a smaller visual cost than a row-trunk kink. Applies to both explicit-grid and auto-layout pipelines because moving the entry port doesn't disturb the auto-layout's fan-in convergence. Refs: #317 follow-up
) * test(invariants): parametrize over full fixture corpus and add 6 new invariants Drops the hardcoded ["da_pipeline.mmd", "rnaseq_sections.mmd"] lists in tests/test_layout_invariants.py and parametrizes each invariant over the full set of layout-testable fixtures discovered under tests/fixtures and examples (47 fixtures total). Per-invariant precondition filters narrow the run (off-track invariants run on fixtures declaring %%metro off_track:; bypass invariants run on fixtures whose layout produces __bypass_ virtual stations). Adds three new invariants from the closed #310: - test_no_icon_overlaps_line_path - test_fanout_branches_at_distinct_y - test_bypass_clearance_from_lower_section Adds four audit-recommended invariants: - test_section_entry_hub_on_grid (audit item 12) - test_inter_section_route_y_stays_within_row_band (audit items 6 + 18 / issue #317) - test_topological_siblings_share_y_or_symmetric (audit item 15 / issue #318) - test_grid_snap_does_not_mutate_x (audit item 11) Pre-existing layout regressions surfaced by the parametrization expansion are marked xfail(strict=False) with descriptive reasons so CI stays green while bugs are tracked in code; when a fix lands the entry becomes XPASS and the marker can be removed. Closes #310 (its 3 fix cherry-picks are on main; its 4 new tests are subsumed here). Addresses issue #323 (fragile single-fixture parametrization). Test count: 814 -> 1509 (1433 passed + 76 xfailed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(invariants): make xfail strict and surface in CI logs Flip the `_fp` helper to strict=True so any silently-resolved xfail reds CI instead of passing unnoticed. Add xfail_strict=true to pyproject so hand-written xfails outside the helper default to strict too. Add -rxX to pytest addopts so CI logs prominently list every xfail/xpass instead of folding them into the dot summary. Lock-in for the comprehensive-invariants suite: future work cannot hide test failures behind strict=False. Refs: #326 * test(invariants): surface all xfailed regressions as live CI failures Empty the six xfail registries (_XFAIL_ROW_TRUNK_CY, _XFAIL_NO_KINK, _XFAIL_SYMFAN, _XFAIL_BREEZE_PAST, _XFAIL_BBOX_BOTTOM_PAD, _XFAIL_SIBLINGS) so every previously-xfailed (fixture, invariant) cell runs as a normal test. Cases that genuinely fail now appear as RED on the PR CI checks instead of being silently masked as XFAIL = green. Each subsequent fix commit (test-logic or engine) reduces the FAIL count until #326 can merge green. Refs: #326 * fix(tests): skip bundle-changed kink pairs and non-divergent symfan pairs Refs: #326 * fix(tests): cluster row trunks by bundle + Y-band, not grid_row alone Refs: #326 * fix(tests): accept column-aligned siblings in topological-siblings check Refs: #326 * style: shorten _is_symfan_pair docstring to satisfy E501 * fix(layout): keep section_y_padding clearance below bottom-most station Refs: #326 * fix(layout): insert bypass station for non-consumed lines through trunk Topological trigger in ``_insert_bypass_stations`` is widened to cover the line/marker crossing patterns surfaced by ``test_lines_dont_cross_non_consumer_markers`` on 5 guide fixtures: * ``shared_via_inbound`` -- the direct predecessor consumes at least one of S's lines, so the non-consumed line on P's exit edge sits on the same inbound trunk. * ``sibling_trunk`` -- a non-predecessor X at ``layer(S) - 1`` feeds an exit port S also feeds, so X and S share a trunk past S's column. Both triggers can over-fire because parse-time topology can't predict which rows the layout engine will assign. Add a post-layout pruning step (``_prune_useless_bypass_stations``) that removes any bypass V whose Y matches both X's and T's Y -- the bypass is geometrically a no-op there but disrupts kink-free port alignment, section bbox bottom padding, and the v111 symmetric-U invariant. When pruning fires, ``_compute_section_layout`` is re-run to clean up positions. Two follow-on tweaks keep the v110/v111 invariants symmetric for the new cases: * ``_section_trunk_y`` excludes hidden bypass stations when picking the LR trunk anchor, so a single-row analysis section with a bypass V below the trunk doesn't shift downstream sections to V's Y in ``_align_row_trunk_ys``. * ``_center_bubble_stations`` skips the flat-equalising shift when an adjacent diagonal targets / leaves a bypass V; v110 pins the V-side flat to ``CURVE_RADIUS + MIN_STATION_FLAT_LENGTH`` so the U at V stays symmetric, and the bubble-centre shift undoes that. Refs: #326, supersedes #296 approach * Revert "Merge remote-tracking branch 'origin/main' into feat/comprehensive-invariants" This reverts commit ce8200b, reversing changes made to 9645a4d. * Revert "Merge remote-tracking branch 'origin/main' into feat/comprehensive-invariants" This reverts commit 7228832, reversing changes made to 3b36717. * fix(layout): keep section_y_padding clearance below bottom-most station Refs: #326 * test(invariants): drop two dip-specific bypass-V tests test_bypass_virtual_station_uses_standard_routing and test_bypass_v_horizontal_segment_is_flat both presuppose the bypass V dips below trunk (asserting a 4-point diagonal route shape and a symmetric U-bottom respectively). In fixtures where the bypassed stations sit below trunk, the bypass line stays flat at trunk and neither assertion has a geometric subject. Both invariants are subsumed by: - test_lines_dont_cross_non_consumer_markers (breeze-through) - test_bypass_v_has_horizontal_segment (V on visible flat run) Refs: #326 * style: ruff format * fix(layout): tighten section bbox below bypass V Bypass V helpers (auto-inserted, hidden, no marker) were treating their Y as if it were a labelled station for bottom-padding purposes, giving 50px of bbox below the V's diversion curve in guide fixtures. V only needs CURVE_RADIUS + MIN_STATION_FLAT_LENGTH/2 (= 20px) of clearance for the corner curve to render past the section edge. Excludes __bypass_ stations from the labelled-station padding computation in both _layout_single_section (initial sizing) and _shrink_bboxes_to_content_bottom (post-compact recalculation), treating them with curve-only clearance instead. Saves ~30px below each guide-fixture bypass section without breaking the V-line's curve clearance. Refs: #326 --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) * test: add layout-quality validators for visual defect detection Four new validator predicates in tests/layout_validator.py, modelled on check_station_as_elbow: - check_intra_section_chain_alignment: same-line same-section consecutive stations should share a track when both endpoints are interior single- line stations with no fan-in/fan-out / multi-line / pre-terminus context. Catches the variant_calling BWA Index zigzag where a chain of single-line stations alternates rows for no structural reason. - check_exit_port_feeder_alignment: section exit ports should align with at least one of their internal feeders. Multi-feeder fan-ins inherently misalign all-but-one feeder, so the check fires only when the port matches none of them. - check_single_segment_diagonals: routed edges that render as a single straight diagonal segment (rather than an L-shape or S-curve with axis-aligned endpoint segments) indicate routing that should step between tracks via corners. Catches the funcprofiler_upstream Preprocess->MERGE_RUNS and QC->Output reporting transitions. - check_inter_section_line_crossings: pairs of lines sharing source and target sections should preserve top-to-bottom order between exit and entry; swapped order means their connecting curves cross. All four emit WARNING severity. Parametrized tests in test_topology_validation.py gate CI on no errors (always green) and report per-fixture warning counts so candidate defects are visible in test output. A focused TestFuncprofilerUpstreamDefects class asserts the QC->Output single-segment diagonal is detected on the funcprofiler_upstream fixture as a positive control. The funcprofiler_upstream.mmd fixture is added to tests/fixtures/topologies/ as the canonical bad render for iterating on validator coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style: ruff format Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: lock in variant_calling defects + refine route-crossing heuristic Adds TestVariantCallingDefects with 3 failing tests for known layout defects (BWA chain misalignment, HC/DV column gap, section 1->2 curve crossing). PR #252 stays red until the layout engine is fixed. Refines check_route_segment_crossings: - Excludes crossings near a shared port endpoint when both crossing segments transition to a new track diagonally (natural fan-out, e.g. hlatyping bam/fastq diverging into section 2) - Still fires when one segment is near-vertical, catching the variant_calling case where qc plunges past main going the wrong direction - Excludes crossings involving hidden helper stations (terminus geometry, e.g. rnaseq_sections orange-line termination) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(validation): xfail known defects in funcprofiler_upstream and variant_calling The validators in this PR correctly detect three classes of layout defects: almost-horizontal junction edges (funcprofiler_upstream humann3), intra-section chain misalignment, excessive column gaps between siblings, and avoidable route segment crossings (variant_calling gatk/deepvariant family). Mark each defect with strict xfail referencing the tracking issue (#318 for variant_calling) so CI is green while a future engine fix that resolves any defect immediately reds CI (XPASS strict) to prompt marker removal. * test(validation): drop defect-documenting tests; keep the validators The earlier xfail-marked TestVariantCallingDefects and TestFuncprofiler- UpstreamDefects classes document known defects rather than asserting invariants on green renders, which violates the no-skips/no-xfails philosophy now enforced by #326's strict marker. Remove those classes and the funcprofiler_upstream fixture they depended on. The validators they exercised (check_excessive_column_gaps, check_route_segment_crossings, check_intra_section_chain_alignment, check_single_segment_diagonals) remain in tests/layout_validator.py and are exercised by the parametrised TestTopologyValidation class across the topology corpus. Variant-calling-specific defects are already tracked in #318. Refs: #318 * Revert "test(validation): drop defect-documenting tests; keep the validators" This reverts commit 7e9480b. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two .mmd fixtures for the nf-core/funcprofiler pipeline: - examples/funcprofiler_upstream.mmd - the upstream pipeline as it actually renders, intended as a canonical bad-case stress test - examples/funcprofiler.mmd - a simplified variant Both fixtures currently exhibit layout defects under the current engine: - funcprofiler.mmd: inter-section trunk-Y kink (profiling -> reporting, 3px delta), qc line crosses non-consumer 'merge' marker - funcprofiler_upstream.mmd: humann3 junction routing nearly horizontal, tracked by validators in #252 Held back from the main docs gallery (#239) until the underlying engine issues are addressed; this PR lands the fixtures so the validators work and engine fixes can iterate against them.
Contributor
|
Render preview is ready for review: This preview shows only the renders that changed compared to |
Updated nf-core/rnaseq fixture matching the upstream pipeline more closely: adds ribodetector, fastqc_filtered, featurecounts, sylph stations and a bowtie2_salmon line. Currently exhibits two engine-side issues that need follow-up: 1. test_auto_y_spacing_fits_content fails - hisat2_align and bowtie2_align stack at gap=10px in genome_align section (need 40px for caption clearance). 2. Visual z-order divergence between fan-out (left of sortmerna/ribodetector/bbsplit) and fan-in (right) - the ribodetector loop is on top in the descent but the sortmerna loop sits on top in the ascent. Held back from the gallery PR #239 until these are resolved; same engine-needs-work bucket as the funcprofiler fixtures in this PR.
pinin4fjords
added a commit
that referenced
this pull request
May 16, 2026
These need engine-side follow-up work: - funcprofiler.mmd: profiling->reporting trunk-Y kink, qc-crosses-merge - funcprofiler_upstream.mmd: humann3 junction nearly horizontal - rnaseq_auto.mmd (expanded): hisat2/bowtie2 stacking under y_spacing threshold, ascend-vs-descend z-order mismatch around bbsplit/sortmerna/ribodetector fan Moved to PR #337. Restored examples/rnaseq_auto.mmd to main's version so this PR (docs gallery + hlatyping logo path + gallery script infrastructure) lands without an engine block.
pinin4fjords
added a commit
that referenced
this pull request
May 16, 2026
pinin4fjords
added a commit
that referenced
this pull request
May 16, 2026
* docs: add nf-core Pipelines page with renders for all adopted pipelines New dedicated docs page showcasing real-world nf-core/sanger-tol pipelines using nf-metro: rnaseq, epitopeprediction, hlatyping, variantprioritization, variantbenchmarking, genomeassembly, and funcprofiler. Removes pipeline-specific entries (genomeassembly, variantprioritization) from the layout gallery, keeping it focused on layout patterns. Adds funcprofiler.mmd from nf-core/funcprofiler dev branch. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use explicit .md paths in cross-page links to silence mkdocs warnings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: sync rnaseq and hlatyping examples with upstream repos Update rnaseq_auto.mmd from nf-core/rnaseq master (adds bowtie2_salmon line, ribodetector, tximport/SummarizedExperiment, file icons, explicit port directives, and section directions). Update hlatyping.mmd from nf-core/hlatyping dev (different hidden station routing with merge junction pattern, updated logo path). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add funcprofiler upstream and simplified variants, debug renders Add funcprofiler_upstream.mmd (original 11-line topology from nf-core) and simplified funcprofiler.mmd (3-line version). Both rendered on the pipelines page. Pipeline renders use debug mode for ongoing review. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Split out funcprofiler + rnaseq_auto fixture changes These need engine-side follow-up work: - funcprofiler.mmd: profiling->reporting trunk-Y kink, qc-crosses-merge - funcprofiler_upstream.mmd: humann3 junction nearly horizontal - rnaseq_auto.mmd (expanded): hisat2/bowtie2 stacking under y_spacing threshold, ascend-vs-descend z-order mismatch around bbsplit/sortmerna/ribodetector fan Moved to PR #337. Restored examples/rnaseq_auto.mmd to main's version so this PR (docs gallery + hlatyping logo path + gallery script infrastructure) lands without an engine block. * Regenerate docs/pipelines/index.md from build_gallery.py The PR initially committed a hand-edited version of the file; CI's render-diff job runs the gallery script, which rewrites the file from its PIPELINE_ENTRIES list, and the resulting diff blocked the base branch checkout step. Committing the script's output verbatim so the file matches what CI regenerates and the workflow's base-branch checkout can proceed. * chore: stop committing build_gallery.py-generated docs/pipelines/index.md The file is regenerated from PIPELINE_ENTRIES by scripts/build_gallery.py on every invocation, including the docs build in CI. Committing it kept us tied to a stale snapshot that diverged from the script's output and broke the render-diff workflow's base-branch checkout step. Add to .gitignore alongside docs/gallery/index.md (which has always been generated and never committed). CI and the mkdocs site builder will regenerate the file from the script on demand. * Remove funcprofiler fixtures (resurrected by accident; they live in #337) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pinin4fjords
added a commit
that referenced
this pull request
Jul 27, 2026
* docs: add nf-core Pipelines page with renders for all adopted pipelines New dedicated docs page showcasing real-world nf-core/sanger-tol pipelines using nf-metro: rnaseq, epitopeprediction, hlatyping, variantprioritization, variantbenchmarking, genomeassembly, and funcprofiler. Removes pipeline-specific entries (genomeassembly, variantprioritization) from the layout gallery, keeping it focused on layout patterns. Adds funcprofiler.mmd from nf-core/funcprofiler dev branch. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use explicit .md paths in cross-page links to silence mkdocs warnings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: sync rnaseq and hlatyping examples with upstream repos Update rnaseq_auto.mmd from nf-core/rnaseq master (adds bowtie2_salmon line, ribodetector, tximport/SummarizedExperiment, file icons, explicit port directives, and section directions). Update hlatyping.mmd from nf-core/hlatyping dev (different hidden station routing with merge junction pattern, updated logo path). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add funcprofiler upstream and simplified variants, debug renders Add funcprofiler_upstream.mmd (original 11-line topology from nf-core) and simplified funcprofiler.mmd (3-line version). Both rendered on the pipelines page. Pipeline renders use debug mode for ongoing review. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Split out funcprofiler + rnaseq_auto fixture changes These need engine-side follow-up work: - funcprofiler.mmd: profiling->reporting trunk-Y kink, qc-crosses-merge - funcprofiler_upstream.mmd: humann3 junction nearly horizontal - rnaseq_auto.mmd (expanded): hisat2/bowtie2 stacking under y_spacing threshold, ascend-vs-descend z-order mismatch around bbsplit/sortmerna/ribodetector fan Moved to PR #337. Restored examples/rnaseq_auto.mmd to main's version so this PR (docs gallery + hlatyping logo path + gallery script infrastructure) lands without an engine block. * Regenerate docs/pipelines/index.md from build_gallery.py The PR initially committed a hand-edited version of the file; CI's render-diff job runs the gallery script, which rewrites the file from its PIPELINE_ENTRIES list, and the resulting diff blocked the base branch checkout step. Committing the script's output verbatim so the file matches what CI regenerates and the workflow's base-branch checkout can proceed. * chore: stop committing build_gallery.py-generated docs/pipelines/index.md The file is regenerated from PIPELINE_ENTRIES by scripts/build_gallery.py on every invocation, including the docs build in CI. Committing it kept us tied to a stale snapshot that diverged from the script's output and broke the render-diff workflow's base-branch checkout step. Add to .gitignore alongside docs/gallery/index.md (which has always been generated and never committed). CI and the mkdocs site builder will regenerate the file from the script on demand. * Remove funcprofiler fixtures (resurrected by accident; they live in #337) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds two .mmd fixtures for the nf-core/funcprofiler pipeline so the validators in #252 (and follow-up engine work) have a target.
These fixtures currently fail layout invariants from #326 and validators from #252. Held back from the docs gallery (#239) until the engine work to lay them out cleanly lands.
DO NOT MERGE
This PR is intentionally draft. Land only after the underlying engine fixes resolve the documented defects.