"""
Composite chart mixins — convergence diagrams, dashboards, and subplot helpers.
"""
import logging
from typing import Dict, List, Any, Optional
# Core plotting libraries
try:
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
MATPLOTLIB_AVAILABLE = True
except ImportError:
MATPLOTLIB_AVAILABLE = False
plt = None
mpatches = None
sns = None
# Data processing
try:
import pandas as pd
import numpy as np
PANDAS_AVAILABLE = True
except ImportError:
PANDAS_AVAILABLE = False
pd = None
np = None
try:
from reportlab.platypus import Image
REPORTLAB_AVAILABLE = True
except ImportError:
REPORTLAB_AVAILABLE = False
Image = None
log = logging.getLogger(__name__)
__all__ = [
"CompositeChartMixin",
]
[docs]
class CompositeChartMixin:
"""Composite chart methods (convergence diagram, dashboard, summary charts, subplots)."""
[docs]
def create_convergence_diagram(self,
sources: List[Dict[str, Any]],
hub_label: str = "Unified Hub",
outputs: Optional[List[Dict[str, Any]]] = None,
title: str = "Convergence Diagram",
width: float = 10.0,
height: float = 6.0,
arrow_style: str = "curved",
arrow_count: Optional[int] = None,
arrow_color: Optional[str] = None,
show_magnitudes: bool = False,
magnitude_key: str = "magnitude") -> Image:
"""
Create a stylized convergence diagram with arrows into a labeled origin.
Args:
sources: Source node dictionaries; supports keys:
``label`` (required), ``color`` (optional), and optional magnitude key.
hub_label: Center node label.
outputs: Optional output node dictionaries (same schema as sources).
title: Diagram title.
width: Figure width in inches.
height: Figure height in inches.
arrow_style: One of ``curved``, ``radial``, ``orthogonal``.
arrow_count: Optional max number of source arrows to render.
arrow_color: Optional color override for arrows.
show_magnitudes: If True, append magnitudes to source labels when present.
magnitude_key: Dict key to read magnitude from.
Returns:
ReportLab Image object.
"""
if not MATPLOTLIB_AVAILABLE:
return self._create_placeholder_chart(width, height, "Matplotlib not available")
try:
if not sources:
raise ValueError("No sources provided for convergence diagram")
out_nodes = outputs or []
src_nodes = sources[:arrow_count] if arrow_count else sources
primary = self.default_colors.get('primary', '#1f3a5f')
secondary = self.default_colors.get('secondary', '#2f5b89')
accent = self.default_colors.get('accent', '#ed8936')
a_color = arrow_color or '#4a5568'
fig, ax = plt.subplots(figsize=(width, height), dpi=self.default_dpi)
ax.set_xlim(0, 12)
ax.set_ylim(0, 8)
ax.axis('off')
hub_x, hub_y, hub_r = 6.0, 4.0, 1.2
hub = mpatches.Circle((hub_x, hub_y), hub_r, facecolor=primary, edgecolor='white', linewidth=2)
ax.add_patch(hub)
ax.text(hub_x, hub_y, hub_label, color='white', ha='center', va='center',
fontsize=10, fontweight='bold', wrap=True)
# Left-side source nodes and inbound arrows
src_y = np.linspace(7.0, 1.0, len(src_nodes))
sx, sw, sh = 0.8, 2.9, 0.82
for src, y in zip(src_nodes, src_y):
label = str(src.get('label', 'Source'))
mag = src.get(magnitude_key)
if show_magnitudes and mag is not None:
label = f"{label}\n({mag})"
fill = src.get('color', secondary)
node = mpatches.FancyBboxPatch(
(sx, y - (sh / 2)),
sw,
sh,
boxstyle="round,pad=0.02,rounding_size=0.12",
linewidth=1.4,
facecolor=fill,
edgecolor='white',
)
ax.add_patch(node)
ax.text(sx + (sw / 2), y, label, color='white', ha='center', va='center',
fontsize=8, fontweight='bold')
if arrow_style == "radial":
conn = "arc3,rad=0.0"
elif arrow_style == "orthogonal":
conn = "angle3,angleA=0,angleB=90"
else:
conn = "arc3,rad=0.15"
lw = 2.0
if show_magnitudes and mag is not None:
try:
lw = max(1.4, min(4.2, 1.2 + float(mag)))
except (ValueError, TypeError):
lw = 2.0
ax.annotate(
"",
xy=(hub_x - (hub_r * 0.94), hub_y),
xytext=(sx + sw, y),
arrowprops=dict(
arrowstyle="-|>",
lw=lw,
color=a_color,
connectionstyle=conn,
),
)
# Optional right-side output nodes
if out_nodes:
out_y = np.linspace(6.3, 1.7, len(out_nodes))
ox, ow, oh = 8.5, 2.7, 0.82
for dst, y in zip(out_nodes, out_y):
label = str(dst.get('label', 'Output'))
fill = dst.get('color', accent)
node = mpatches.FancyBboxPatch(
(ox, y - (oh / 2)),
ow,
oh,
boxstyle="round,pad=0.02,rounding_size=0.12",
linewidth=1.4,
facecolor=fill,
edgecolor='white',
)
ax.add_patch(node)
ax.text(ox + (ow / 2), y, label, color='white', ha='center', va='center',
fontsize=8, fontweight='bold')
if arrow_style == "radial":
out_conn = "arc3,rad=0.0"
elif arrow_style == "orthogonal":
out_conn = "angle3,angleA=180,angleB=90"
else:
out_conn = "arc3,rad=-0.12"
ax.annotate(
"",
xy=(ox, y),
xytext=(hub_x + (hub_r * 0.94), hub_y),
arrowprops=dict(
arrowstyle="-|>",
lw=2.0,
color=a_color,
connectionstyle=out_conn,
),
)
ax.set_title(title, fontsize=13, fontweight='bold')
return self._matplotlib_to_reportlab_image(fig, width, height)
except (ValueError, TypeError, KeyError, IndexError, AttributeError) as e:
raise RuntimeError(
f"Convergence Diagram Error: {e}"
) from e
[docs]
def create_dashboard(self, charts: List[Dict[str, Any]],
layout: str = "2x2", width: float = 12.0, height: float = 8.0) -> Image:
"""
Create a dashboard with multiple charts.
Args:
charts: List of chart configurations
layout: Layout string (e.g., "2x2", "3x1")
width: Total dashboard width in inches
height: Total dashboard height in inches
Returns:
ReportLab Image object
"""
if not MATPLOTLIB_AVAILABLE:
return self._create_placeholder_chart(width, height, "Matplotlib not available")
try:
# Parse layout
if 'x' in layout:
cols, rows = map(int, layout.split('x'))
else:
cols, rows = 2, 2
# Create subplot grid with very conservative sizing to prevent ReportLab crashes
fig, axes = plt.subplots(rows, cols, figsize=(width, height), dpi=self.default_dpi)
# Handle single subplot case
if rows == 1 and cols == 1:
axes = [axes]
elif rows == 1 or cols == 1:
axes = axes.flatten()
else:
axes = axes.flatten()
# Create each chart
for i, chart_config in enumerate(charts):
if i >= len(axes):
break
ax = axes[i]
chart_type = chart_config.get('type', 'bar')
chart_title = chart_config.get('title', f'Chart {i+1}')
# Create chart based on type
if chart_type == 'bar':
self._create_bar_subplot(ax, chart_config, chart_title)
elif chart_type == 'line':
self._create_line_subplot(ax, chart_config, chart_title)
elif chart_type == 'pie':
self._create_pie_subplot(ax, chart_config, chart_title)
elif chart_type == 'scatter':
self._create_scatter_subplot(ax, chart_config, chart_title)
# Hide empty subplots
for i in range(len(charts), len(axes)):
axes[i].set_visible(False)
plt.tight_layout()
# Convert to ReportLab Image
return self._matplotlib_to_reportlab_image(fig, width, height)
except (ValueError, TypeError, KeyError, IndexError, AttributeError) as e:
raise RuntimeError(
f"Dashboard Error: {e}"
) from e
[docs]
def create_dataframe_summary_charts(self, df: 'pd.DataFrame',
title: str = "", width: float = 8.0, height: float = 6.0) -> Image:
"""
Create summary charts from a pandas DataFrame.
Args:
df: Pandas DataFrame
title: Chart title
width: Chart width in inches
height: Chart height in inches
Returns:
ReportLab Image object
"""
if not MATPLOTLIB_AVAILABLE or not PANDAS_AVAILABLE:
return self._create_placeholder_chart(width, height, "Matplotlib/Pandas not available")
try:
# Get numeric columns
numeric_cols = df.select_dtypes(include=[np.number]).columns
if len(numeric_cols) == 0:
raise ValueError("No numeric columns found for summary charts")
# Create subplots
fig, axes = plt.subplots(2, 2, figsize=(width, height), dpi=self.default_dpi)
axes = axes.flatten()
# Distribution plots
for i, col in enumerate(numeric_cols[:4]):
if i < len(axes):
ax = axes[i]
ax.hist(df[col].dropna(), bins=20, alpha=0.7, color=self.color_palette[i % len(self.color_palette)])
ax.set_title(f'{col} Distribution')
ax.set_xlabel(col)
ax.set_ylabel('Frequency')
ax.grid(True, alpha=0.3)
# Hide empty subplots
for i in range(len(numeric_cols[:4]), len(axes)):
axes[i].set_visible(False)
plt.suptitle(title or "DataFrame Summary Charts")
plt.tight_layout()
# Convert to ReportLab Image
return self._matplotlib_to_reportlab_image(fig, width, height)
except (ValueError, TypeError, KeyError, IndexError, AttributeError) as e:
raise RuntimeError(
f"Summary Charts Error: {e}"
) from e
def _create_bar_subplot(self, ax, chart_config: Dict[str, Any], title: str):
"""Create a bar chart in a subplot."""
try:
data = chart_config.get('data', {})
labels = data.get('labels', [])
datasets = data.get('datasets', [])
if datasets and len(datasets) > 0:
values = datasets[0].get('data', [])
ax.bar(labels, values, color=self.default_colors['primary'])
ax.set_title(title)
ax.grid(True, alpha=0.3)
except (ValueError, TypeError, KeyError, IndexError, AttributeError) as e:
log.error(f"Error creating bar subplot: {e}")
def _create_line_subplot(self, ax, chart_config: Dict[str, Any], title: str):
"""Create a line chart in a subplot."""
try:
data = chart_config.get('data', {})
labels = data.get('labels', [])
datasets = data.get('datasets', [])
for i, dataset in enumerate(datasets):
values = dataset.get('data', [])
color = self.color_palette[i % len(self.color_palette)]
ax.plot(labels, values, color=color, marker='o')
ax.set_title(title)
ax.grid(True, alpha=0.3)
except (ValueError, TypeError, KeyError, IndexError, AttributeError) as e:
log.error(f"Error creating line subplot: {e}")
def _create_pie_subplot(self, ax, chart_config: Dict[str, Any], title: str):
"""Create a pie chart in a subplot."""
try:
data = chart_config.get('data', {})
labels = data.get('labels', [])
values = data.get('data', [])
ax.pie(values, labels=labels, autopct='%1.1f%%', colors=self.color_palette[:len(values)])
ax.set_title(title)
except (ValueError, TypeError, KeyError, IndexError, AttributeError) as e:
log.error(f"Error creating pie subplot: {e}")
def _create_scatter_subplot(self, ax, chart_config: Dict[str, Any], title: str):
"""Create a scatter plot in a subplot."""
try:
data = chart_config.get('data', {})
x_values = data.get('x', [])
y_values = data.get('y', [])
ax.scatter(x_values, y_values, alpha=0.6, color=self.default_colors['primary'])
ax.set_title(title)
ax.grid(True, alpha=0.3)
except (ValueError, TypeError, KeyError, IndexError, AttributeError) as e:
log.error(f"Error creating scatter subplot: {e}")