Source code for hbat.gui.graphviz_renderer

"""
GraphViz-based visualization renderer for HBAT.

This module implements the VisualizationRenderer protocol using GraphViz
for high-quality rendering of cooperative hydrogen bond chains.
"""

import logging
import subprocess
import tempfile
import tkinter as tk
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, List, Optional

import networkx as nx
from PIL import Image, ImageTk

from hbat.core.app_config import HBATConfig
from hbat.gui.visualization_renderer import BaseVisualizationRenderer
from hbat.utilities.graphviz_utils import GraphVizDetector

# Set up logging
logger = logging.getLogger(__name__)

# Optional GraphViz import
try:
    import graphviz

    GRAPHVIZ_PYTHON_AVAILABLE = True
except ImportError:
    GRAPHVIZ_PYTHON_AVAILABLE = False

# Layout mapping from existing HBAT layouts to GraphViz engines
LAYOUT_ENGINE_MAPPING = {
    "circular": "circo",
    "shell": "twopi",
    "kamada_kawai": "neato",
    "planar": "dot",
    "hierarchical": "dot",
    "spring": "fdp",
}

# Color mapping for GraphViz (HTML color names)
GRAPHVIZ_COLORS = {
    "springgreen": "springgreen",
    "cyan": "cyan",
    "mediumturquoise": "mediumturquoise",
    "darkkhaki": "darkkhaki",
    "lightgray": "lightgray",
    "darkorange": "darkorange",
    "peachpuff": "peachpuff",
}


