Skip to content

fixtures: add funcprofiler and funcprofiler_upstream examples - #337

Draft
pinin4fjords wants to merge 552 commits into
mainfrom
feat/funcprofiler-fixtures
Draft

fixtures: add funcprofiler and funcprofiler_upstream examples#337
pinin4fjords wants to merge 552 commits into
mainfrom
feat/funcprofiler-fixtures

Conversation

@pinin4fjords

Copy link
Copy Markdown
Member

Adds two .mmd fixtures for the nf-core/funcprofiler pipeline so the validators in #252 (and follow-up engine work) have a target.

Fixture Status Known issues
`examples/funcprofiler.mmd` (simplified) Has layout defects profiling->reporting trunk Y mismatch (~3px); qc line crosses non-consumer `merge` marker
`examples/funcprofiler_upstream.mmd` (real upstream) Has layout defects humann3 junction routing nearly horizontal

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.

pinin4fjords and others added 30 commits February 24, 2026 15:11
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>
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>
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>
pinin4fjords and others added 20 commits May 16, 2026 09:04
…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.
@github-actions

github-actions Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

Render preview is ready for review:
🖼️ https://pinin4fjords.github.io/nf-metro/_pr/337/

This preview shows only the renders that changed compared to main.

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
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant