from __future__ import annotations
import json
import html
from pathlib import Path
from typing import Dict, List
from typing_extensions import Self
import matplotlib as mpl
import matplotlib.colors as mcolors
from graph.PPINetwork import PPINetwork
ADD_SECTION_STRING = "__ADD_MORE_SECTIONS__"
[docs]
class ReportGenerator:
"""
Generate interactive HTML reports for protein-protein interaction (PPI) networks.
This class creates standalone HTML reports with embedded Cytoscape.js visualizations
and analysis plots. Reports include interactive controls for selecting which node/edge
attributes determine visual properties (color, size, width).
Attributes:
html_template (str): The base HTML template with placeholders for dynamic content.
"""
def __init__(self, base_file_html_path: str = "./visual/web/base.html"):
"""
Initialize the ReportGenerator with a base HTML template.
Args:
base_file_html_path: Path to the base HTML template file. Defaults to "./visual/web/base.html".
Raises:
FileNotFoundError: If the base HTML template file does not exist.
IOError: If the template file cannot be read.
"""
with open(base_file_html_path, 'r') as file:
self.html_template = file.read()
@staticmethod
def _make_colormap_palette(cmap_name: str = "viridis", n_colors: int = 256) -> List[str]:
"""
Generate a list of hex color codes from a matplotlib colormap.
Args:
cmap_name: Name of a matplotlib colormap. Defaults to "viridis".
Falls back to "viridis" if the name is not found.
n_colors: Number of colors to generate. Defaults to 256.
Returns:
List[str]: List of hex color codes (e.g., ["#440154", "#482878", ...]).
Note:
If n_colors is <= 1, only one color is returned.
"""
try:
cmap = mpl.colormaps[cmap_name]
except KeyError:
cmap = mpl.colormaps["viridis"]
if n_colors <= 1:
return [mcolors.to_hex(cmap(0.0))]
return [
mcolors.to_hex(cmap(i / (n_colors - 1)))
for i in range(n_colors)
]
[docs]
def add_cytoscape_html_report(
self,
network: PPINetwork,
title: str = "Protein Network Report",
node_color_attr: str = "pagerank",
node_size_attr: str = "degree",
edge_width_attr: str = "combined_score",
cmap_name: str = "viridis",
min_node_size: float = 35,
max_node_size: float = 100,
min_edge_width: float = 1,
max_edge_width: float = 10,
) -> "ReportGenerator":
"""
Add an interactive Cytoscape.js network visualization to the report.
The visualization includes dropdown menus for dynamically selecting which
node/edge attributes control color, size, and width, allowing interactive
exploration of different network properties.
Args:
network: The PPINetwork object to visualize.
title: Title for the network report. Defaults to "Protein Network Report".
node_color_attr: Node attribute to control node color. Defaults to "pagerank".
node_size_attr: Node attribute to control node size. Defaults to "degree".
edge_width_attr: Edge attribute to control edge width. Defaults to "combined_score".
cmap_name: Name of the matplotlib colormap. Defaults to "viridis".
min_node_size: Minimum node size in pixels. Defaults to 35.
max_node_size: Maximum node size in pixels. Defaults to 100.
min_edge_width: Minimum edge width in pixels. Defaults to 1.
max_edge_width: Maximum edge width in pixels. Defaults to 10.
Returns:
ReportGenerator: Self for method chaining.
Note:
The generated HTML tries to load cytoscape.min.js locally first.
If not found, it loads Cytoscape.js from the online CDN.
"""
elements = network.networkx_to_cytoscape_elements(
node_color_attr=node_color_attr,
node_size_attr=node_size_attr,
edge_width_attr=edge_width_attr,
cmap_name=cmap_name,
min_node_size=min_node_size,
max_node_size=max_node_size,
min_edge_width=min_edge_width,
max_edge_width=max_edge_width,
)
node_numeric_attrs = network.get_numeric_node_attrs()
edge_numeric_attrs = network.get_numeric_edge_attrs()
color_palette = self._make_colormap_palette(cmap_name=cmap_name, n_colors=256)
elements_json = json.dumps(elements, ensure_ascii=False)
node_numeric_attrs_json = json.dumps(node_numeric_attrs, ensure_ascii=False)
edge_numeric_attrs_json = json.dumps(edge_numeric_attrs, ensure_ascii=False)
color_palette_json = json.dumps(color_palette, ensure_ascii=False)
safe_title = html.escape(title)
self.html_template = (
self.html_template
.replace("__TITLE__", safe_title)
.replace("__NODE_COLOR_ATTR__", html.escape(node_color_attr))
.replace("__NODE_SIZE_ATTR__", html.escape(node_size_attr))
.replace("__EDGE_WIDTH_ATTR__", html.escape(edge_width_attr))
.replace("__NODE_COLOR_ATTR_JS__", json.dumps(node_color_attr)[1:-1])
.replace("__NODE_SIZE_ATTR_JS__", json.dumps(node_size_attr)[1:-1])
.replace("__EDGE_WIDTH_ATTR_JS__", json.dumps(edge_width_attr)[1:-1])
.replace("__N_NODES__", str(network.G.number_of_nodes()))
.replace("__N_EDGES__", str(network.G.number_of_edges()))
.replace("__MIN_NODE_SIZE__", str(float(min_node_size)))
.replace("__MAX_NODE_SIZE__", str(float(max_node_size)))
.replace("__MIN_EDGE_WIDTH__", str(float(min_edge_width)))
.replace("__MAX_EDGE_WIDTH__", str(float(max_edge_width)))
.replace("__ELEMENTS_JSON__", elements_json)
.replace("__NODE_NUMERIC_ATTRS_JSON__", node_numeric_attrs_json)
.replace("__EDGE_NUMERIC_ATTRS_JSON__", edge_numeric_attrs_json)
.replace("__COLOR_PALETTE_JSON__", color_palette_json)
)
return self
[docs]
def add_section(self, html_to_add: str) -> "ReportGenerator":
"""
Add a new section to the HTML report.
Adds the provided HTML content by replacing the placeholder string.
Args:
html_to_add: HTML string to insert into the report.
Returns:
ReportGenerator: Self for method chaining.
Note:
The placeholder string is defined as `__ADD_MORE_SECTIONS__` and
should be present in the base HTML template.
"""
self.html_template = self.html_template.replace(
ADD_SECTION_STRING,
f"{html_to_add} \n {ADD_SECTION_STRING}"
)
return self
[docs]
def add_section_from_file(
self,
file: str,
replacements: Dict[str, str] | None = None,
) -> "ReportGenerator":
"""
Add a new section to the HTML report by reading from an HTML file.
Optionally performs string replacements before inserting the content.
Args:
file: Path to an HTML file to read and insert.
replacements: Optional dictionary mapping strings to replace in the file content.
Defaults to None.
Returns:
ReportGenerator: Self for method chaining.
Raises:
FileNotFoundError: If the specified file does not exist.
IOError: If the file cannot be read.
Example:
>>> gen = ReportGenerator()
>>> gen.add_section_from_file(
... file="./plots.html",
... replacements={"__PLOT_1__": encoded_image_1, "__PLOT_2__": encoded_image_2}
... )
"""
with open(file, 'r') as f:
html_file = f.read()
if replacements is not None:
for to_replace, new_str in replacements.items():
html_file = html_file.replace(
to_replace, new_str
)
return self.add_section(html_to_add=html_file)
[docs]
def generate_report_file(self, output_file: str = "protein_network_report.html") -> str:
"""
Generate and save the final HTML report file.
Args:
output_file: Output file path for the HTML report. Defaults to "protein_network_report.html".
Returns:
str: The absolute path to the generated report file.
Raises:
IOError: If the report file cannot be written.
"""
html_report = self.html_template.replace(ADD_SECTION_STRING, "")
output_path = Path(output_file)
output_path.write_text(html_report, encoding="utf-8")
return str(output_path)