"""
Visualization renderer protocol and base classes for HBAT.
This module defines the interface and common functionality for different
visualization renderers (GraphViz, matplotlib) used in cooperative chain
visualization.
"""
import abc
import tkinter as tk
from typing import Any, Dict, List, Optional, Protocol, Tuple, Union
import networkx as nx
from hbat.core.app_config import HBATConfig
[docs]
class VisualizationRenderer(Protocol):
"""Protocol for visualization renderer implementations.
This protocol defines the interface that all visualization renderers
must implement to be compatible with the chain visualization system.
"""
[docs]
def render(self, graph: nx.Graph, layout_type: str) -> None:
"""Render the graph with the specified layout.
:param graph: NetworkX graph to render
:type graph: nx.Graph
:param layout_type: Layout algorithm name
:type layout_type: str
:returns: None
:rtype: None
"""
...
[docs]
def export(self, format: str, filename: str) -> bool:
"""Export visualization to file.
:param format: Export format (png, svg, pdf, etc.)
:type format: str
:param filename: Output filename
:type filename: str
:returns: True if export successful, False otherwise
:rtype: bool
"""
...
[docs]
def update_layout(self, layout_type: str) -> None:
"""Update visualization with new layout.
:param layout_type: New layout algorithm name
:type layout_type: str
:returns: None
:rtype: None
"""
...
[docs]
def get_supported_layouts(self) -> List[str]:
"""Get list of supported layout algorithms.
:returns: List of supported layout names
:rtype: List[str]
"""
...
[docs]
def is_available(self) -> bool:
"""Check if renderer is available for use.
:returns: True if renderer can be used
:rtype: bool
"""
...
[docs]
def get_renderer_name(self) -> str:
"""Get human-readable name of the renderer.
:returns: Renderer name
:rtype: str
"""
...
[docs]
class BaseVisualizationRenderer(abc.ABC):
"""Abstract base class for visualization renderers.
Provides common functionality and enforces the VisualizationRenderer
protocol implementation.
"""
[docs]
def __init__(self, parent_widget: tk.Widget, config: HBATConfig) -> None:
"""Initialize the base renderer.
:param parent_widget: Parent tkinter widget
:type parent_widget: tk.Widget
:param config: HBAT configuration instance
:type config: HBATConfig
"""
self.parent = parent_widget
self.config = config
self.graph: Optional[nx.Graph] = None
self.current_layout: str = "circular"
self.node_data: Dict[str, Any] = {}
self.edge_data: Dict[str, Any] = {}
self.last_export_path: Optional[str] = None
[docs]
@abc.abstractmethod
def render(self, graph: nx.Graph, layout_type: str) -> None:
"""Render the graph with the specified layout.
Must be implemented by subclasses.
"""
pass
[docs]
@abc.abstractmethod
def export(self, format: str, filename: str) -> bool:
"""Export visualization to file.
Must be implemented by subclasses.
"""
pass
[docs]
@abc.abstractmethod
def is_available(self) -> bool:
"""Check if renderer is available for use.
Must be implemented by subclasses.
"""
pass
[docs]
@abc.abstractmethod
def get_renderer_name(self) -> str:
"""Get human-readable name of the renderer.
Must be implemented by subclasses.
"""
pass
[docs]
def update_layout(self, layout_type: str) -> None:
"""Update visualization with new layout.
Default implementation re-renders with new layout.
:param layout_type: New layout algorithm name
:type layout_type: str
"""
if self.graph is not None:
self.current_layout = layout_type
self.render(self.graph, layout_type)
[docs]
def get_supported_layouts(self) -> List[str]:
"""Get list of supported layout algorithms.
Default implementation returns common layouts.
:returns: List of supported layout names
:rtype: List[str]
"""
return ["circular", "shell", "kamada_kawai", "planar", "spring"]
[docs]
def prepare_graph_data(self, graph: nx.Graph) -> None:
"""Prepare node and edge data for rendering.
This method extracts styling information from the NetworkX graph
and prepares it for rendering.
:param graph: NetworkX graph to prepare
:type graph: nx.Graph
"""
self.graph = graph
self.node_data = self._prepare_node_data()
self.edge_data = self._prepare_edge_data()
def _prepare_node_data(self) -> Dict[str, Any]:
"""Prepare node styling data.
:returns: Dictionary with node styling information
:rtype: Dict[str, Any]
"""
if not self.graph:
return {}
node_labels = {}
node_colors = []
node_sizes = []
for node in self.graph.nodes():
node_labels[node] = node
# Color based on node type (atom vs residue)
if "(" in node:
# Atom-specific node
atom_name = node.split("(")[1].split(")")[0]
if atom_name.startswith(("N", "NH")):
node_colors.append("springgreen")
elif atom_name.startswith(("O", "OH")):
node_colors.append("cyan")
elif atom_name.startswith(("S", "SH")):
node_colors.append("mediumturquoise")
elif atom_name in ["F", "Cl", "Br", "I"]:
node_colors.append("darkkhaki")
else:
node_colors.append("lightgray")
node_sizes.append(900)
else:
# Residue node
if any(res in node for res in ["PHE", "TYR", "TRP", "HIS"]):
node_colors.append("darkorange")
elif any(res in node for res in ["ASP", "GLU"]):
node_colors.append("cyan")
elif any(res in node for res in ["LYS", "ARG", "HIS"]):
node_colors.append("springgreen")
elif any(res in node for res in ["SER", "THR", "ASN", "GLN"]):
node_colors.append("peachpuff")
else:
node_colors.append("lightgray")
node_sizes.append(1200)
return {
"labels": node_labels,
"colors": node_colors,
"sizes": node_sizes,
}
def _prepare_edge_data(self) -> Dict[str, Any]:
"""Prepare edge styling data.
:returns: Dictionary with edge styling information
:rtype: Dict[str, Any]
"""
if not self.graph:
return {}
edge_labels = {}
# Handle both MultiDiGraph and regular Graph
if isinstance(self.graph, nx.MultiDiGraph) or isinstance(
self.graph, nx.MultiGraph
):
edges = self.graph.edges(keys=True, data=True)
else:
# For regular graphs, add a dummy key
edges = [(u, v, 0, data) for u, v, data in self.graph.edges(data=True)]
for u, v, key, data in edges:
interaction = data.get("interaction")
if interaction:
interaction_type = getattr(interaction, "interaction_type", "Unknown")
distance = getattr(interaction, "distance", 0)
angle = getattr(interaction, "angle", 0)
# Convert angle from radians to degrees if needed
import math
if hasattr(interaction, "angle") and interaction.angle:
angle_deg = math.degrees(interaction.angle)
else:
angle_deg = 0
edge_labels[(u, v, key)] = (
f"{interaction_type}\n{distance:.2f}Å\n{angle_deg:.1f}°"
)
return {"labels": edge_labels}
[docs]
def validate_layout(self, layout_type: str) -> bool:
"""Validate if layout type is supported.
:param layout_type: Layout type to validate
:type layout_type: str
:returns: True if layout is supported
:rtype: bool
"""
return layout_type in self.get_supported_layouts()
[docs]
def get_last_export_path(self) -> Optional[str]:
"""Get the path of the last successful export.
:returns: Last export path or None
:rtype: Optional[str]
"""
return self.last_export_path
[docs]
def set_last_export_path(self, path: str) -> None:
"""Set the path of the last successful export.
:param path: Export path to remember
:type path: str
"""
self.last_export_path = path
[docs]
class RendererFactory:
"""Factory class for creating visualization renderers.
Handles selection and instantiation of appropriate renderers based on
availability and user preferences.
"""
[docs]
@staticmethod
def create_renderer(
parent_widget: tk.Widget,
config: HBATConfig,
preferred_type: Optional[str] = None,
) -> VisualizationRenderer:
"""Create appropriate visualization renderer.
:param parent_widget: Parent tkinter widget
:type parent_widget: tk.Widget
:param config: HBAT configuration instance
:type config: HBATConfig
:param preferred_type: Preferred renderer type ("graphviz" or "matplotlib")
:type preferred_type: Optional[str]
:returns: Visualization renderer instance
:rtype: VisualizationRenderer
:raises ImportError: If no renderer is available
"""
# Import renderers here to avoid circular imports
from hbat.utilities.graphviz_utils import GraphVizDetector
# Check GraphViz availability and preferences
graphviz_available = GraphVizDetector.is_graphviz_available()
graphviz_enabled = config.is_graphviz_enabled()
# Determine which renderer to use
use_graphviz = False
if preferred_type == "graphviz":
use_graphviz = graphviz_available and graphviz_enabled
elif preferred_type == "matplotlib":
use_graphviz = False
else:
# Auto-select based on availability and preferences
use_graphviz = graphviz_available and graphviz_enabled
if use_graphviz:
try:
from hbat.gui.graphviz_renderer import GraphVizRenderer
return GraphVizRenderer(parent_widget, config)
except ImportError:
# Fall back to matplotlib if GraphViz renderer fails to import
pass
# Use matplotlib renderer as fallback
try:
from hbat.gui.matplotlib_renderer import MatplotlibRenderer
return MatplotlibRenderer(parent_widget, config)
except ImportError:
raise ImportError("No visualization renderer available")
[docs]
@staticmethod
def get_available_renderers(config: HBATConfig) -> List[Tuple[str, str]]:
"""Get list of available renderers.
:param config: HBAT configuration instance
:type config: HBATConfig
:returns: List of (renderer_type, renderer_name) tuples
:rtype: List[Tuple[str, str]]
"""
renderers = []
# Check matplotlib availability
try:
from hbat.gui.matplotlib_renderer import MatplotlibRenderer
renderers.append(("matplotlib", "NetworkX/Matplotlib"))
except ImportError:
pass
# Check GraphViz availability
from hbat.utilities.graphviz_utils import GraphVizDetector
if GraphVizDetector.is_graphviz_available():
try:
from hbat.gui.graphviz_renderer import GraphVizRenderer
renderers.append(("graphviz", "GraphViz"))
except ImportError:
pass
return renderers