From d532dc40dd35419af3bd4c3b5d4a5585b3af8e9b Mon Sep 17 00:00:00 2001 From: Jonathan Manning Date: Sun, 15 Feb 2026 20:08:13 +0000 Subject: [PATCH 0001/1246] feat: Add animated balls traveling along metro lines Add --animate CLI flag that injects SVG elements so white circles travel along each metro line's path. At diamond/bubble patterns (e.g., FastP/TrimGalore), balls travel both branches by finding all distinct root-to-sink paths per line via DFS. Co-Authored-By: Claude Opus 4.6 --- src/nf_metro/cli.py | 5 +- src/nf_metro/render/animate.py | 299 +++++++++++++++++++++++++++++++++ src/nf_metro/render/style.py | 5 + src/nf_metro/render/svg.py | 6 + src/nf_metro/themes/light.py | 1 + 5 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 src/nf_metro/render/animate.py diff --git a/src/nf_metro/cli.py b/src/nf_metro/cli.py index 4337088e1..cf242bfc2 100644 --- a/src/nf_metro/cli.py +++ b/src/nf_metro/cli.py @@ -33,6 +33,8 @@ def cli() -> None: help="Vertical spacing between tracks (default: 40)") @click.option("--max-layers-per-row", type=int, default=None, help="Max layers before folding to next row (default: auto)") +@click.option("--animate/--no-animate", default=False, + help="Add animated balls traveling along lines") def render( input_file: Path, output: Path | None, @@ -42,6 +44,7 @@ def render( x_spacing: float, y_spacing: float, max_layers_per_row: int | None, + animate: bool, ) -> None: """Render a Mermaid metro map definition to SVG.""" text = input_file.read_text() @@ -51,7 +54,7 @@ def render( max_layers_per_row=max_layers_per_row) theme_obj = THEMES[theme] - svg = render_svg(graph, theme_obj, width=width, height=height) + svg = render_svg(graph, theme_obj, width=width, height=height, animate=animate) if output is None: output = input_file.with_suffix(".svg") diff --git a/src/nf_metro/render/animate.py b/src/nf_metro/render/animate.py new file mode 100644 index 000000000..4bb2c0f9e --- /dev/null +++ b/src/nf_metro/render/animate.py @@ -0,0 +1,299 @@ +"""Animation support: animated balls traveling along metro lines.""" + +from __future__ import annotations + +import math +import re + +import drawsvg as draw + +from nf_metro.layout.routing import RoutedPath +from nf_metro.parser.model import MetroGraph +from nf_metro.render.style import Theme + + +def render_animation( + d: draw.Drawing, + graph: MetroGraph, + routes: list[RoutedPath], + station_offsets: dict[tuple[str, str], float], + theme: Theme, + curve_radius: float = 10.0, +) -> None: + """Add animated balls traveling along each metro line. + + For each metro line, builds a continuous SVG path from its chained + edges, then injects invisible elements and elements + with to create the traveling ball effect. + """ + line_paths = _build_line_motion_paths( + graph, routes, station_offsets, theme, curve_radius, + ) + + for idx, (line_id, d_attr) in enumerate(line_paths): + path_id = f"motion-path-{line_id}-{idx}" + + # Invisible path for animateMotion to follow + d.append(draw.Raw( + f'' + )) + + # Compute duration from approximate path length + path_length = _compute_path_length(d_attr) + dur = max(path_length / theme.animation_speed, 2.0) + + n_balls = theme.animation_balls_per_line + for i in range(n_balls): + begin_offset = -i * dur / n_balls + d.append(draw.Raw( + f'' + f'' + f'' + f'' + f'' + )) + + +def _build_line_motion_paths( + graph: MetroGraph, + routes: list[RoutedPath], + station_offsets: dict[tuple[str, str], float], + theme: Theme, + curve_radius: float = 10.0, +) -> list[tuple[str, str]]: + """Build continuous SVG motion paths for each metro line. + + At diamond/bubble patterns (fork-join), produces separate paths for + each branch so balls travel both alternatives (e.g., FastP and + TrimGalore). Returns list of (line_id, d_attr) pairs -- a line_id + may appear multiple times when it has forking branches. + """ + # Index routes by (source, target, line_id) for lookup + route_by_edge: dict[tuple[str, str, str], RoutedPath] = {} + for route in routes: + key = (route.edge.source, route.edge.target, route.line_id) + route_by_edge[key] = route + + # Group edges by line + edges_by_line: dict[str, list] = {} + for edge in graph.edges: + edges_by_line.setdefault(edge.line_id, []).append(edge) + + result: list[tuple[str, str]] = [] + + for line_id, edges in edges_by_line.items(): + if line_id not in graph.lines: + continue + + # Build adjacency: source -> list of (target, edge) + adj: dict[str, list] = {} + incoming: set[str] = set() + for edge in edges: + adj.setdefault(edge.source, []).append((edge.target, edge)) + incoming.add(edge.target) + + # Find root nodes (no incoming edges for this line) + all_sources = set(adj.keys()) + roots = all_sources - incoming + if not roots: + continue + + # Find all distinct root-to-sink paths (covers both branches + # of diamonds/bubbles) + all_paths: list[list] = [] + for root in sorted(roots): + _find_all_paths(root, adj, [], all_paths) + + if not all_paths: + continue + + for path_edges in all_paths: + all_points = _chain_edge_points( + path_edges, route_by_edge, station_offsets, + ) + if len(all_points) < 2: + continue + + d_attr = _points_to_svg_path(all_points, curve_radius) + if d_attr: + result.append((line_id, d_attr)) + + return result + + +def _find_all_paths( + current: str, + adj: dict[str, list], + path_so_far: list, + results: list[list], +) -> None: + """DFS to find all root-to-sink paths through the adjacency map.""" + if current not in adj: + # Sink node: save the accumulated path + if path_so_far: + results.append(list(path_so_far)) + return + + for target, edge in adj[current]: + path_so_far.append(edge) + _find_all_paths(target, adj, path_so_far, results) + path_so_far.pop() + + +def _chain_edge_points( + edges: list, + route_by_edge: dict[tuple[str, str, str], RoutedPath], + station_offsets: dict[tuple[str, str], float], +) -> list[tuple[float, float]]: + """Chain edge routes into one continuous list of waypoints.""" + all_points: list[tuple[float, float]] = [] + + for edge in edges: + route = route_by_edge.get( + (edge.source, edge.target, edge.line_id), + ) + if not route: + continue + + pts = _apply_offsets(route, station_offsets) + + if not all_points: + all_points.extend(pts) + elif pts: + last = all_points[-1] + first = pts[0] + if abs(last[0] - first[0]) < 1.0 and abs(last[1] - first[1]) < 1.0: + all_points.extend(pts[1:]) + else: + all_points.extend(pts) + + return all_points + + +def _apply_offsets( + route: RoutedPath, + station_offsets: dict[tuple[str, str], float], +) -> list[tuple[float, float]]: + """Apply station offsets to route points, matching _render_edges logic.""" + if route.offsets_applied: + return list(route.points) + + src_off = station_offsets.get((route.edge.source, route.line_id), 0.0) + tgt_off = station_offsets.get((route.edge.target, route.line_id), 0.0) + + orig_sy = route.points[0][1] + orig_ty = route.points[-1][1] + pts = [] + for i, (x, y) in enumerate(route.points): + if i == 0: + pts.append((x, y + src_off)) + elif i == len(route.points) - 1: + pts.append((x, y + tgt_off)) + elif abs(y - orig_sy) <= abs(y - orig_ty): + pts.append((x, y + src_off)) + else: + pts.append((x, y + tgt_off)) + return pts + + +def _points_to_svg_path( + pts: list[tuple[float, float]], + curve_radius: float = 10.0, + route_curve_radii: list[float] | None = None, +) -> str: + """Convert a list of waypoints to an SVG path 'd' attribute. + + Replicates the curve logic from _render_edges in svg.py: + straight lines with quadratic Bezier curves at direction changes. + """ + if len(pts) < 2: + return "" + + if len(pts) == 2: + return f"M {pts[0][0]:.2f} {pts[0][1]:.2f} L {pts[1][0]:.2f} {pts[1][1]:.2f}" + + parts = [f"M {pts[0][0]:.2f} {pts[0][1]:.2f}"] + + for i in range(1, len(pts) - 1): + prev = pts[i - 1] + curr = pts[i] + nxt = pts[i + 1] + + dx1 = curr[0] - prev[0] + dy1 = curr[1] - prev[1] + len1 = math.hypot(dx1, dy1) + + dx2 = nxt[0] - curr[0] + dy2 = nxt[1] - curr[1] + len2 = math.hypot(dx2, dy2) + + max_len1 = len1 / 2 if i > 1 else len1 + max_len2 = len2 / 2 if i < len(pts) - 2 else len2 + + effective_r = curve_radius + r = min(effective_r, max_len1, max_len2) + + if len1 > 0 and len2 > 0: + before_x = curr[0] - (dx1 / len1) * r + before_y = curr[1] - (dy1 / len1) * r + after_x = curr[0] + (dx2 / len2) * r + after_y = curr[1] + (dy2 / len2) * r + + parts.append( + f"L {before_x:.2f} {before_y:.2f} " + f"Q {curr[0]:.2f} {curr[1]:.2f} {after_x:.2f} {after_y:.2f}" + ) + else: + parts.append(f"L {curr[0]:.2f} {curr[1]:.2f}") + + parts.append(f"L {pts[-1][0]:.2f} {pts[-1][1]:.2f}") + + return " ".join(parts) + + +def _compute_path_length(d_attr: str) -> float: + """Approximate the length of an SVG path from its commands. + + Parses M, L, and Q commands and sums segment lengths. + For Q (quadratic Bezier), approximates with the chord length. + """ + # Extract all numbers from the path + tokens = re.findall(r'[MLQ]|[-+]?\d*\.?\d+', d_attr) + + total = 0.0 + cx, cy = 0.0, 0.0 # current position + i = 0 + + while i < len(tokens): + token = tokens[i] + if token == 'M': + cx = float(tokens[i + 1]) + cy = float(tokens[i + 2]) + i += 3 + elif token == 'L': + nx = float(tokens[i + 1]) + ny = float(tokens[i + 2]) + total += math.hypot(nx - cx, ny - cy) + cx, cy = nx, ny + i += 3 + elif token == 'Q': + # Q cx cy ex ey - approximate with control point polygon + qcx = float(tokens[i + 1]) + qcy = float(tokens[i + 2]) + ex = float(tokens[i + 3]) + ey = float(tokens[i + 4]) + # Sum of legs through control point (overestimates slightly) + leg1 = math.hypot(qcx - cx, qcy - cy) + leg2 = math.hypot(ex - qcx, ey - qcy) + chord = math.hypot(ex - cx, ey - cy) + # Average of chord and polygon for a decent approximation + total += (chord + leg1 + leg2) / 2 + cx, cy = ex, ey + i += 5 + else: + i += 1 + + return total diff --git a/src/nf_metro/render/style.py b/src/nf_metro/render/style.py index a18527cfe..a9d439431 100644 --- a/src/nf_metro/render/style.py +++ b/src/nf_metro/render/style.py @@ -28,3 +28,8 @@ class Theme: legend_background: str legend_text_color: str legend_font_size: float + # Animation settings + animation_ball_radius: float = 3.0 + animation_ball_color: str = "#ffffff" + animation_balls_per_line: int = 3 + animation_speed: float = 80.0 # pixels per second diff --git a/src/nf_metro/render/svg.py b/src/nf_metro/render/svg.py index 7a6eb16ca..d2ffd5415 100644 --- a/src/nf_metro/render/svg.py +++ b/src/nf_metro/render/svg.py @@ -19,6 +19,7 @@ def render_svg( width: int | None = None, height: int | None = None, padding: float = 60.0, + animate: bool = False, ) -> str: """Render a metro map graph to an SVG string.""" if not graph.stations: @@ -128,6 +129,11 @@ def render_svg( # Draw edges (lines) behind stations _render_edges(d, graph, routes, station_offsets, theme) + # Animation (after edges, before stations so balls travel behind station markers) + if animate: + from nf_metro.render.animate import render_animation + render_animation(d, graph, routes, station_offsets, theme) + # Draw stations (all circles, skip ports) _render_stations(d, graph, theme, station_offsets) diff --git a/src/nf_metro/themes/light.py b/src/nf_metro/themes/light.py index d894aca7b..ea1aef79f 100644 --- a/src/nf_metro/themes/light.py +++ b/src/nf_metro/themes/light.py @@ -22,4 +22,5 @@ legend_background="rgba(255, 255, 255, 0.8)", legend_text_color="#333333", legend_font_size=12.0, + animation_ball_color="#333333", ) From 6a6cb5b1d0310ea48ef5e0a669439bc268c0c53b Mon Sep 17 00:00:00 2001 From: Jonathan Manning Date: Sun, 15 Feb 2026 20:24:13 +0000 Subject: [PATCH 0002/1246] feat: Use transparent background for light theme Skip drawing background rectangle when background_color is "none", allowing SVGs to work on any page background. Updated the light theme to use a transparent background instead of solid #f5f5f5. Co-Authored-By: Claude Opus 4.6 --- src/nf_metro/render/svg.py | 5 +++-- src/nf_metro/themes/light.py | 2 +- tests/test_render.py | 4 +++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/nf_metro/render/svg.py b/src/nf_metro/render/svg.py index 7a6eb16ca..c54fc076a 100644 --- a/src/nf_metro/render/svg.py +++ b/src/nf_metro/render/svg.py @@ -101,8 +101,9 @@ def render_svg( d = draw.Drawing(svg_width, svg_height) - # Background - d.append(draw.Rectangle(0, 0, svg_width, svg_height, fill=theme.background_color)) + # Background (skip for transparent themes) + if theme.background_color and theme.background_color != "none": + d.append(draw.Rectangle(0, 0, svg_width, svg_height, fill=theme.background_color)) # Title / Logo if show_logo: diff --git a/src/nf_metro/themes/light.py b/src/nf_metro/themes/light.py index d894aca7b..6dd9214cd 100644 --- a/src/nf_metro/themes/light.py +++ b/src/nf_metro/themes/light.py @@ -4,7 +4,7 @@ LIGHT_THEME = Theme( name="light", - background_color="#f5f5f5", + background_color="none", station_fill="#ffffff", station_stroke="#333333", station_radius=6.0, diff --git a/tests/test_render.py b/tests/test_render.py index aac002c31..151d6d5c1 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -58,7 +58,9 @@ def test_render_light_theme(): ) compute_layout(graph) svg = render_svg(graph, LIGHT_THEME) - assert LIGHT_THEME.background_color in svg + # Light theme uses transparent background (no background rectangle) + assert LIGHT_THEME.background_color == "none" + assert '#333333' in svg # label/stroke color present def test_render_empty_graph(): From 28d631c162410b8b0a63c4824d02d7e4bfdcbed9 Mon Sep 17 00:00:00 2001 From: Jonathan Manning Date: Sun, 15 Feb 2026 20:58:42 +0000 Subject: [PATCH 0003/1246] fix: Sort edge draw order to prevent middle lines appearing narrower Lines in a bundle were drawn in .mmd parse order, which meant a line listed first could end up spatially sandwiched between two lines drawn later. Both neighbors would paint over its boundary pixels, making it appear visually thinner than the rest of the bundle. Sort routes by effective Y (highest first) before rendering so lines are drawn bottom-to-top. Each interior line now loses at most one boundary edge consistently. Co-Authored-By: Claude Opus 4.6 --- src/nf_metro/render/svg.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/nf_metro/render/svg.py b/src/nf_metro/render/svg.py index c5fd5a1cd..9e1b24499 100644 --- a/src/nf_metro/render/svg.py +++ b/src/nf_metro/render/svg.py @@ -237,6 +237,19 @@ def _render_edges( curve_radius: float = 10.0, ) -> None: """Render metro line edges with smooth curves at direction changes.""" + # Sort routes by effective Y of the source point (highest Y first) so + # lines are drawn bottom-to-top. This ensures each interior line in a + # bundle only loses one boundary edge to its neighbor rather than having + # a line drawn first get painted over on both sides. + def _sort_key(route: RoutedPath) -> float: + if route.offsets_applied: + return -route.points[0][1] + src_off = station_offsets.get( + (route.edge.source, route.line_id), 0.0) + return -(route.points[0][1] + src_off) + + routes = sorted(routes, key=_sort_key) + for route in routes: line = graph.lines.get(route.line_id) color = line.color if line else "#888888" From 311172afe58c032643fe456a6f28bcab9b6fd4e4 Mon Sep 17 00:00:00 2001 From: Jonathan Manning Date: Sun, 15 Feb 2026 21:03:16 +0000 Subject: [PATCH 0004/1246] docs: Use animated light-theme SVG in README Replace static dark-theme PNG with an animated light-theme SVG that shows balls traveling along the metro lines for a more engaging preview. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- examples/rnaseq_sections_light_animated.svg | 255 ++++++++++++++++++++ 2 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 examples/rnaseq_sections_light_animated.svg diff --git a/README.md b/README.md index cd9bcaa97..13fa40b86 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Generate metro-map-style SVG diagrams from Mermaid graph definitions with `%%metro` directives. Designed for visualizing bioinformatics pipeline workflows (e.g., nf-core pipelines) as transit-style maps where each analysis route is a colored "metro line." -![nf-core/rnaseq metro map](examples/rnaseq_sections.png) +![nf-core/rnaseq metro map](examples/rnaseq_sections_light_animated.svg) ## Installation diff --git a/examples/rnaseq_sections_light_animated.svg b/examples/rnaseq_sections_light_animated.svg new file mode 100644 index 000000000..8e67f1fe6 --- /dev/null +++ b/examples/rnaseq_sections_light_animated.svg @@ -0,0 +1,255 @@ + + + + + + + +1 +Pre-processing + + +2 +Genome alignment & quantification + + +3 +Post-processing + + +4 +Pseudo-alignment & quantification + + +5 +Quality control & reporting + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +cat fastq +STAR +SAMtools +RSeQC +HISAT2 +Salmon +Kallisto +FastQC +RSEM +Picard +Preseq +UMI-tools dedup +MultiQC +infer strandedness +BEDTools +Qualimap +Salmon +UMI-tools extract +bedGraphToBigWig +dupRadar +FastP +StringTie +DESeq2 PCA +Trim Galore! +FastQC +Kraken2/Bracken +BBSplit +MultiQC +SortMeRNA + + +Aligner: STAR, Quantification: RSEM + +Aligner: STAR, Quantification: Salmon (default) + +Aligner: HISAT2, Quantification: None + +Pseudo-aligner: Salmon, Quantification: Salmon + +Pseudo-aligner: Kallisto, Quantification: Kallisto + \ No newline at end of file From 8585d73a725e8d1da72651f914c79e372e308b2b Mon Sep 17 00:00:00 2001 From: Jonathan Manning Date: Sun, 15 Feb 2026 21:26:18 +0000 Subject: [PATCH 0005/1246] fix: Offset labels from pill edge, not station base Y Labels were positioned a fixed distance from station.y, but station pills extend further when multiple lines pass through (via per-line offsets). Below labels overlapped the pill on multi-line stations. Now label placement accounts for the station's offset span, measuring the gap from the actual pill edge. Also re-renders the README figure. Co-Authored-By: Claude Opus 4.6 --- examples/rnaseq_sections_light_animated.svg | 22 ++++++------ src/nf_metro/layout/labels.py | 39 +++++++++++++++++---- src/nf_metro/render/svg.py | 2 +- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/examples/rnaseq_sections_light_animated.svg b/examples/rnaseq_sections_light_animated.svg index 8e67f1fe6..3e32706e7 100644 --- a/examples/rnaseq_sections_light_animated.svg +++ b/examples/rnaseq_sections_light_animated.svg @@ -212,10 +212,10 @@ -cat fastq -STAR -SAMtools -RSeQC +cat fastq +STAR +SAMtools +RSeQC HISAT2 Salmon Kallisto @@ -225,21 +225,21 @@ Preseq UMI-tools dedup MultiQC -infer strandedness +infer strandedness BEDTools -Qualimap +Qualimap Salmon UMI-tools extract bedGraphToBigWig dupRadar -FastP +FastP StringTie -DESeq2 PCA -Trim Galore! +DESeq2 PCA +Trim Galore! FastQC Kraken2/Bracken -BBSplit -MultiQC +BBSplit +MultiQC SortMeRNA diff --git a/src/nf_metro/layout/labels.py b/src/nf_metro/layout/labels.py index 108a7e2fd..a5d94cbdc 100644 --- a/src/nf_metro/layout/labels.py +++ b/src/nf_metro/layout/labels.py @@ -67,6 +67,7 @@ def _boxes_overlap( def place_labels( graph: MetroGraph, label_offset: float = 16.0, + station_offsets: dict[tuple[str, str], float] | None = None, ) -> list[LabelPlacement]: """Place horizontal labels alternating above/below stations. @@ -83,6 +84,18 @@ def place_labels( placements: list[LabelPlacement] = [] for i, station in enumerate(sorted_stations): + # Compute the vertical extent of the station pill so labels + # are offset from the pill edge, not from station.y. + if station_offsets: + line_offs = [ + station_offsets.get((station.id, lid), 0.0) + for lid in graph.station_lines(station.id) + ] + min_off = min(line_offs) if line_offs else 0.0 + max_off = max(line_offs) if line_offs else 0.0 + else: + min_off = max_off = 0.0 + # Check if this is a TB section vertical station (layer > 0) is_tb_vert = False if station.section_id: @@ -110,20 +123,28 @@ def place_labels( # Alternate by layer (column): even layers below, odd layers above start_above = (station.layer % 2 == 1) - candidate = _try_place(station, label_offset, start_above, placements) + candidate = _try_place( + station, label_offset, start_above, placements, + min_off, max_off) if _has_collision(candidate, placements): # Try the other side - candidate = _try_place(station, label_offset, not start_above, placements) + candidate = _try_place( + station, label_offset, not start_above, placements, + min_off, max_off) if _has_collision(candidate, placements): # Push further in the non-default direction direction = -1 if not start_above else 1 + if direction < 0: + y = station.y + min_off - label_offset * 2.2 + else: + y = station.y + max_off + label_offset * 2.2 candidate = LabelPlacement( station_id=station.id, text=station.label, x=station.x, - y=station.y + direction * label_offset * 2.2, + y=y, above=(direction < 0), ) @@ -137,14 +158,20 @@ def _try_place( label_offset: float, above: bool, existing: list[LabelPlacement], + min_off: float = 0.0, + max_off: float = 0.0, ) -> LabelPlacement: - """Create a label placement above or below a station.""" + """Create a label placement above or below a station. + + Offsets are measured from the pill edge: above labels use min_off + (top of the pill) and below labels use max_off (bottom of the pill). + """ if above: return LabelPlacement( station_id=station.id, text=station.label, x=station.x, - y=station.y - label_offset, + y=station.y + min_off - label_offset, above=True, ) else: @@ -152,7 +179,7 @@ def _try_place( station_id=station.id, text=station.label, x=station.x, - y=station.y + label_offset, + y=station.y + max_off + label_offset, above=False, ) diff --git a/src/nf_metro/render/svg.py b/src/nf_metro/render/svg.py index 9e1b24499..c7ba7d653 100644 --- a/src/nf_metro/render/svg.py +++ b/src/nf_metro/render/svg.py @@ -139,7 +139,7 @@ def render_svg( _render_stations(d, graph, theme, station_offsets) # Draw labels (horizontal, skip ports) - labels = place_labels(graph) + labels = place_labels(graph, station_offsets=station_offsets) _render_labels(d, labels, theme) # Legend From 5a7c292f050e5b7ce423db910ef1b6d4fd2258f5 Mon Sep 17 00:00:00 2001 From: Jonathan Manning Date: Sun, 15 Feb 2026 22:12:24 +0000 Subject: [PATCH 0006/1246] feat: Add file icons adjacent to terminus stations Add %%metro file: directive to mark stations as terminus points with a document icon rendered beside the pill. Icons appear on the outside of the flow direction (left for sources, right for sinks), with RL section support. Labels are clamped to section bounding boxes. Co-Authored-By: Claude Opus 4.6 --- examples/rnaseq_sections.mmd | 3 + src/nf_metro/layout/labels.py | 10 ++++ src/nf_metro/parser/mermaid.py | 14 +++++ src/nf_metro/parser/model.py | 4 ++ src/nf_metro/render/icons.py | 100 ++++++++++++++++++++++++++++++++- src/nf_metro/render/style.py | 10 ++++ src/nf_metro/render/svg.py | 42 ++++++++++++++ 7 files changed, 182 insertions(+), 1 deletion(-) diff --git a/examples/rnaseq_sections.mmd b/examples/rnaseq_sections.mmd index ad8688c0c..18c36f90d 100644 --- a/examples/rnaseq_sections.mmd +++ b/examples/rnaseq_sections.mmd @@ -1,6 +1,9 @@ %%metro title: nf-core/rnaseq %%metro logo: examples/nf-core-rnaseq_logo_dark.png %%metro style: dark +%%metro file: cat_fastq | FASTQ +%%metro file: multiqc_final | HTML +%%metro file: multiqc_pseudo | HTML %%metro line: star_rsem | Aligner: STAR, Quantification: RSEM | #0570b0 %%metro line: star_salmon | Aligner: STAR, Quantification: Salmon (default) | #2db572 %%metro line: hisat2 | Aligner: HISAT2, Quantification: None | #f5c542 diff --git a/src/nf_metro/layout/labels.py b/src/nf_metro/layout/labels.py index a5d94cbdc..284153a71 100644 --- a/src/nf_metro/layout/labels.py +++ b/src/nf_metro/layout/labels.py @@ -148,6 +148,16 @@ def place_labels( above=(direction < 0), ) + # Clamp terminus labels so they stay within section bbox + if station.is_terminus and station.section_id: + sec = graph.sections.get(station.section_id) + if sec and sec.bbox_w > 0: + char_width = 7.0 + text_half_w = len(candidate.text) * char_width / 2 + min_x = sec.bbox_x + text_half_w + 4 + max_x = sec.bbox_x + sec.bbox_w - text_half_w - 4 + candidate.x = max(min_x, min(candidate.x, max_x)) + placements.append(candidate) return placements diff --git a/src/nf_metro/parser/mermaid.py b/src/nf_metro/parser/mermaid.py index 801fb9942..73588d83f 100644 --- a/src/nf_metro/parser/mermaid.py +++ b/src/nf_metro/parser/mermaid.py @@ -71,6 +71,13 @@ def parse_metro_mermaid(text: str) -> MetroGraph: infer_section_layout(graph) _resolve_sections(graph) + # Apply pending terminus designations + for station_id, ext_label in graph._pending_terminus.items(): + station = graph.stations.get(station_id) + if station: + station.is_terminus = True + station.terminus_label = ext_label + return graph @@ -120,6 +127,12 @@ def _parse_directive( pos = content[len("legend:"):].strip().lower() if pos in ("bl", "br", "tl", "tr", "bottom", "right", "none"): graph.legend_position = pos + elif content.startswith("file:"): + parts = content[len("file:"):].strip().split("|") + if len(parts) >= 2: + station_id = parts[0].strip() + ext_label = parts[1].strip() + graph._pending_terminus[station_id] = ext_label def _parse_port_hint( @@ -272,6 +285,7 @@ def _parse_edge( graph.add_edge(Edge(source=source, target=target, line_id=line_id)) + def _resolve_sections(graph: MetroGraph) -> None: """Post-parse: classify edges, create ports, rewrite inter-section edges. diff --git a/src/nf_metro/parser/model.py b/src/nf_metro/parser/model.py index 2cb8b8856..375e43e23 100644 --- a/src/nf_metro/parser/model.py +++ b/src/nf_metro/parser/model.py @@ -32,6 +32,8 @@ class Station: label: str section_id: str | None = None is_port: bool = False + is_terminus: bool = False + terminus_label: str = "" # Populated by layout engine x: float = 0.0 y: float = 0.0 @@ -131,6 +133,8 @@ class MetroGraph: logo_path: str = "" # Section IDs that had explicit %%metro direction: directives _explicit_directions: set[str] = field(default_factory=set) + # Pending terminus designations: station_id -> extension label + _pending_terminus: dict[str, str] = field(default_factory=dict) def add_line(self, line: MetroLine) -> None: self.lines[line.id] = line diff --git a/src/nf_metro/render/icons.py b/src/nf_metro/render/icons.py index b8aca0406..62731b3a2 100644 --- a/src/nf_metro/render/icons.py +++ b/src/nf_metro/render/icons.py @@ -1,7 +1,9 @@ -"""Icon helpers for metro map rendering (future use).""" +"""Icon helpers for metro map rendering.""" from __future__ import annotations +import drawsvg as draw + def train_icon_path(x: float, y: float, size: float = 12.0) -> str: """Generate an SVG path string for a small train icon. Placeholder for future.""" @@ -13,3 +15,99 @@ def train_icon_path(x: float, y: float, size: float = 12.0) -> str: f"L {x} {y + hs} " f"L {x - hs} {y} Z" ) + + +def render_file_icon( + d: draw.Drawing, + cx: float, + cy: float, + width: float, + height: float, + fold_size: float, + fill: str, + stroke: str, + stroke_width: float, + corner_radius: float, + label: str, + font_size: float, + font_color: str, + font_family: str, +) -> None: + """Render a file/document icon with a dog-ear fold at top-right. + + The icon is centered on (cx, cy). The shape is a rectangle with the + top-right corner replaced by a diagonal fold. + """ + hw = width / 2 + hh = height / 2 + x0 = cx - hw + y0 = cy - hh + x1 = cx + hw + y1 = cy + hh + r = corner_radius + f = fold_size + + # Main document shape: rectangle with top-right dog-ear + # Start at top-left + corner radius, go clockwise + path = draw.Path( + fill=fill, + stroke=stroke, + stroke_width=stroke_width, + stroke_linejoin="round", + ) + # Top edge: from top-left corner to fold start + path.M(x0 + r, y0) + path.L(x1 - f, y0) + # Diagonal fold + path.L(x1, y0 + f) + # Right edge down to bottom-right corner + path.L(x1, y1 - r) + # Bottom-right corner + path.Q(x1, y1, x1 - r, y1) + # Bottom edge + path.L(x0 + r, y1) + # Bottom-left corner + path.Q(x0, y1, x0, y1 - r) + # Left edge + path.L(x0, y0 + r) + # Top-left corner + path.Q(x0, y0, x0 + r, y0) + path.Z() + d.append(path) + + # Fold triangle (slightly darker overlay) + fold_path = draw.Path( + fill=stroke, + opacity=0.15, + stroke="none", + ) + fold_path.M(x1 - f, y0) + fold_path.L(x1 - f, y0 + f) + fold_path.L(x1, y0 + f) + fold_path.Z() + d.append(fold_path) + + # Fold crease line + crease = draw.Path( + fill="none", + stroke=stroke, + stroke_width=stroke_width * 0.6, + ) + crease.M(x1 - f, y0) + crease.L(x1 - f, y0 + f) + crease.L(x1, y0 + f) + d.append(crease) + + # Extension label centered in the body (shifted down slightly to + # account for fold taking up top-right space) + text_y = cy + f * 0.15 + d.append(draw.Text( + label, + font_size, + cx, text_y, + fill=font_color, + font_family=font_family, + font_weight="bold", + text_anchor="middle", + dominant_baseline="central", + )) diff --git a/src/nf_metro/render/style.py b/src/nf_metro/render/style.py index a9d439431..4abf5c205 100644 --- a/src/nf_metro/render/style.py +++ b/src/nf_metro/render/style.py @@ -33,3 +33,13 @@ class Theme: animation_ball_color: str = "#ffffff" animation_balls_per_line: int = 3 animation_speed: float = 80.0 # pixels per second + # Terminus (file icon) settings + terminus_width: float = 28.0 + terminus_height: float = 32.0 + terminus_fold_size: float = 8.0 + terminus_fill: str = "" # empty = inherit station_fill + terminus_stroke: str = "" # empty = inherit station_stroke + terminus_stroke_width: float = 1.5 + terminus_corner_radius: float = 2.0 + terminus_font_size: float = 7.0 + terminus_font_color: str = "" # empty = inherit label_color diff --git a/src/nf_metro/render/svg.py b/src/nf_metro/render/svg.py index c7ba7d653..37cdd5dc3 100644 --- a/src/nf_metro/render/svg.py +++ b/src/nf_metro/render/svg.py @@ -9,6 +9,7 @@ from nf_metro.layout.labels import LabelPlacement, place_labels from nf_metro.layout.routing import RoutedPath, compute_station_offsets, route_edges from nf_metro.parser.model import MetroGraph +from nf_metro.render.icons import render_file_icon from nf_metro.render.legend import compute_legend_dimensions, render_legend from nf_metro.render.style import Theme @@ -400,6 +401,47 @@ def _render_stations( stroke_width=theme.station_stroke_width, )) + # Render file icon adjacent to terminus stations + if station.is_terminus: + section = graph.sections.get(station.section_id) if station.section_id else None + # Detect if station is a source (no incoming internal edges) or sink + is_source = True + if section: + for edge in section.internal_edges: + if edge.target == station.id: + is_source = False + break + # Place icon on the "outside" of the flow + icon_gap = r + 6 + icon_half_w = theme.terminus_width / 2 + section_dir = section.direction if section else "LR" + if section_dir == "RL": + icon_cx_offset = (icon_gap + icon_half_w) if is_source else -(icon_gap + icon_half_w) + else: + icon_cx_offset = -(icon_gap + icon_half_w) if is_source else (icon_gap + icon_half_w) + icon_cx = station.x + icon_cx_offset + icon_cy = station.y + (min_off + max_off) / 2 + # Clamp to stay within section bbox + if section and section.bbox_w > 0: + icon_cx = max(section.bbox_x + icon_half_w + 2, + min(icon_cx, section.bbox_x + section.bbox_w - icon_half_w - 2)) + render_file_icon( + d, + cx=icon_cx, + cy=icon_cy, + width=theme.terminus_width, + height=theme.terminus_height, + fold_size=theme.terminus_fold_size, + fill=theme.terminus_fill or theme.station_fill, + stroke=theme.terminus_stroke or theme.station_stroke, + stroke_width=theme.terminus_stroke_width, + corner_radius=theme.terminus_corner_radius, + label=station.terminus_label, + font_size=theme.terminus_font_size, + font_color="#000000", + font_family=theme.label_font_family, + ) + def _render_labels( d: draw.Drawing, From a714b67c84a2ec81766858f92a5ffe3d1806b228 Mon Sep 17 00:00:00 2001 From: Jonathan Manning Date: Mon, 16 Feb 2026 08:54:54 +0000 Subject: [PATCH 0007/1246] feat: Add non-process terminus station to qc_report section Add report_final blank-label terminus to section 5 (RL direction). Exclude non-process terminus stations from bbox computation and RL mirror anchoring so existing stations don't shift. Expand section bbox post-placement to include terminus while keeping opposite edge fixed. Generalize label clamping to all stations within sections. Co-Authored-By: Claude Opus 4.6 --- examples/rnaseq_sections.mmd | 4 ++- src/nf_metro/layout/engine.py | 53 ++++++++++++++++++++++++++++++----- src/nf_metro/layout/labels.py | 22 +++++++++++---- 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/examples/rnaseq_sections.mmd b/examples/rnaseq_sections.mmd index 18c36f90d..999dc0f25 100644 --- a/examples/rnaseq_sections.mmd +++ b/examples/rnaseq_sections.mmd @@ -2,7 +2,7 @@ %%metro logo: examples/nf-core-rnaseq_logo_dark.png %%metro style: dark %%metro file: cat_fastq | FASTQ -%%metro file: multiqc_final | HTML +%%metro file: report_final | HTML %%metro file: multiqc_pseudo | HTML %%metro line: star_rsem | Aligner: STAR, Quantification: RSEM | #0570b0 %%metro line: star_salmon | Aligner: STAR, Quantification: Salmon (default) | #2db572 @@ -91,6 +91,7 @@ graph LR deseq2_pca[DESeq2 PCA] kraken2[Kraken2/Bracken] multiqc_final[MultiQC] + report_final[ ] rseqc -->|star_salmon,star_rsem,hisat2| preseq preseq -->|star_salmon,star_rsem,hisat2| qualimap @@ -98,6 +99,7 @@ graph LR dupradar -->|star_salmon,star_rsem,hisat2| deseq2_pca deseq2_pca -->|star_salmon,star_rsem,hisat2| kraken2 kraken2 -->|star_salmon,star_rsem,hisat2| multiqc_final + multiqc_final -->|star_salmon,star_rsem,hisat2| report_final end %% Inter-section edges diff --git a/src/nf_metro/layout/engine.py b/src/nf_metro/layout/engine.py index e4af17333..a7d3ea42f 100644 --- a/src/nf_metro/layout/engine.py +++ b/src/nf_metro/layout/engine.py @@ -97,15 +97,26 @@ def _compute_section_layout( station.x = station.layer * x_spacing + layer_extra.get(station.layer, 0) station.y = track_rank[station.track] * y_spacing - # RL: mirror X so layer 0 is rightmost + # RL: mirror X so layer 0 is rightmost. + # Anchor on non-terminus stations so adding terminus layers + # extends leftward without shifting the entry point. if section.direction == "RL": - max_x_val = max(s.x for s in sub.stations.values()) + non_term = [s for s in sub.stations.values() + if not (s.is_terminus and not s.label.strip())] + anchor_stations = non_term if non_term else list(sub.stations.values()) + max_x_val = max(s.x for s in anchor_stations) for s in sub.stations.values(): s.x = max_x_val - s.x - # Ensure minimum inner extent so stations sit on visible track - xs = [s.x for s in sub.stations.values()] - ys = [s.y for s in sub.stations.values()] + # Ensure minimum inner extent so stations sit on visible track. + # Exclude terminus stations from extent/bbox so they don't + # affect section dimensions or port positions. + # Non-process terminus stations have blank labels + non_term_s = [s for s in sub.stations.values() + if not (s.is_terminus and not s.label.strip())] + bbox_src = non_term_s if non_term_s else list(sub.stations.values()) + xs = [s.x for s in bbox_src] + ys = [s.y for s in bbox_src] if section.direction == "TB": inner_h = max(ys) - min(ys) min_inner_h = y_spacing @@ -113,7 +124,7 @@ def _compute_section_layout( shift = (min_inner_h - inner_h) / 2 for station in sub.stations.values(): station.y += shift - ys = [s.y for s in sub.stations.values()] + ys = [s.y for s in bbox_src] else: inner_w = max(xs) - min(xs) min_inner_w = x_spacing @@ -121,7 +132,7 @@ def _compute_section_layout( shift = (min_inner_w - inner_w) / 2 for station in sub.stations.values(): station.x += shift - xs = [s.x for s in sub.stations.values()] + xs = [s.x for s in bbox_src] # Compute section bounding box from real stations only section.bbox_x = min(xs) - section_x_padding @@ -151,6 +162,32 @@ def _compute_section_layout( section.bbox_x += section.offset_x + x_offset section.bbox_y += section.offset_y + y_offset + # Phase 4.5: Expand section bboxes to include non-process terminus stations. + # The bbox was computed from process stations only, so terminus stations may + # sit outside. Expand toward them while keeping the opposite edge fixed. + for sec_id, section in graph.sections.items(): + margin = section_x_padding + for sid in section.station_ids: + station = graph.stations.get(sid) + if not station or not (station.is_terminus and not station.label.strip()): + continue + right_edge = section.bbox_x + section.bbox_w + bottom_edge = section.bbox_y + section.bbox_h + if station.x - margin < section.bbox_x: + expand = section.bbox_x - (station.x - margin) + section.bbox_x -= expand + section.bbox_w += expand + if station.x + margin > right_edge: + expand = (station.x + margin) - right_edge + section.bbox_w += expand + if station.y - section_y_padding < section.bbox_y: + expand = section.bbox_y - (station.y - section_y_padding) + section.bbox_y -= expand + section.bbox_h += expand + if station.y + section_y_padding > bottom_edge: + expand = (station.y + section_y_padding) - bottom_edge + section.bbox_h += expand + # Phase 5: Position ports on section boundaries (after bbox is in global coords) for sec_id, section in graph.sections.items(): position_ports(section, graph) @@ -293,6 +330,8 @@ def _build_section_subgraph(graph: MetroGraph, section: Section) -> MetroGraph: label=station.label, section_id=station.section_id, is_port=False, + is_terminus=station.is_terminus, + terminus_label=station.terminus_label, )) real_station_ids.add(sid) diff --git a/src/nf_metro/layout/labels.py b/src/nf_metro/layout/labels.py index 284153a71..79468d5ca 100644 --- a/src/nf_metro/layout/labels.py +++ b/src/nf_metro/layout/labels.py @@ -77,7 +77,7 @@ def place_labels( 3. If still colliding, push further away. """ sorted_stations = sorted( - (s for s in graph.stations.values() if not s.is_port), + (s for s in graph.stations.values() if not s.is_port and s.label.strip()), key=lambda s: (s.layer, s.track), ) @@ -148,15 +148,27 @@ def place_labels( above=(direction < 0), ) - # Clamp terminus labels so they stay within section bbox - if station.is_terminus and station.section_id: + # Clamp labels so they stay within section bbox + if station.section_id: sec = graph.sections.get(station.section_id) if sec and sec.bbox_w > 0: char_width = 7.0 + font_height = 14.0 text_half_w = len(candidate.text) * char_width / 2 - min_x = sec.bbox_x + text_half_w + 4 - max_x = sec.bbox_x + sec.bbox_w - text_half_w - 4 + margin = 4 + # Horizontal clamping + min_x = sec.bbox_x + text_half_w + margin + max_x = sec.bbox_x + sec.bbox_w - text_half_w - margin candidate.x = max(min_x, min(candidate.x, max_x)) + # Vertical clamping + if candidate.above: + min_y = sec.bbox_y + font_height + margin + if candidate.y < min_y: + candidate.y = min_y + else: + max_y = sec.bbox_y + sec.bbox_h - font_height - margin + if candidate.y > max_y: + candidate.y = max_y placements.append(candidate) From 09da11f04b5a9381f49ebb8921b9a11ecee89631 Mon Sep 17 00:00:00 2001 From: Jonathan Manning Date: Mon, 16 Feb 2026 09:28:04 +0000 Subject: [PATCH 0008/1246] feat: Grid-aware terminus layout with entry inset curve fix Include all stations (including non-process terminus) in section bbox computation, replacing the post-hoc Phase 4.5 expansion. Normalize local coordinates after RL mirror so bbox_x is consistent across sections. Expand grid columns/rows when spanning sections exceed their allocated space. Add non-process terminus stations to preprocessing (fastq_in) and pseudo_align (report_pseudo) sections. Render terminus stations as filled rectangles instead of pills. Add entry inset: when a horizontal section has a TOP/BOTTOM entry, pad bbox_w by a fixed amount so the grid allocates extra space, preventing the entry line from dropping straight down. This breaks the structural coupling between the entry station and the source above that otherwise forces them to the same X coordinate. Co-Authored-By: Claude Opus 4.6 --- examples/rnaseq_sections.mmd | 8 ++- src/nf_metro/layout/engine.py | 64 ++++++++++-------------- src/nf_metro/layout/section_placement.py | 25 +++++++++ src/nf_metro/render/svg.py | 15 +++++- 4 files changed, 72 insertions(+), 40 deletions(-) diff --git a/examples/rnaseq_sections.mmd b/examples/rnaseq_sections.mmd index 999dc0f25..98e1ba6e9 100644 --- a/examples/rnaseq_sections.mmd +++ b/examples/rnaseq_sections.mmd @@ -1,9 +1,9 @@ %%metro title: nf-core/rnaseq %%metro logo: examples/nf-core-rnaseq_logo_dark.png %%metro style: dark -%%metro file: cat_fastq | FASTQ +%%metro file: fastq_in | FASTQ %%metro file: report_final | HTML -%%metro file: multiqc_pseudo | HTML +%%metro file: report_pseudo | HTML %%metro line: star_rsem | Aligner: STAR, Quantification: RSEM | #0570b0 %%metro line: star_salmon | Aligner: STAR, Quantification: Salmon (default) | #2db572 %%metro line: hisat2 | Aligner: HISAT2, Quantification: None | #f5c542 @@ -17,6 +17,7 @@ graph LR subgraph preprocessing [Pre-processing] %%metro exit: right | star_salmon, star_rsem, hisat2 %%metro exit: bottom | pseudo_salmon, pseudo_kallisto + fastq_in[ ] cat_fastq[cat fastq] fastqc_raw[FastQC] infer_strandedness[infer strandedness] @@ -27,6 +28,7 @@ graph LR bbsplit[BBSplit] sortmerna[SortMeRNA] + fastq_in -->|star_salmon,star_rsem,hisat2,pseudo_salmon,pseudo_kallisto| cat_fastq cat_fastq -->|star_salmon,star_rsem,hisat2,pseudo_salmon,pseudo_kallisto| fastqc_raw fastqc_raw -->|star_salmon,star_rsem,hisat2,pseudo_salmon,pseudo_kallisto| infer_strandedness infer_strandedness -->|star_salmon,star_rsem,hisat2,pseudo_salmon,pseudo_kallisto| umi_tools_extract @@ -76,9 +78,11 @@ graph LR salmon_pseudo[Salmon] kallisto[Kallisto] multiqc_pseudo[MultiQC] + report_pseudo[ ] salmon_pseudo -->|pseudo_salmon| multiqc_pseudo kallisto -->|pseudo_kallisto| multiqc_pseudo + multiqc_pseudo -->|pseudo_salmon,pseudo_kallisto| report_pseudo end subgraph qc_report [Quality control & reporting] diff --git a/src/nf_metro/layout/engine.py b/src/nf_metro/layout/engine.py index a7d3ea42f..8fbe7d00a 100644 --- a/src/nf_metro/layout/engine.py +++ b/src/nf_metro/layout/engine.py @@ -108,15 +108,18 @@ def _compute_section_layout( for s in sub.stations.values(): s.x = max_x_val - s.x - # Ensure minimum inner extent so stations sit on visible track. - # Exclude terminus stations from extent/bbox so they don't - # affect section dimensions or port positions. - # Non-process terminus stations have blank labels - non_term_s = [s for s in sub.stations.values() - if not (s.is_terminus and not s.label.strip())] - bbox_src = non_term_s if non_term_s else list(sub.stations.values()) - xs = [s.x for s in bbox_src] - ys = [s.y for s in bbox_src] + # Normalize local X so leftmost station is at x=0. + # After RL mirror, terminus stations may have negative X; normalizing + # ensures bbox_x is always at -padding, and extra width from terminus + # goes into bbox_w (which feeds into grid column sizing). + min_local_x = min(s.x for s in sub.stations.values()) + if min_local_x != 0: + for s in sub.stations.values(): + s.x -= min_local_x + + # Ensure minimum inner extent so stations sit on visible track + xs = [s.x for s in sub.stations.values()] + ys = [s.y for s in sub.stations.values()] if section.direction == "TB": inner_h = max(ys) - min(ys) min_inner_h = y_spacing @@ -124,7 +127,7 @@ def _compute_section_layout( shift = (min_inner_h - inner_h) / 2 for station in sub.stations.values(): station.y += shift - ys = [s.y for s in bbox_src] + ys = [s.y for s in sub.stations.values()] else: inner_w = max(xs) - min(xs) min_inner_w = x_spacing @@ -132,7 +135,7 @@ def _compute_section_layout( shift = (min_inner_w - inner_w) / 2 for station in sub.stations.values(): station.x += shift - xs = [s.x for s in bbox_src] + xs = [s.x for s in sub.stations.values()] # Compute section bounding box from real stations only section.bbox_x = min(xs) - section_x_padding @@ -140,6 +143,19 @@ def _compute_section_layout( section.bbox_w = (max(xs) - min(xs)) + section_x_padding * 2 section.bbox_h = (max(ys) - min(ys)) + section_y_padding * 2 + # When a horizontal section (LR/RL) has a TOP/BOTTOM entry, add + # extra width on the entry side so the grid allocates space for + # the line to curve in rather than dropping straight down. + if section.direction in ("LR", "RL"): + has_vertical_entry = any( + graph.ports[pid].side in (PortSide.TOP, PortSide.BOTTOM) + for pid in section.entry_ports + if pid in graph.ports + ) + if has_vertical_entry: + entry_inset = x_spacing * 0.3 + section.bbox_w += entry_inset + section_subgraphs[sec_id] = sub # Phase 3: Place sections on the canvas @@ -162,32 +178,6 @@ def _compute_section_layout( section.bbox_x += section.offset_x + x_offset section.bbox_y += section.offset_y + y_offset - # Phase 4.5: Expand section bboxes to include non-process terminus stations. - # The bbox was computed from process stations only, so terminus stations may - # sit outside. Expand toward them while keeping the opposite edge fixed. - for sec_id, section in graph.sections.items(): - margin = section_x_padding - for sid in section.station_ids: - station = graph.stations.get(sid) - if not station or not (station.is_terminus and not station.label.strip()): - continue - right_edge = section.bbox_x + section.bbox_w - bottom_edge = section.bbox_y + section.bbox_h - if station.x - margin < section.bbox_x: - expand = section.bbox_x - (station.x - margin) - section.bbox_x -= expand - section.bbox_w += expand - if station.x + margin > right_edge: - expand = (station.x + margin) - right_edge - section.bbox_w += expand - if station.y - section_y_padding < section.bbox_y: - expand = section.bbox_y - (station.y - section_y_padding) - section.bbox_y -= expand - section.bbox_h += expand - if station.y + section_y_padding > bottom_edge: - expand = (station.y + section_y_padding) - bottom_edge - section.bbox_h += expand - # Phase 5: Position ports on section boundaries (after bbox is in global coords) for sec_id, section in graph.sections.items(): position_ports(section, graph) diff --git a/src/nf_metro/layout/section_placement.py b/src/nf_metro/layout/section_placement.py index cf4f8e267..5a35e64fb 100644 --- a/src/nf_metro/layout/section_placement.py +++ b/src/nf_metro/layout/section_placement.py @@ -147,6 +147,19 @@ def place_sections( if c not in col_widths: col_widths[c] = 0.0 + # Expand columns if a spanning section's intrinsic width exceeds the + # sum of its spanned columns. Distributes the extra to the last column. + for sid, section in graph.sections.items(): + cspan = section.grid_col_span + if cspan <= 1: + continue + start_col = col_assign.get(sid, 0) + spanned = sum(col_widths[c] for c in range(start_col, start_col + cspan)) + spanned += (cspan - 1) * section_x_gap + if section.bbox_w > spanned: + deficit = section.bbox_w - spanned + col_widths[start_col + cspan - 1] += deficit + # Cumulative x offsets (columns are shared) col_offsets: dict[int, float] = {} cumulative_x = 0.0 @@ -174,6 +187,18 @@ def place_sections( if r not in row_heights: row_heights[r] = 0.0 + # Expand rows if a spanning section's intrinsic height exceeds spanned rows + for sid, section in graph.sections.items(): + rspan = section.grid_row_span + if rspan <= 1: + continue + start_row = row_assign.get(sid, 0) + spanned = sum(row_heights[r] for r in range(start_row, start_row + rspan)) + spanned += (rspan - 1) * section_y_gap + if section.bbox_h > spanned: + deficit = section.bbox_h - spanned + row_heights[start_row + rspan - 1] += deficit + # Cumulative y offsets per row row_offsets: dict[int, float] = {} cumulative_y = 0.0 diff --git a/src/nf_metro/render/svg.py b/src/nf_metro/render/svg.py index 37cdd5dc3..b87d1665f 100644 --- a/src/nf_metro/render/svg.py +++ b/src/nf_metro/render/svg.py @@ -374,7 +374,20 @@ def _render_stations( span = max_off - min_off - if is_tb_vert: + # Non-process terminus stations: filled rectangle (same size as pill, no rounding) + is_blank_terminus = station.is_terminus and not station.label.strip() + if is_blank_terminus: + w = r * 2 + h = span + r * 2 + cy = station.y + (min_off + max_off) / 2 + d.append(draw.Rectangle( + station.x - w / 2, cy - h / 2, + w, h, + fill=theme.station_fill, + stroke=theme.station_stroke, + stroke_width=theme.station_stroke_width, + )) + elif is_tb_vert: # Horizontal pill: lines spread along X axis w = span + r * 2 h = r * 2 From 8dbd95ec634358c739c941d961fc20fec45382e7 Mon Sep 17 00:00:00 2001 From: Jonathan Manning Date: Mon, 16 Feb 2026 09:41:19 +0000 Subject: [PATCH 0009/1246] feat: Add --logo CLI option to override logo path Allows passing a different logo image (e.g. light mode variant) without modifying the .mmd file. Co-Authored-By: Claude Opus 4.6 --- src/nf_metro/cli.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/nf_metro/cli.py b/src/nf_metro/cli.py index cf242bfc2..731f601d1 100644 --- a/src/nf_metro/cli.py +++ b/src/nf_metro/cli.py @@ -35,6 +35,8 @@ def cli() -> None: help="Max layers before folding to next row (default: auto)") @click.option("--animate/--no-animate", default=False, help="Add animated balls traveling along lines") +@click.option("--logo", type=click.Path(exists=True, path_type=Path), default=None, + help="Logo image path (overrides %%metro logo: directive)") def render( input_file: Path, output: Path | None, @@ -45,11 +47,15 @@ def render( y_spacing: float, max_layers_per_row: int | None, animate: bool, + logo: Path | None, ) -> None: """Render a Mermaid metro map definition to SVG.""" text = input_file.read_text() graph = parse_metro_mermaid(text) + if logo is not None: + graph.logo_path = str(logo) + compute_layout(graph, x_spacing=x_spacing, y_spacing=y_spacing, max_layers_per_row=max_layers_per_row) From 4da709b1e8de3924c68411b7ba47f5e3f8c8ff2a Mon Sep 17 00:00:00 2001 From: Jonathan Manning Date: Mon, 16 Feb 2026 09:44:39 +0000 Subject: [PATCH 0010/1246] docs: Regenerate README SVG with terminus icons, document new features Update the animated light-theme SVG to include file terminus stations and icons. Add %%metro file directive to the reference table and document the --logo CLI option. Co-Authored-By: Claude Opus 4.6 --- README.md | 4 + examples/rnaseq_sections_light_animated.svg | 405 +++++++++++--------- 2 files changed, 219 insertions(+), 190 deletions(-) diff --git a/README.md b/README.md index 13fa40b86..faa51f1b9 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,13 @@ pip install -e ".[dev]" ```bash nf-metro render pipeline.mmd -o pipeline.svg +nf-metro render pipeline.mmd -o pipeline.svg --theme light --logo logo_light.png nf-metro validate pipeline.mmd nf-metro info pipeline.mmd ``` +The `--logo` flag overrides the `%%metro logo:` directive, letting you use the same `.mmd` file with different logos for dark/light themes. + ## Input format Input files use a subset of Mermaid `graph LR` syntax extended with `%%metro` directives. The format has three layers: **global directives** that configure the overall map, **section directives** inside `subgraph` blocks that control section layout, and **edges** that define connections between stations. @@ -171,6 +174,7 @@ These are automatically rewritten into port-to-port connections with junction st | `%%metro line: \| \| ` | Global | Define a metro line | | `%%metro grid:
\| ,[,[,]]` | Global | Pin section to grid position | | `%%metro legend: ` | Global | Legend position: `tl`, `tr`, `bl`, `br`, `bottom`, `right`, `none` | +| `%%metro file: \|