"""Visualization of communication networks (matplotlib/seaborn).
Ports the most-used CellChat plots (``visualization.R``) in the sceleto house
style: each function accepts an optional ``ax``/``figsize``/``cmap`` and returns
the matplotlib ``Figure`` (or seaborn grid).
- :func:`plot_network_circle` — circular cell-group interaction diagram
(``netVisual_circle``).
- :func:`plot_heatmap` — sender × receiver interaction heatmap
(``netVisual_heatmap``).
- :func:`plot_bubble` — L–R pair × cell-pair bubble plot (``netVisual_bubble``).
- :func:`plot_chord` — chord diagram of group–group communication
(``netVisual_chord_cell``); uses matplotlib arcs (no external chord lib).
"""
from __future__ import annotations
from typing import Optional, Sequence
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch, Circle
def _check_square(mat: np.ndarray, names: Sequence[str]) -> None:
"""Validate that ``mat`` is a K×K matrix matching ``len(names)``."""
mat = np.asarray(mat)
K = len(names)
if mat.shape != (K, K):
raise ValueError(
f"matrix shape {mat.shape} does not match len(names)={K}; "
"expected a square (K, K) matrix aligned to the group names"
)
if K == 0:
raise ValueError("cannot plot an empty network (no cell groups)")
def _palette(n: int) -> list:
"""n distinct colors from the bundled sceleto palettes."""
from sceleto.data._colors import zeileis_26, godsnot_64
base = zeileis_26 if n <= 26 else godsnot_64
return [base[i % len(base)] for i in range(n)]
[docs]
def plot_network_circle(
mat: np.ndarray,
names: Sequence[str],
*,
ax: Optional[plt.Axes] = None,
figsize: tuple[float, float] = (7, 7),
edge_scale: float = 8.0,
node_scale: float = 800.0,
cmap: Optional[str] = None,
title: Optional[str] = None,
) -> plt.Figure:
"""Circular interaction diagram (``netVisual_circle``).
Nodes are cell groups placed on a circle; a directed edge i→j is drawn with
width proportional to ``mat[i, j]`` and colored by the sender.
Parameters
----------
mat
``K × K`` weight/count matrix (source × target).
names
Cell-group names.
edge_scale, node_scale
Visual scaling for edge widths and node sizes.
"""
names = list(names)
_check_square(mat, names)
K = len(names)
if ax is None:
fig, ax = plt.subplots(figsize=figsize)
else:
fig = ax.get_figure()
theta = np.linspace(0, 2 * np.pi, K, endpoint=False)
pos = np.c_[np.cos(theta), np.sin(theta)]
colors = _palette(K)
mx = mat.max() if mat.max() > 0 else 1.0
node_tot = mat.sum(axis=1) + mat.sum(axis=0)
ntot_mx = node_tot.max() if node_tot.max() > 0 else 1.0
# edges
for i in range(K):
for j in range(K):
w = mat[i, j]
if w <= 0:
continue
if i == j:
# self-loop as a small circle offset outward
c = Circle(pos[i] * 1.12, radius=0.06 * (0.5 + w / mx),
fill=False, edgecolor=colors[i],
lw=0.5 + edge_scale * w / mx, alpha=0.7)
ax.add_patch(c)
continue
arrow = FancyArrowPatch(
pos[i], pos[j],
arrowstyle="-|>", mutation_scale=12,
lw=0.4 + edge_scale * w / mx,
color=colors[i], alpha=0.6,
connectionstyle="arc3,rad=0.15",
shrinkA=14, shrinkB=14,
)
ax.add_patch(arrow)
# nodes
ax.scatter(pos[:, 0], pos[:, 1],
s=node_scale * (0.25 + node_tot / ntot_mx),
c=colors, zorder=3, edgecolors="white", linewidths=1.0)
for i, name in enumerate(names):
ang = theta[i]
ax.text(pos[i, 0] * 1.28, pos[i, 1] * 1.28, name,
ha="center", va="center", fontsize=10,
rotation=0)
ax.set_xlim(-1.6, 1.6)
ax.set_ylim(-1.6, 1.6)
ax.set_aspect("equal")
ax.axis("off")
if title:
ax.set_title(title)
fig.tight_layout()
return fig
[docs]
def plot_heatmap(
mat: np.ndarray,
names: Sequence[str],
*,
ax: Optional[plt.Axes] = None,
figsize: tuple[float, float] = (6, 5),
cmap: str = "Reds",
title: Optional[str] = None,
) -> plt.Figure:
"""Sender × receiver heatmap of interaction strength (``netVisual_heatmap``)."""
import seaborn as sns
names = list(names)
_check_square(mat, names)
if ax is None:
fig, ax = plt.subplots(figsize=figsize)
else:
fig = ax.get_figure()
df = pd.DataFrame(mat, index=names, columns=names)
sns.heatmap(df, cmap=cmap, ax=ax, cbar_kws={"label": "strength"},
square=True, linewidths=0.3, linecolor="white")
ax.set_xlabel("Target (receiver)")
ax.set_ylabel("Source (sender)")
if title:
ax.set_title(title)
fig.tight_layout()
return fig
[docs]
def plot_bubble(
comm: pd.DataFrame,
*,
figsize: Optional[tuple[float, float]] = None,
cmap: str = "Reds",
title: Optional[str] = None,
) -> plt.Figure:
"""Bubble plot of L–R communication (``netVisual_bubble``).
Parameters
----------
comm
Long-form table from :meth:`CellComm.subset_communication` (columns
``source``, ``target``, ``interaction_name``, ``prob``, ``pval``).
"""
df = comm.copy()
if len(df) == 0:
# nothing significant to plot — return an empty annotated axes rather
# than crashing inside scatter on object-dtype empty arrays
fig, ax = plt.subplots(figsize=figsize or (5, 4))
ax.text(0.5, 0.5, "no significant communication",
ha="center", va="center", transform=ax.transAxes, color="gray")
ax.axis("off")
if title:
ax.set_title(title)
return fig
df["pair"] = df["source"].astype(str) + " → " + df["target"].astype(str)
x_order = sorted(df["pair"].unique())
y_order = sorted(df["interaction_name"].unique())
xi = {p: i for i, p in enumerate(x_order)}
yi = {p: i for i, p in enumerate(y_order)}
if figsize is None:
figsize = (max(5, 0.5 * len(x_order) + 3), max(4, 0.3 * len(y_order) + 2))
fig, ax = plt.subplots(figsize=figsize)
x = df["pair"].map(xi).to_numpy()
y = df["interaction_name"].map(yi).to_numpy()
prob = df["prob"].to_numpy(dtype=float)
# dot size ~ significance (−log10 p), color ~ probability
size = 20 + 200 * (-np.log10(np.clip(df["pval"].to_numpy(dtype=float), 1e-4, 1)) / 4)
sc = ax.scatter(x, y, s=size, c=prob, cmap=cmap, edgecolors="gray", linewidths=0.3)
ax.set_xticks(range(len(x_order)))
ax.set_xticklabels(x_order, rotation=90, fontsize=8)
ax.set_yticks(range(len(y_order)))
ax.set_yticklabels(y_order, fontsize=8)
ax.set_xlabel("Source → Target")
fig.colorbar(sc, ax=ax, label="comm. prob")
if title:
ax.set_title(title)
ax.margins(0.05)
fig.tight_layout()
return fig
[docs]
def plot_chord(
mat: np.ndarray,
names: Sequence[str],
*,
ax: Optional[plt.Axes] = None,
figsize: tuple[float, float] = (7, 7),
gap: float = 0.02,
title: Optional[str] = None,
) -> plt.Figure:
"""Chord diagram of group→group communication (``netVisual_chord_cell``).
A lightweight matplotlib implementation: each cell group gets an arc on the
unit circle sized by its total interaction; ribbons connect senders to
receivers with width proportional to the communication weight.
"""
names = list(names)
_check_square(mat, names)
K = len(names)
if ax is None:
fig, ax = plt.subplots(figsize=figsize, subplot_kw={"aspect": "equal"})
else:
fig = ax.get_figure()
colors = _palette(K)
totals = mat.sum(axis=1) + mat.sum(axis=0)
total = totals.sum()
if total == 0:
ax.axis("off")
return fig
frac = totals / total
gaps = gap * 2 * np.pi
span = 2 * np.pi - K * gaps
starts = np.zeros(K)
acc = 0.0
arc = {}
for i in range(K):
a0 = acc
a1 = acc + frac[i] * span
arc[i] = (a0, a1)
acc = a1 + gaps
# draw the group arc
ts = np.linspace(a0, a1, 40)
ax.plot(np.cos(ts), np.sin(ts), lw=8, color=colors[i], solid_capstyle="butt")
mid = (a0 + a1) / 2
ax.text(1.18 * np.cos(mid), 1.18 * np.sin(mid), names[i],
ha="center", va="center", fontsize=10)
# ribbons: place each i→j chord at a sub-arc position within i and j
mx = mat.max() if mat.max() > 0 else 1.0
for i in range(K):
a0, a1 = arc[i]
cur = a0
w_i = mat[i].sum() + mat[:, i].sum()
for j in range(K):
w = mat[i, j]
if w <= 0:
continue
ai = (a0 + a1) / 2
aj = (arc[j][0] + arc[j][1]) / 2
p0 = np.array([np.cos(ai), np.sin(ai)]) * 0.98
p1 = np.array([np.cos(aj), np.sin(aj)]) * 0.98
arrow = FancyArrowPatch(
p0, p1, connectionstyle="arc3,rad=0.4",
arrowstyle="-", lw=0.5 + 6 * w / mx,
color=colors[i], alpha=0.45,
)
ax.add_patch(arrow)
ax.set_xlim(-1.4, 1.4)
ax.set_ylim(-1.4, 1.4)
ax.axis("off")
if title:
ax.set_title(title)
fig.tight_layout()
return fig
[docs]
def plot_rank_net(
ranking: pd.DataFrame,
*,
ax: Optional[plt.Axes] = None,
figsize: Optional[tuple[float, float]] = None,
color: str = "#4C72B0",
title: Optional[str] = None,
) -> plt.Figure:
"""Horizontal bar plot of pathway information flow (``rankNet``).
Parameters
----------
ranking
Output of :meth:`CellComm.rank_net` — columns ``pathway_name`` and
``flow``, already sorted descending.
"""
df = ranking.iloc[::-1] # so the largest ends up on top
if figsize is None:
figsize = (5, max(3, 0.3 * len(df) + 1))
if ax is None:
fig, ax = plt.subplots(figsize=figsize)
else:
fig = ax.get_figure()
ax.barh(df["pathway_name"].astype(str), df["flow"].to_numpy(dtype=float), color=color)
ax.set_xlabel("Information flow")
if title:
ax.set_title(title)
fig.tight_layout()
return fig
[docs]
def plot_signaling_role_heatmap(
role_matrix: pd.DataFrame,
*,
figsize: Optional[tuple[float, float]] = None,
cmap: str = "OrRd",
title: Optional[str] = None,
) -> plt.Figure:
"""Pathway × cell-group signaling-role heatmap (``netAnalysis_signalingRole_heatmap``).
Parameters
----------
role_matrix
Output of :func:`sceleto.interaction.signaling_role_pathway` — rows =
pathways, columns = cell groups.
"""
import seaborn as sns
if figsize is None:
figsize = (max(4, 0.4 * role_matrix.shape[1] + 2),
max(4, 0.3 * role_matrix.shape[0] + 1))
fig, ax = plt.subplots(figsize=figsize)
sns.heatmap(role_matrix, cmap=cmap, ax=ax, linewidths=0.3, linecolor="white",
cbar_kws={"label": "importance"})
ax.set_xlabel("Cell group")
ax.set_ylabel("Signaling pathway")
if title:
ax.set_title(title)
fig.tight_layout()
return fig
[docs]
def plot_contribution(
contrib: pd.DataFrame,
*,
ax: Optional[plt.Axes] = None,
figsize: Optional[tuple[float, float]] = None,
color: str = "#55A868",
title: Optional[str] = None,
) -> plt.Figure:
"""Bar plot of each L–R pair's contribution to a pathway (``netAnalysis_contribution``).
Parameters
----------
contrib
Output of :meth:`CellComm.contribution` — columns ``interaction_name``
and ``contribution``.
"""
df = contrib.iloc[::-1]
if figsize is None:
figsize = (5, max(2.5, 0.3 * len(df) + 1))
if ax is None:
fig, ax = plt.subplots(figsize=figsize)
else:
fig = ax.get_figure()
ax.barh(df["interaction_name"].astype(str),
df["contribution"].to_numpy(dtype=float), color=color)
ax.set_xlabel("Relative contribution")
if title:
ax.set_title(title)
fig.tight_layout()
return fig