[docs] class GraphVizRenderer(BaseVisualizationRenderer): """GraphViz-based visualization renderer. Implements high-quality graph rendering using GraphViz layout engines with fallback to subprocess calls if the Python graphviz package is not available. """
[docs] def __init__(self, parent_widget: tk.Widget, config: HBATConfig) -> None: """Initialize GraphViz renderer. :param parent_widget: Parent tkinter widget :type parent_widget: tk.Widget :param config: HBAT configuration instance :type config: HBATConfig """ super().__init__(parent_widget, config) self.canvas: Optional[tk.Canvas] = None self.current_image: Optional[ImageTk.PhotoImage] = None self.temp_files: List[str] = [] # Create canvas for displaying rendered images self._create_canvas()
[docs] def render(self, graph: nx.Graph, layout_type: str) -> None: """Render the graph using GraphViz. :param graph: NetworkX graph to render :type graph: nx.Graph :param layout_type: Layout algorithm name :type layout_type: str """ if not self.is_available(): logger.error("GraphViz renderer is not available") return try: # Prepare graph data self.prepare_graph_data(graph) # Generate DOT format dot_string = self.generate_dot(graph, layout_type) # Render with GraphViz image = self.render_with_graphviz(dot_string, layout_type) # Display in canvas if image and self.canvas: self._display_image(image) logger.debug(f"Successfully rendered graph with {layout_type} layout") except Exception as e: logger.error(f"Failed to render graph with GraphViz: {e}") raise
[docs] def export(self, format: str, filename: str) -> bool: """Export visualization to file. :param format: Export format (png, svg, pdf) :type format: str :param filename: Output filename :type filename: str :returns: True if export successful :rtype: bool """ if not self.graph: logger.error("No graph to export") return False if format.lower() not in self.get_supported_formats(): logger.error(f"Unsupported export format: {format}") return False try: # Generate DOT string dot_string = self.generate_dot(self.graph, self.current_layout) # Export using GraphViz success = self._export_with_graphviz(dot_string, format, filename) if success: self.set_last_export_path(filename) logger.info(f"Successfully exported to {filename}") return success except Exception as e: logger.error(f"Failed to export visualization: {e}") return False
[docs] def get_supported_formats(self) -> List[str]: """Get list of supported export formats. :returns: List of supported format names :rtype: List[str] """ return ["png", "svg", "pdf", "dot"]
[docs] def get_supported_layouts(self) -> List[str]: """Get list of supported layout algorithms. :returns: List of supported layout names :rtype: List[str] """ return list(LAYOUT_ENGINE_MAPPING.keys())
[docs] def is_available(self) -> bool: """Check if GraphViz renderer is available. :returns: True if renderer can be used :rtype: bool """ return GraphVizDetector.is_graphviz_available()
[docs] def get_renderer_name(self) -> str: """Get human-readable name of the renderer. :returns: Renderer name :rtype: str """ return "GraphViz"
[docs] def generate_dot(self, graph: nx.Graph, layout_type: str) -> str: """Generate DOT format string from NetworkX graph. :param graph: NetworkX graph to convert :type graph: nx.Graph :param layout_type: Layout type for engine selection :type layout_type: str :returns: DOT format string :rtype: str """ # Get GraphViz configuration - use configured engine instead of layout mapping engine = self.config.get_graphviz_engine() # Force background color to white for better visibility bgcolor = "white" rankdir = self.config.get_graphviz_preference("rankdir", "TB") node_shape = self.config.get_graphviz_preference("node_shape", "ellipse") # Start DOT string dot_lines = [ f"digraph G {{", f' bgcolor="{bgcolor}";', f' rankdir="{rankdir}";', f' node [shape="{node_shape}"];', f" overlap=false;", f" splines=true;", ] # Add nodes with styling for i, node in enumerate(graph.nodes()): node_id = self._sanitize_node_id(node) label = self._escape_label(str(node)) # Get node color if i < len(self.node_data.get("colors", [])): color = self.node_data["colors"][i] graphviz_color = GRAPHVIZ_COLORS.get(color, color) else: graphviz_color = "lightgray" # Determine node style based on type if "(" in node: # Atom node - smaller, different style style = "filled,dotted" width = "0.5" height = "0.3" else: # Residue node - larger, solid style style = "filled,solid" width = "0.7" height = "0.5" dot_lines.append( f' {node_id} [label="{label}", ' f'fillcolor="{graphviz_color}", ' f'style="{style}", ' f'width="{width}", ' f'height="{height}"];' ) # Add edges with styling edge_labels = self.edge_data.get("labels", {}) # Handle both MultiDiGraph and regular Graph if isinstance(graph, (nx.MultiDiGraph, nx.MultiGraph)): edges = graph.edges(keys=True, data=True) else: # For regular graphs, add a dummy key edges = [(u, v, 0, data) for u, v, data in graph.edges(data=True)] for u, v, key, data in edges: u_id = self._sanitize_node_id(u) v_id = self._sanitize_node_id(v) # Get edge label edge_key = (u, v, key) if edge_key in edge_labels: label = self._escape_label(edge_labels[edge_key]) label_attr = f'label="{label}", ' else: label_attr = "" # Edge styling interaction = data.get("interaction") if interaction: interaction_type = getattr(interaction, "interaction_type", "") if "hydrogen" in interaction_type.lower(): color = "blue" style = "solid" elif "halogen" in interaction_type.lower(): color = "red" style = "dashed" elif "pi" in interaction_type.lower(): color = "green" style = "dotted" else: color = "black" style = "solid" else: color = "black" style = "solid" dot_lines.append( f" {u_id} -> {v_id} [" f"{label_attr}" f'color="{color}", ' f'style="{style}", ' f'arrowhead="vee"];' ) dot_lines.append("}") return "\n".join(dot_lines)
[docs] def render_with_graphviz( self, dot_string: str, layout_type: str ) -> Optional[Image.Image]: """Render DOT string using GraphViz. :param dot_string: DOT format graph description :type dot_string: str :param layout_type: Layout type for engine selection :type layout_type: str :returns: PIL Image if successful :rtype: Optional[Image.Image] """ engine = self.config.get_graphviz_engine() if GRAPHVIZ_PYTHON_AVAILABLE: return self._render_with_python_graphviz(dot_string, engine) else: return self._render_with_subprocess(dot_string, engine)
def _render_with_python_graphviz( self, dot_string: str, engine: str ) -> Optional[Image.Image]: """Render using Python graphviz package. :param dot_string: DOT format string :type dot_string: str :param engine: GraphViz engine name :type engine: str :returns: PIL Image if successful :rtype: Optional[Image.Image] """ try: # Use graphviz package graph = graphviz.Source(dot_string) graph.engine = engine # Get DPI from config dpi = self.config.get_graphviz_export_dpi() # Render to PNG bytes png_bytes = graph.pipe(format="png", renderer="gd") # Convert to PIL Image image = Image.open(BytesIO(png_bytes)) return image except Exception as e: logger.error(f"Failed to render with Python graphviz: {e}") return None def _render_with_subprocess( self, dot_string: str, engine: str ) -> Optional[Image.Image]: """Render using subprocess calls to GraphViz. :param dot_string: DOT format string :type dot_string: str :param engine: GraphViz engine name :type engine: str :returns: PIL Image if successful :rtype: Optional[Image.Image] """ try: # Create temporary files with tempfile.NamedTemporaryFile( mode="w", suffix=".dot", delete=False ) as dot_file: dot_file.write(dot_string) dot_path = dot_file.name with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as png_file: png_path = png_file.name self.temp_files.extend([dot_path, png_path]) # Get DPI from config dpi = self.config.get_graphviz_export_dpi() # Run GraphViz command cmd = [engine, "-Tpng", f"-Gdpi={dpi}", "-o", png_path, dot_path] result = subprocess.run( cmd, capture_output=True, text=True, timeout=30, ) if result.returncode != 0: logger.error(f"GraphViz command failed: {result.stderr}") return None # Load rendered image image = Image.open(png_path) return image except Exception as e: logger.error(f"Failed to render with subprocess: {e}") return None def _export_with_graphviz( self, dot_string: str, format: str, filename: str ) -> bool: """Export using GraphViz to specified format. :param dot_string: DOT format string :type dot_string: str :param format: Export format :type format: str :param filename: Output filename :type filename: str :returns: True if successful :rtype: bool """ # Handle DOT format specially - just save the source if format.lower() == "dot": return self._export_dot_source(dot_string, filename) engine = self.config.get_graphviz_engine() if GRAPHVIZ_PYTHON_AVAILABLE: return self._export_with_python_graphviz( dot_string, engine, format, filename ) else: return self._export_with_subprocess(dot_string, engine, format, filename) def _export_with_python_graphviz( self, dot_string: str, engine: str, format: str, filename: str ) -> bool: """Export using Python graphviz package. :param dot_string: DOT format string :type dot_string: str :param engine: GraphViz engine name :type engine: str :param format: Export format :type format: str :param filename: Output filename :type filename: str :returns: True if successful :rtype: bool """ try: graph = graphviz.Source(dot_string) graph.engine = engine # Remove extension from filename since graph.render() adds it automatically from pathlib import Path base_filename = str(Path(filename).with_suffix("")) # Render to file graph.render(base_filename, format=format, cleanup=True) return True except Exception as e: logger.error(f"Failed to export with Python graphviz: {e}") return False def _export_with_subprocess( self, dot_string: str, engine: str, format: str, filename: str ) -> bool: """Export using subprocess calls to GraphViz. :param dot_string: DOT format string :type dot_string: str :param engine: GraphViz engine name :type engine: str :param format: Export format :type format: str :param filename: Output filename :type filename: str :returns: True if successful :rtype: bool """ try: # Create temporary DOT file with tempfile.NamedTemporaryFile( mode="w", suffix=".dot", delete=False ) as dot_file: dot_file.write(dot_string) dot_path = dot_file.name self.temp_files.append(dot_path) # Get DPI from config dpi = self.config.get_graphviz_export_dpi() # Run GraphViz command cmd = [engine, f"-T{format}", f"-Gdpi={dpi}", "-o", filename, dot_path] result = subprocess.run( cmd, capture_output=True, text=True, timeout=30, ) return result.returncode == 0 except Exception as e: logger.error(f"Failed to export with subprocess: {e}") return False def _export_dot_source(self, dot_string: str, filename: str) -> bool: """Export DOT source code to file. :param dot_string: DOT format string :type dot_string: str :param filename: Output filename :type filename: str :returns: True if successful :rtype: bool """ try: with open(filename, "w", encoding="utf-8") as f: f.write(dot_string) logger.info(f"Successfully exported DOT source to {filename}") return True except Exception as e: logger.error(f"Failed to export DOT source: {e}") return False def _create_canvas(self) -> None: """Create scrollable canvas widget for displaying rendered images.""" if self.parent: # Create a frame to hold canvas and scrollbars self.canvas_frame = tk.Frame(self.parent) # Create canvas with scrollbars self.canvas = tk.Canvas(self.canvas_frame, bg="white") # Create scrollbars self.h_scrollbar = tk.Scrollbar( self.canvas_frame, orient=tk.HORIZONTAL, command=self.canvas.xview ) self.v_scrollbar = tk.Scrollbar( self.canvas_frame, orient=tk.VERTICAL, command=self.canvas.yview ) # Configure canvas scrolling self.canvas.configure( xscrollcommand=self.h_scrollbar.set, yscrollcommand=self.v_scrollbar.set ) # Pack scrollbars and canvas self.h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X) self.v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # Pack the canvas frame into the parent widget self.canvas_frame.pack(fill=tk.BOTH, expand=True) def _display_image(self, image: Image.Image) -> None: """Display PIL image in scrollable canvas. :param image: PIL Image to display :type image: Image.Image """ if not self.canvas: return try: # Clear canvas first to remove any old images self.canvas.delete("all") # Get image dimensions img_width = image.width img_height = image.height # Configure scroll region to match image size first self.canvas.configure(scrollregion=(0, 0, img_width, img_height)) # Convert to PhotoImage with explicit master to prevent garbage collection # Pass the canvas master (root window) to ensure proper ownership root_widget = self.canvas.winfo_toplevel() self.current_image = ImageTk.PhotoImage(image, master=root_widget) # Ensure canvas_frame is visible if hasattr(self, 'canvas_frame') and self.canvas_frame: self.canvas_frame.update_idletasks() # Create image at top-left corner (0, 0) for proper scrolling image_id = self.canvas.create_image(0, 0, image=self.current_image, anchor=tk.NW) # Store multiple references to prevent garbage collection self.canvas.image_ref = self.current_image # Also store on the renderer itself self._image_reference = self.current_image # Bind mouse wheel scrolling self._bind_mouse_scroll() # Update canvas to ensure everything is rendered self.canvas.update_idletasks() except Exception as e: logger.error(f"Failed to display image: {e}") # Set empty scroll region if image display fails if self.canvas: try: self.canvas.configure(scrollregion=(0, 0, 0, 0)) except: pass def _bind_mouse_scroll(self) -> None: """Bind mouse wheel scrolling to canvas.""" def on_mousewheel(event): # Scroll vertically self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") def on_shift_mousewheel(event): # Scroll horizontally when shift is held self.canvas.xview_scroll(int(-1 * (event.delta / 120)), "units") # Bind mouse wheel events self.canvas.bind("<MouseWheel>", on_mousewheel) self.canvas.bind("<Shift-MouseWheel>", on_shift_mousewheel) # For Linux systems self.canvas.bind("<Button-4>", lambda e: self.canvas.yview_scroll(-1, "units")) self.canvas.bind("<Button-5>", lambda e: self.canvas.yview_scroll(1, "units")) self.canvas.bind( "<Shift-Button-4>", lambda e: self.canvas.xview_scroll(-1, "units") ) self.canvas.bind( "<Shift-Button-5>", lambda e: self.canvas.xview_scroll(1, "units") ) def _sanitize_node_id(self, node: str) -> str: """Sanitize node ID for DOT format. :param node: Original node identifier :type node: str :returns: Sanitized identifier :rtype: str """ # Replace problematic characters with underscores sanitized = node.replace("(", "_").replace(")", "_").replace(" ", "_") sanitized = sanitized.replace("-", "_").replace(":", "_").replace(".", "_") # Ensure it starts with a letter or underscore if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): sanitized = f"node_{sanitized}" return sanitized or "empty_node" def _escape_label(self, label: str) -> str: """Escape label text for DOT format. :param label: Original label text :type label: str :returns: Escaped label text :rtype: str """ # Escape quotes and backslashes escaped = label.replace("\\", "\\\\").replace('"', '\\"') # Handle newlines escaped = escaped.replace("\n", "\\n") return escaped
[docs] def cleanup(self) -> None: """Clean up temporary files and resources.""" for temp_file in self.temp_files: try: Path(temp_file).unlink(missing_ok=True) except Exception as e: logger.warning(f"Failed to clean up temp file {temp_file}: {e}") self.temp_files.clear()
[docs] def __del__(self) -> None: """Destructor to ensure cleanup.""" self.cleanup()