"""
Chain visualization window for HBAT cooperative hydrogen bond analysis.
This module provides a dedicated window for visualizing cooperative hydrogen bond
chains using NetworkX and matplotlib with ellipse-shaped nodes.
"""
import logging
import tkinter as tk
from tkinter import messagebox, ttk
from typing import Optional
import networkx as nx
from hbat.core.app_config import HBATConfig, get_hbat_config
from hbat.gui.export_manager import ExportManager
from hbat.gui.visualization_renderer import RendererFactory, VisualizationRenderer
# Set up logging
logger = logging.getLogger(__name__)
[docs]
class ChainVisualizationWindow:
"""Window for visualizing cooperative hydrogen bond chains.
This class creates a dedicated visualization window for displaying
cooperative interaction chains using NetworkX graphs and matplotlib.
:param parent: Parent widget
:type parent: tkinter widget
:param chain: CooperativityChain object to visualize
:type chain: CooperativityChain
:param chain_id: String identifier for the chain
:type chain_id: str
"""
[docs]
def __init__(
self, parent, chain, chain_id, config: Optional[HBATConfig] = None
) -> None:
"""Initialize the chain visualization window.
Sets up the visualization window with NetworkX graph rendering
capabilities for displaying cooperative interaction chains.
:param parent: Parent widget
:type parent: tkinter widget
:param chain: CooperativityChain object to visualize
:type chain: CooperativityChain
:param chain_id: String identifier for the chain
:type chain_id: str
:param config: HBAT configuration instance (optional)
:type config: Optional[HBATConfig]
:returns: None
:rtype: None
"""
self.parent = parent
self.chain = chain
self.chain_id = chain_id
self.config = config or get_hbat_config()
self.viz_window = None
self.G = None
self.renderer: Optional[VisualizationRenderer] = None
self.export_manager: Optional[ExportManager] = None
self.current_layout = "circular"
# Create the window
try:
self._create_window()
except ImportError as e:
messagebox.showerror(
"Error", f"Visualization libraries are not available: {str(e)}"
)
logger.error(f"Failed to create visualization window: {e}")
return
def _create_window(self):
"""Create the visualization window."""
self.viz_window = tk.Toplevel(self.parent)
self.viz_window.title(f"Cooperativity Chain Visualization - {self.chain_id}")
self.viz_window.geometry("900x700")
# Create main container frames
main_frame = ttk.Frame(self.viz_window)
main_frame.pack(fill=tk.BOTH, expand=True)
# Create visualization frame
viz_frame = ttk.Frame(main_frame)
viz_frame.pack(fill=tk.BOTH, expand=True)
# Create renderer
try:
self.renderer = RendererFactory.create_renderer(viz_frame, self.config)
logger.info(f"Using {self.renderer.get_renderer_name()} renderer")
# Create export manager
self.export_manager = ExportManager(self.renderer, self.config)
except ImportError as e:
raise ImportError(f"No visualization renderer available: {e}")
# Create the network graph
self.G = nx.MultiDiGraph()
# Build the graph
self._build_graph()
# Add toolbar first to set up engine preferences
self._create_toolbar()
# Now render the graph with correct engine settings
self._render_graph()
# Pack the renderer's canvas widget
self._pack_renderer_widget(viz_frame)
# Add chain information
self._create_info_panel()
def _build_graph(self):
"""Build the NetworkX graph from chain interactions."""
self.G.clear()
for interaction in self.chain.interactions:
# Get donor and acceptor information
donor_res = interaction.get_donor_residue()
acceptor_res = interaction.get_acceptor_residue()
# Create node IDs
if interaction.get_donor_atom():
donor_node = f"{donor_res}({interaction.get_donor_atom().name})"
else:
donor_node = donor_res
if interaction.get_acceptor_atom():
acceptor_node = (
f"{acceptor_res}({interaction.get_acceptor_atom().name})"
)
else:
acceptor_node = acceptor_res
# Add nodes
self.G.add_node(donor_node)
self.G.add_node(acceptor_node)
# Add edge with interaction data
self.G.add_edge(donor_node, acceptor_node, interaction=interaction)
def _render_graph(self, layout_type="circular"):
"""Render the graph with the specified layout using the current renderer."""
self.current_layout = layout_type
if self.renderer:
try:
self.renderer.render(self.G, layout_type)
except Exception as e:
logger.error(f"Failed to render graph: {e}")
messagebox.showerror(
"Render Error", f"Failed to render graph: {str(e)}"
)
def _pack_renderer_widget(self, parent_frame):
"""Pack the renderer's widget into the parent frame."""
# Different renderers have different widget types
if hasattr(self.renderer, "get_canvas"):
# Matplotlib renderer
canvas = self.renderer.get_canvas()
if canvas:
canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
elif hasattr(self.renderer, "canvas_frame"):
# GraphViz renderer with scrollable canvas
if self.renderer.canvas_frame:
self.renderer.canvas_frame.pack(fill=tk.BOTH, expand=True)
elif hasattr(self.renderer, "canvas"):
# Fallback for GraphViz renderer without canvas_frame
if self.renderer.canvas:
self.renderer.canvas.pack(fill=tk.BOTH, expand=True)
def _create_toolbar(self):
"""Create toolbar with layout options."""
self.toolbar_frame = ttk.Frame(self.viz_window)
self.toolbar_frame.pack(fill=tk.X)
# Create a fixed section for renderer selection
self.renderer_section = ttk.Frame(self.toolbar_frame)
self.renderer_section.pack(side=tk.LEFT)
# Renderer selection (if multiple available)
renderers = RendererFactory.get_available_renderers(self.config)
if len(renderers) > 1:
ttk.Label(self.renderer_section, text="Renderer:").pack(
side=tk.LEFT, padx=5
)
self.renderer_var = tk.StringVar(value=self.renderer.get_renderer_name())
renderer_menu = ttk.Combobox(
self.renderer_section,
textvariable=self.renderer_var,
values=[name for _, name in renderers],
state="readonly",
width=15,
)
renderer_menu.pack(side=tk.LEFT, padx=5)
renderer_menu.bind("<<ComboboxSelected>>", self._change_renderer)
# Create a dynamic section for layout/engine controls
self.layout_section = ttk.Frame(self.toolbar_frame)
self.layout_section.pack(side=tk.LEFT)
# Update the layout section based on current renderer
self._update_layout_controls()
# Create a fixed section for export and close buttons
self.button_section = ttk.Frame(self.toolbar_frame)
self.button_section.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Export button
ttk.Button(
self.button_section, text="Export...", command=self._export_visualization
).pack(side=tk.LEFT, padx=10)
# Close button
ttk.Button(
self.button_section, text="Close", command=self.viz_window.destroy
).pack(side=tk.RIGHT, padx=5, pady=5)
def _update_layout_controls(self):
"""Update the layout/engine controls based on current renderer."""
# Clear existing controls in layout section
for widget in self.layout_section.winfo_children():
widget.destroy()
renderer_name = self.renderer.get_renderer_name()
logger.debug(f"Updating layout controls for renderer: {renderer_name}")
if "GraphViz" in renderer_name:
# For GraphViz, show actual engine names
ttk.Label(self.layout_section, text="Engine:").pack(side=tk.LEFT, padx=5)
# Get available GraphViz engines
from hbat.utilities.graphviz_utils import GraphVizDetector
available_engines = GraphVizDetector.get_available_engines()
if not available_engines:
available_engines = ["dot", "neato", "fdp", "circo", "twopi"]
# Ensure "dot" is first in the list
if "dot" in available_engines:
available_engines = ["dot"] + [
engine for engine in available_engines if engine != "dot"
]
# Create dropdown for engine selection
# Always prefer 'dot' as the default engine for new chain visualization windows
current_engine = (
"dot" if "dot" in available_engines else available_engines[0]
)
# Update config to use the preferred engine
self.config.set_graphviz_engine(current_engine)
self.engine_var = tk.StringVar(value=current_engine)
engine_menu = ttk.Combobox(
self.layout_section,
textvariable=self.engine_var,
values=available_engines,
state="readonly",
width=8,
)
engine_menu.pack(side=tk.LEFT, padx=5)
engine_menu.bind("<<ComboboxSelected>>", self._change_engine)
# Explicitly set the combobox to show the current engine
# This ensures the GUI displays the correct default value
engine_menu.set(current_engine)
else:
# For other renderers, show layout names
ttk.Label(self.layout_section, text="Layout:").pack(side=tk.LEFT, padx=5)
# Get supported layouts from renderer
if hasattr(self.renderer, "get_supported_layouts"):
layouts = self.renderer.get_supported_layouts()
else:
layouts = ["circular", "shell", "kamada_kawai", "planar", "spring"]
for layout in layouts:
ttk.Button(
self.layout_section,
text=layout.replace("_", " ").title(),
command=lambda lt=layout: self._update_layout(lt),
).pack(side=tk.LEFT, padx=2)
def _create_info_panel(self):
"""Create information panel."""
info_frame = ttk.Frame(self.viz_window)
info_frame.pack(fill=tk.X, padx=10, pady=5)
info_text = (
f"Chain Type: {getattr(self.chain, 'chain_type', 'Mixed')} | "
f"Length: {self.chain.chain_length} | "
f"Interactions: {len(self.chain.interactions)}"
)
ttk.Label(info_frame, text=info_text).pack(side=tk.LEFT)
def _update_layout(self, layout_type):
"""Update the visualization with a new layout."""
self._render_graph(layout_type)
def _change_renderer(self, event=None):
"""Change the visualization renderer."""
if not hasattr(self, "renderer_var"):
return
selected_name = self.renderer_var.get()
renderers = RendererFactory.get_available_renderers(self.config)
# Find the renderer type for the selected name
renderer_type = None
for r_type, r_name in renderers:
if r_name == selected_name:
renderer_type = r_type
break
if renderer_type:
try:
# Get the parent frame of current renderer widget
parent_frame = self.renderer.parent
# Remove old renderer widget
if hasattr(self.renderer, "get_canvas"):
canvas = self.renderer.get_canvas()
if canvas:
canvas.get_tk_widget().pack_forget()
elif hasattr(self.renderer, "canvas_frame"):
if self.renderer.canvas_frame:
self.renderer.canvas_frame.pack_forget()
elif hasattr(self.renderer, "canvas"):
if self.renderer.canvas:
self.renderer.canvas.pack_forget()
# Create new renderer
self.renderer = RendererFactory.create_renderer(
parent_frame, self.config, preferred_type=renderer_type
)
# Update export manager
self.export_manager = ExportManager(self.renderer, self.config)
# Pack new renderer widget
self._pack_renderer_widget(parent_frame)
# Re-render with current layout
self._render_graph(self.current_layout)
# Update the toolbar layout controls for the new renderer
self._update_layout_controls()
logger.info(f"Switched to {self.renderer.get_renderer_name()} renderer")
except Exception as e:
logger.error(f"Failed to switch renderer: {e}")
messagebox.showerror(
"Renderer Error", f"Failed to switch renderer: {str(e)}"
)
def _export_visualization(self):
"""Export the current visualization."""
if self.export_manager:
self.export_manager.export_visualization()
def _change_engine(self, event=None):
"""Change the GraphViz engine and re-render."""
if (
not hasattr(self, "engine_var")
or self.renderer.get_renderer_name() != "GraphViz"
):
return
selected_engine = self.engine_var.get()
try:
# Update the configuration with the selected engine
self.config.set_graphviz_engine(selected_engine)
# Re-render the graph with the new engine
# For GraphViz, we use a dummy layout since the engine determines the layout
self._render_graph("dot_layout")
logger.info(f"Switched GraphViz engine to: {selected_engine}")
except Exception as e:
logger.error(f"Failed to change GraphViz engine: {e}")
messagebox.showerror("Engine Error", f"Failed to change engine: {str(e)}")