| """ |
| Graph rendering utilities for Collatz trees using Graphviz. |
| |
| Main entry point: |
| |
| render_collatz_tree_graphviz(df_edges, ...) |
| |
| It takes a DataFrame of edges with columns ["Source", "Target"] |
| and renders a PNG image using Graphviz. The function returns the |
| absolute path to the generated image file, which is convenient |
| for Gradio's image components. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from pathlib import Path |
| from typing import Optional, Union |
|
|
| import pandas as pd |
| from graphviz import Digraph |
|
|
|
|
| PathLike = Union[str, Path] |
|
|
|
|
| def render_collatz_tree_graphviz( |
| df_edges: pd.DataFrame, |
| filename: str = "collatz_tree", |
| directory: Optional[PathLike] = None, |
| *, |
| font_size: int = 12, |
| node_size: float = 0.05, |
| arrow_size: float = 0.5, |
| nodesep: float = 0.2, |
| ranksep: float = 0.15, |
| dpi: int = 100, |
| image_format: str = "png", |
| ) -> str: |
| """ |
| Render a Collatz inverse tree from a DataFrame of directed edges using Graphviz, |
| and save it as a single image file. |
| |
| Parameters |
| ---------- |
| df_edges : pd.DataFrame |
| DataFrame with at least two columns: "Source" and "Target". |
| Each row defines a directed edge: Source -> Target. |
| filename : str, default="collatz_tree" |
| Base file name (without extension) for the generated image. |
| directory : str or Path, optional |
| Directory where the image will be written. If None, uses the current |
| working directory. |
| font_size : int, default=11 |
| Fixed font size used for node labels. This stays constant. |
| node_size : float, default=0.40 |
| Minimum diameter of node circles in inches. Nodes will grow |
| automatically if labels are larger. |
| arrow_size : float, default=0.5 |
| Arrowhead size. |
| nodesep : float, default=0.2 |
| Minimum space between nodes on the same rank/level. |
| ranksep : float, default=0.15 |
| Minimum space between different ranks/levels in the tree. |
| dpi : int, default=200 |
| Render resolution in dots-per-inch. |
| image_format : str, default="png" |
| Output image format supported by Graphviz (e.g. "png", "svg"). |
| |
| Returns |
| ------- |
| str |
| Absolute filesystem path to the generated image file. |
| |
| Notes |
| ----- |
| - Layout direction is Bottom-to-Top ("BT"), so the root (1) appears near |
| the bottom and branches extend upwards. |
| - Font size is fixed; node circles automatically expand when labels |
| require more space, because fixedsize="false". |
| """ |
|
|
| required_columns = {"Source", "Target"} |
| if not required_columns.issubset(df_edges.columns): |
| missing = required_columns.difference(df_edges.columns) |
| raise ValueError( |
| f"df_edges must contain columns {required_columns}, missing: {missing}" |
| ) |
|
|
| |
| if directory is None: |
| directory_path = Path(".").resolve() |
| else: |
| directory_path = Path(directory).resolve() |
|
|
| directory_path.mkdir(parents=True, exist_ok=True) |
|
|
| dot = Digraph( |
| comment="Collatz Inverse Tree", |
| format=image_format, |
| ) |
|
|
| |
| raw_nodes = set(df_edges["Source"]).union(set(df_edges["Target"])) |
| labels = [] |
| for node in raw_nodes: |
| if isinstance(node, (int, float)) and int(node) == node: |
| label = str(int(node)) |
| else: |
| label = str(node) |
| labels.append(label) |
|
|
| |
| dot.attr( |
| rankdir="BT", |
| dpi=str(dpi), |
| nodesep=str(nodesep), |
| ranksep=str(ranksep), |
| ) |
|
|
| |
| |
| |
| |
| dot.attr( |
| "node", |
| shape="circle", |
| fontsize=str(font_size), |
| width=str(node_size), |
| height=str(node_size), |
| fixedsize="false", |
| ) |
|
|
| |
| dot.attr("edge", arrowsize=str(arrow_size)) |
| dot.attr(splines="true") |
|
|
| |
| for label in labels: |
| attrs = {} |
|
|
| |
| try: |
| n = int(label) |
| is_int = True |
| except Exception: |
| n = None |
| is_int = False |
|
|
| if is_int: |
| |
| if n % 2 == 0: |
| |
| attrs.update(style="filled", fillcolor="#ddeeff") |
| else: |
| |
| attrs.update(style="filled", fillcolor="#ffe5cc") |
|
|
| |
| if n == 1: |
| attrs.update( |
| style="filled,bold", |
| fillcolor="#fff2cc", |
| penwidth="2", |
| ) |
| elif n in (2, 4): |
| attrs.update( |
| style="filled", |
| fillcolor="#d0e3ff", |
| ) |
| else: |
| |
| pass |
|
|
| dot.node(label, **attrs) |
|
|
| |
| for source, target in df_edges[["Source", "Target"]].itertuples(index=False): |
| |
| if isinstance(source, (int, float)) and int(source) == source: |
| src_label = str(int(source)) |
| else: |
| src_label = str(source) |
|
|
| if isinstance(target, (int, float)) and int(target) == target: |
| tgt_label = str(int(target)) |
| else: |
| tgt_label = str(target) |
|
|
| |
| if src_label == "1" and tgt_label == "4": |
| dot.edge( |
| src_label, |
| tgt_label, |
| constraint="false", |
| color="gray40", |
| penwidth="1.6", |
| style="curved", |
| arrowsize="0.8", |
| ) |
| else: |
| dot.edge(src_label, tgt_label) |
|
|
| |
| output_path = dot.render( |
| filename=filename, |
| directory=str(directory_path), |
| cleanup=True, |
| ) |
|
|
| return str(Path(output_path).resolve()) |