"""
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_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()