Repository: chemosim-lab/ProLIF

Issue:
Include "barcode" plot in codebase
Include seaborn barcode plot from the docs. Add support for PPI as well (issue #78)

Discussion:

Patch:
diff --git a/.binder/environment.yml b/.binder/environment.yml
index 6faa4c6..db8e919 100644
--- a/.binder/environment.yml
+++ b/.binder/environment.yml
@@ -12,7 +12,7 @@ dependencies:
   - nbsphinx==0.8.7
   - networkx
   - recommonmark
-  - seaborn==0.11.0
+  - matplotlib==3.5.2
   - sphinx==4.1.2
   - tqdm
   - python==3.9.*
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ecbfdde..e3fb033 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## [Unreleased]
 
 ### Added
+- Added a `Barcode` class for plotting interactions. Added the
+  `Fingerprint.to_barcode_plot` method to generate the plot directly from an FP object.
 - Added a `count` argument in `Fingerprint`. If `count=True`, enumerates all groups of
   atoms that satisfy interaction constraints (instead of stopping at the first one),
   allowing users to generate a count-fingerprint. The `Fingerprint.to_dataframe` method
@@ -50,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   atom indices.
 - All interaction classes now return more complete details about the interaction (e.g.
   distances, angles, atom indices in the residue and parent molecule).
+- Changed the default color for `VanDerWaals` interactions in the builtin plots.
 - Converting the IFP to a dataframe with atom indices has been optimized and now runs
   about 5 times faster (Issue #112, PR #113 by @ReneHamburger1993). *Note: discarded*
   *by the subsequent updates to the codebase which removed the ability to have*
diff --git a/docs/notebooks/protein-protein_interactions.ipynb b/docs/notebooks/protein-protein_interactions.ipynb
index 7806991..d79cee6 100644
--- a/docs/notebooks/protein-protein_interactions.ipynb
+++ b/docs/notebooks/protein-protein_interactions.ipynb
@@ -131,6 +131,22 @@
     "g.loc[g > 0.3]"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Note: Add `%matplotlib ipympl` at the top of the cell for an interactive visualization:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "fp.to_barcode_plot()"
+   ]
+  },
   {
    "cell_type": "markdown",
    "metadata": {},
@@ -194,7 +210,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.5"
+   "version": "3.9.13"
   },
   "toc": {
    "base_numbering": 1,
diff --git a/docs/notebooks/quickstart.ipynb b/docs/notebooks/quickstart.ipynb
index b78e4b2..6337aae 100644
--- a/docs/notebooks/quickstart.ipynb
+++ b/docs/notebooks/quickstart.ipynb
@@ -25,7 +25,7 @@
     "u = mda.Universe(plf.datafiles.TOP, plf.datafiles.TRAJ)\n",
     "# create selections for the ligand and protein\n",
     "lig = u.atoms.select_atoms(\"resname LIG\")\n",
-    "prot = u.atoms.select_atoms(\"protein\")\n",
+    "prot = u.atoms.select_atoms(\"protein and byres around 20 group ligand\", ligand=lig)\n",
     "lig, prot"
    ]
   },
@@ -225,7 +225,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Here's a simple example to plot the interactions over time"
+    "Here's a simple example to plot the interactions over time. Add `%matplotlib ipympl` at the top of the cell for an interactive visualization:"
    ]
   },
   {
@@ -234,49 +234,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "import seaborn as sns\n",
-    "import pandas as pd\n",
-    "\n",
-    "# reorganize data\n",
-    "data = df.reset_index()\n",
-    "data = pd.melt(data, id_vars=[\"Frame\"], var_name=[\"residue\", \"interaction\"])\n",
-    "data = data[data[\"value\"] != False]\n",
-    "data.reset_index(inplace=True, drop=True)\n",
-    "\n",
-    "# plot\n",
-    "sns.set_theme(font_scale=0.8, style=\"white\", context=\"talk\")\n",
-    "g = sns.catplot(\n",
-    "    data=data,\n",
-    "    x=\"interaction\",\n",
-    "    y=\"Frame\",\n",
-    "    hue=\"interaction\",\n",
-    "    col=\"residue\",\n",
-    "    hue_order=[\n",
-    "        \"Hydrophobic\",\n",
-    "        \"HBDonor\",\n",
-    "        \"HBAcceptor\",\n",
-    "        \"PiStacking\",\n",
-    "        \"CationPi\",\n",
-    "        \"Cationic\",\n",
-    "    ],\n",
-    "    height=3,\n",
-    "    aspect=0.2,\n",
-    "    jitter=0,\n",
-    "    sharex=False,\n",
-    "    marker=\"_\",\n",
-    "    s=8,\n",
-    "    linewidth=3.5,\n",
-    ")\n",
-    "g.set_titles(\"{col_name}\")\n",
-    "g.set(xticks=[], ylim=(-0.5, data.Frame.max() + 1))\n",
-    "g.set_xticklabels([])\n",
-    "g.set_xlabels(\"\")\n",
-    "g.fig.subplots_adjust(wspace=0)\n",
-    "g.add_legend()\n",
-    "g.despine(bottom=True)\n",
-    "for ax in g.axes.flat:\n",
-    "    ax.invert_yaxis()\n",
-    "    ax.set_title(ax.get_title(), pad=15, rotation=60, ha=\"center\", va=\"baseline\")"
+    "fp.to_barcode_plot()"
    ]
   },
   {
diff --git a/docs/notebooks/visualisation.ipynb b/docs/notebooks/visualisation.ipynb
index d93b76f..4598600 100644
--- a/docs/notebooks/visualisation.ipynb
+++ b/docs/notebooks/visualisation.ipynb
@@ -252,13 +252,12 @@
    "outputs": [],
    "source": [
     "# display\n",
-    "net = fp.to_ligplot(\n",
+    "fp.to_ligplot(\n",
     "    lmol,\n",
     "    # replace with `kind=\"frame\", frame=0,` for the other depiction\n",
     "    kind=\"aggregate\",\n",
     "    threshold=0.3,\n",
-    ")\n",
-    "net.display()"
+    ")"
    ]
   },
   {
@@ -468,7 +467,7 @@
     "tm3 = u.select_atoms(\"resid 119:152\")\n",
     "prot = u.select_atoms(\"protein and not group tm3\", tm3=tm3)\n",
     "fp = plf.Fingerprint()\n",
-    "fp.run(u.trajectory[::10], tm3, prot)\n",
+    "fp.run(u.trajectory[::25], tm3, prot)\n",
     "df = fp.to_dataframe()\n",
     "df.head()"
    ]
@@ -533,7 +532,7 @@
     "        \"PiCation\",\n",
     "    ]\n",
     ")\n",
-    "fp.run(u.trajectory[::10], prot, prot)\n",
+    "fp.run(u.trajectory[::25], prot, prot, n_jobs=1)\n",
     "df = fp.to_dataframe()\n",
     "df.head()"
    ]
diff --git a/docs/source/modules/plotting.rst b/docs/source/modules/plotting.rst
index fe8a9af..990f365 100644
--- a/docs/source/modules/plotting.rst
+++ b/docs/source/modules/plotting.rst
@@ -2,3 +2,5 @@ Plotting
 ========
 
 .. automodule:: prolif.plotting.network
+
+.. automodule:: prolif.plotting.barcode
\ No newline at end of file
diff --git a/prolif/exceptions.py b/prolif/exceptions.py
new file mode 100644
index 0000000..893028f
--- /dev/null
+++ b/prolif/exceptions.py
@@ -0,0 +1,2 @@
+class RunRequiredError(RuntimeError):
+    pass
diff --git a/prolif/fingerprint.py b/prolif/fingerprint.py
index 43c4a9f..ee0a159 100644
--- a/prolif/fingerprint.py
+++ b/prolif/fingerprint.py
@@ -26,6 +26,7 @@ Calculate a Protein-Ligand Interaction Fingerprint --- :mod:`prolif.fingerprint`
 """
 from collections.abc import Sized
 from functools import wraps
+from typing import Literal, Optional, Tuple
 
 import dill
 import multiprocess as mp
@@ -37,6 +38,7 @@ from prolif.ifp import IFP
 from prolif.interactions.base import _BASE_INTERACTIONS, _INTERACTIONS
 from prolif.molecule import Molecule
 from prolif.parallel import MolIterablePool, TrajectoryPool
+from prolif.plotting.utils import IS_NOTEBOOK
 from prolif.utils import (
     get_residues_near_ligand,
     to_bitvectors,
@@ -375,6 +377,7 @@ class Fingerprint:
         traj,
         lig,
         prot,
+        *,
         residues=None,
         converter_kwargs=None,
         progress=True,
@@ -520,7 +523,7 @@ class Fingerprint:
         return self
 
     def run_from_iterable(
-        self, lig_iterable, prot_mol, residues=None, progress=True, n_jobs=None
+        self, lig_iterable, prot_mol, *, residues=None, progress=True, n_jobs=None
     ):
         """Generates the fingerprint between a list of ligands and a protein
 
@@ -628,7 +631,9 @@ class Fingerprint:
         self.ifp = ifp
         return self
 
-    def to_dataframe(self, count=None, dtype=None, drop_empty=True, index_col="Frame"):
+    def to_dataframe(
+        self, *, count=None, dtype=None, drop_empty=True, index_col="Frame"
+    ):
         """Converts fingerprints to a pandas DataFrame
 
         Parameters
@@ -812,46 +817,137 @@ class Fingerprint:
     def to_ligplot(
         self,
         ligand_mol,
-        kind="aggregate",
-        frame=0,
-        display_all=False,
-        threshold=0.3,
-        **kwargs,
+        *,
+        kind: Literal["aggregate", "frame"] = "aggregate",
+        frame: int = 0,
+        display_all: bool = False,
+        threshold: float = 0.3,
+        use_coordinates: bool = False,
+        flatten_coordinates: bool = True,
+        kekulize: bool = False,
+        molsize: int = 35,
+        rotation: float = 0,
+        carbon: float = 0.16,
+        width: str = "100%",
+        height: str = "500px",
     ):
-        """Generate a :class:`~prolif.plotting.network.LigNetwork` plot from a
-        fingerprint object that has been executed.
+        """Generate and display a :class:`~prolif.plotting.network.LigNetwork` plot from
+        a fingerprint object that has been used to run an analysis.
 
         Parameters
         ----------
         ligand_mol : rdkit.Chem.rdChem.Mol
-            Ligand molecule
+            Ligand molecule.
         kind : str
-            One of ``"aggregate"`` or ``"frame"``
+            One of ``"aggregate"`` or ``"frame"``.
         frame : int
             Frame number (see :attr:`~prolif.fingerprint.Fingerprint.ifp`). Only
-            applicable for ``kind="frame"``
+            applicable for ``kind="frame"``.
         display_all : bool
             Display all occurences for a given pair of residues and interaction, or only
             the shortest one. Only applicable for ``kind="frame"``. Not relevant if
             ``count=False`` in the ``Fingerprint`` object.
         threshold : float
             Frequency threshold, between 0 and 1. Only applicable for
-            ``kind="aggregate"``
-        kwargs : object
-            Other arguments passed to the :class:`LigNetwork` class
+            ``kind="aggregate"``.
+        use_coordinates : bool
+            If ``True``, uses the coordinates of the molecule directly, otherwise generates
+            2D coordinates from scratch. See also ``flatten_coordinates``.
+        flatten_coordinates : bool
+            If this is ``True`` and ``use_coordinates=True``, generates 2D coordinates that
+            are constrained to fit the 3D conformation of the ligand as best as possible.
+        kekulize : bool
+            Kekulize the ligand.
+        molsize : int
+            Multiply the coordinates by this number to create a bigger and
+            more readable depiction.
+        rotation : int
+            Rotate the structure on the XY plane.
+        carbon : float
+            Size of the carbon atom dots on the depiction. Use `0` to hide the
+            carbon dots.
+        width : str
+            Width of the IFrame window.
+        height : str
+            Height of the IFrame window.
+
+        See Also
+        --------
+        :class:`prolif.plotting.network.LigNetwork`
 
         .. versionadded:: 2.0.0
         """
         from prolif.plotting.network import LigNetwork
 
-        if hasattr(self, "ifp"):
-            return LigNetwork.from_fingerprint(
-                fp=self,
-                ligand_mol=ligand_mol,
-                kind=kind,
-                frame=frame,
-                display_all=display_all,
-                threshold=threshold,
-                **kwargs,
-            )
-        raise AttributeError("Please use the `run` method before")
+        ligplot = LigNetwork.from_fingerprint(
+            fp=self,
+            ligand_mol=ligand_mol,
+            kind=kind,
+            frame=frame,
+            display_all=display_all,
+            threshold=threshold,
+            use_coordinates=use_coordinates,
+            flatten_coordinates=flatten_coordinates,
+            kekulize=kekulize,
+            molsize=molsize,
+            rotation=rotation,
+            carbon=carbon,
+        )
+        return ligplot.display(width=width, height=height)
+
+    def to_barcode_plot(
+        self,
+        *,
+        figsize: Tuple[int, int] = (8, 10),
+        dpi: int = 100,
+        interactive: bool = IS_NOTEBOOK,
+        n_frame_ticks: int = 10,
+        residues_tick_location: Literal["top", "bottom"] = "top",
+        xlabel: str = "Frame",
+        subplots_kwargs: Optional[dict] = None,
+        tight_layout_kwargs: Optional[dict] = None,
+    ):
+        """Generate and display a :class:`~prolif.plotting.barcode.Barcode` plot from
+        a fingerprint object that has been used to run an analysis.
+
+        Parameters
+        ----------
+        figsize: Tuple[int, int] = (8, 10)
+            Size of the matplotlib figure.
+        dpi: int = 100
+            DPI used for the matplotlib figure.
+        interactive: bool
+            Add hover interactivity to the plot (only relevant for notebooks). You may
+            need to add ``%matplotlib notebook`` or ``%matplotlib ipympl`` for it to
+            work as expected.
+        n_frame_ticks: int = 10
+            Number of ticks on the X axis. May use ±1 tick to have them evenly spaced.
+        residues_tick_location: Literal["top", "bottom"] = "top"
+            Whether the Y ticks appear at the top or at the bottom of the series of
+            interactions of each residue.
+        xlabel: str = "Frame"
+            Label displayed for the X axis.
+        subplots_kwargs: Optional[dict] = None
+            Other parameters passed to :func:`matplotlib.pyplot.subplots`.
+        tight_layout_kwargs: Optional[dict] = None
+            Other parameters passed to :meth:`matplotlib.figure.Figure.tight_layout`.
+
+        See Also
+        --------
+        :class:`prolif.plotting.barcode.Barcode`
+
+        .. versionadded:: 2.0.0
+        """
+        from prolif.plotting.barcode import Barcode
+
+        barcode = Barcode.from_fingerprint(self)
+        return barcode.display(
+            figsize=figsize,
+            dpi=dpi,
+            interactive=interactive,
+            n_frame_ticks=n_frame_ticks,
+            residues_tick_location=residues_tick_location,
+            xlabel=xlabel,
+            subplots_kwargs=subplots_kwargs,
+            tight_layout_kwargs=tight_layout_kwargs,
+        )
diff --git a/prolif/plotting/barcode.py b/prolif/plotting/barcode.py
new file mode 100644
index 0000000..dde1c6b
--- /dev/null
+++ b/prolif/plotting/barcode.py
@@ -0,0 +1,242 @@
+"""
+Plot interactions as a barcode --- :mod:`prolif.plotting.barcode`
+=================================================================
+
+.. versionadded:: 2.0.0
+
+.. autoclass:: Barcode
+   :members:
+
+"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, ClassVar, Dict, List, Literal, Optional, Tuple
+
+import numpy as np
+import pandas as pd
+from matplotlib import pyplot as plt
+from matplotlib.colors import ListedColormap
+from matplotlib.patches import Patch
+
+from prolif.exceptions import RunRequiredError
+from prolif.plotting.utils import IS_NOTEBOOK, separated_interaction_colors
+
+if TYPE_CHECKING:
+    from prolif.fingerprint import Fingerprint
+
+
+class Barcode:
+    """Creates a barcode plot of interactions.
+
+    Parameters
+    ----------
+    df: pandas.DataFrame
+        The DataFrame as obtained from :meth:`Fingerprint.to_dataframe()`
+
+    Attributes
+    ----------
+    COLORS : dict
+        Dictionnary of colors used in the plot for interactions.
+
+    """
+
+    COLORS: ClassVar[Dict[Optional[str], str]] = {
+        None: "white",
+        **separated_interaction_colors,
+    }
+
+    def __init__(self, df: pd.DataFrame) -> None:
+        # mapping interaction type (HBond...etc.) to an arbitrary value which
+        # corresponds to a color
+        self.color_mapper = {
+            interaction: value for value, interaction in enumerate(self.COLORS)
+        }
+        # reverse: map value to interaction type
+        self.inv_color_mapper = {
+            value: interaction for interaction, value in self.color_mapper.items()
+        }
+        # matplotlib colormap
+        self.cmap = ListedColormap(list(self.COLORS.values()))
+
+        # drop ligand level if single residue
+        # else concatenate ligand with protein and drop ligand if peptide
+        n_ligand_residues = len(np.unique(df.columns.get_level_values("ligand")))
+        if n_ligand_residues == 1:
+            df = df.droplevel("ligand", axis=1)
+        else:
+            df.columns = pd.MultiIndex.from_tuples(
+                [(f"{items[0]}-{items[1]}", items[2]) for items in df.columns],
+                names=["protein", "interaction"],
+            )
+
+        def _bit_to_color_value(s: pd.Series) -> pd.Series:
+            """Replaces a bit value with it's corresponding color value"""
+            interaction = s.name[-1]
+            return s.apply(
+                lambda v: self.color_mapper[interaction]
+                if v
+                else self.color_mapper[None]
+            )
+
+        self.df = df.astype(np.uint8).T.apply(_bit_to_color_value, axis=1)
+
+    @classmethod
+    def from_fingerprint(cls, fp: Fingerprint) -> Barcode:
+        """Creates a barcode object from a fingerprint."""
+        if not hasattr(fp, "ifp"):
+            raise RunRequiredError(
+                "Please run the fingerprint analysis before attempting to display results."
+            )
+        return cls(fp.to_dataframe())
+
+    def display(
+        self,
+        figsize: Tuple[int, int] = (8, 10),
+        dpi: int = 100,
+        interactive: bool = IS_NOTEBOOK,
+        n_frame_ticks: int = 10,
+        residues_tick_location: Literal["top", "bottom"] = "top",
+        xlabel: str = "Frame",
+        subplots_kwargs: Optional[dict] = None,
+        tight_layout_kwargs: Optional[dict] = None,
+    ):
+        """Generate and display the barcode plot.
+
+        Parameters
+        ----------
+        figsize: Tuple[int, int] = (8, 10)
+            Size of the matplotlib figure.
+        dpi: int = 100
+            DPI used for the matplotlib figure.
+        interactive: bool
+            Add hover interactivity to the plot (only relevant for notebooks). You may
+            need to add ``%matplotlib notebook`` or ``%matplotlib ipympl`` for it to
+            work as expected.
+        n_frame_ticks: int = 10
+            Number of ticks on the X axis. May use ±1 tick to have them evenly spaced.
+        residues_tick_location: Literal["top", "bottom"] = "top"
+            Whether the Y ticks appear at the top or at the bottom of the series of
+            interactions of each residue.
+        xlabel: str = "Frame"
+            Label displayed for the X axis.
+        subplots_kwargs: Optional[dict] = None
+            Other parameters passed to :func:`matplotlib.pyplot.subplots`.
+        tight_layout_kwargs: Optional[dict] = None
+            Other parameters passed to :meth:`matplotlib.figure.Figure.tight_layout`.
+        """
+        if subplots_kwargs is None:
+            subplots_kwargs = {}
+        subplots_kwargs.setdefault("figsize", figsize)
+        subplots_kwargs.setdefault("dpi", dpi)
+
+        if tight_layout_kwargs is None:
+            tight_layout_kwargs = {}
+        tight_layout_kwargs.setdefault("pad", 1.2)
+
+        # Plot as image
+        fig, ax = plt.subplots(**subplots_kwargs)
+        ax: plt.Axes
+        im = ax.imshow(
+            self.df.values,
+            aspect="auto",
+            cmap=self.cmap,
+            vmin=0,
+            vmax=max(self.color_mapper.values()),
+        )
+
+        # Frame ticks
+        frames = self.df.columns
+        max_ticks = len(frames) - 1
+        # try to have evenly spaced ticks
+        for effective_n_ticks in (n_frame_ticks, n_frame_ticks - 1, n_frame_ticks + 1):
+            samples, step = np.linspace(0, max_ticks, effective_n_ticks, retstep=True)
+            if step.is_integer():
+                break
+        else:
+            samples = np.linspace(0, max_ticks, n_frame_ticks)
+        indices = np.round(samples).astype(int)
+        ax.xaxis.set_ticks(indices, frames[indices])
+        ax.set_xlabel(xlabel)
+
+        # Residues ticks
+        n_items = len(self.df.index)
+        residues = self.df.index.get_level_values("protein")
+        interactions = self.df.index.get_level_values("interaction")
+        if residues_tick_location == "top":
+            indices = [
+                i
+                for i in range(n_items)
+                if (i - 1 > 0 and residues[i - 1] != residues[i]) or i == 0
+            ]
+        else:
+            indices = [
+                i
+                for i in range(n_items)
+                if (i + 1 < n_items and residues[i + 1] != residues[i])
+                or i + 1 == n_items
+            ]
+        ax.yaxis.set_ticks(indices, residues[indices])
+
+        # legend
+        values: List[int] = np.unique(self.df.values).tolist()
+        values.pop(values.index(0))  # remove None color
+        legend_colors = {
+            self.inv_color_mapper[value]: im.cmap(value) for value in values
+        }
+        patches = [
+            Patch(color=color, label=interaction)
+            for interaction, color in legend_colors.items()
+        ]
+        ax.legend(handles=patches, bbox_to_anchor=(1.01, 1), loc=2, borderaxespad=0)
+
+        # interactive
+        if interactive:
+            self._add_interaction_callback(
+                fig,
+                ax,
+                im=im,
+                frames=frames,
+                residues=residues,
+                interactions=interactions,
+            )
+
+        fig.tight_layout(**tight_layout_kwargs)
+        return ax
+
+    def _add_interaction_callback(self, fig, ax, *, im, frames, residues, interactions):
+        annot = ax.annotate(
+            "",
+            xy=(0, 0),
+            xytext=(5, 5),
+            textcoords="offset points",
+            alpha=0.8,
+            bbox={"boxstyle": "round", "facecolor": "w"},
+            wrap=True,
+        )
+        annot.set_visible(False)
+
+        def hover_callback(event):
+            if (
+                event.inaxes is ax
+                and event.xdata is not None
+                and event.ydata is not None
+            ):
+                x, y = round(event.xdata), round(event.ydata)
+                if self.df.values[y, x]:
+                    annot.xy = (x, y)
+                    frame = frames[x]
+                    interaction = interactions[y]
+                    residue = residues[y]
+                    annot.set_text(f"Frame {frame}: {residue}")
+                    color = im.cmap(self.color_mapper[interaction])
+                    annot.get_bbox_patch().set_facecolor(color)
+                    annot.set_visible(True)
+                    fig.canvas.draw_idle()
+                    return
+            if annot.get_visible():
+                annot.set_visible(False)
+                fig.canvas.draw_idle()
+
+        fig.canvas.mpl_connect("motion_notify_event", hover_callback)
+        fig.canvas.header_visible = False
+        fig.canvas.footer_visible = False
diff --git a/prolif/plotting/network.py b/prolif/plotting/network.py
index 7b686fe..2230693 100644
--- a/prolif/plotting/network.py
+++ b/prolif/plotting/network.py
@@ -25,6 +25,8 @@ import pandas as pd
 from rdkit import Chem
 from rdkit.Chem import rdDepictor
 
+from prolif.exceptions import RunRequiredError
+from prolif.plotting.utils import grouped_interaction_colors
 from prolif.residue import ResidueId
 from prolif.utils import requires
 
@@ -90,25 +92,12 @@ class LigNetwork:
         works without requiring a dataframe with atom indices. Replaced ``match3D``
         parameter with ``use_coordinates`` and ``flatten_coordinates`` to give users
         more control and allow them to provide their own 2D coordinates. Added support
-        for displaying peptides as the "ligand".
+        for displaying peptides as the "ligand". Changed the default color for
+        VanDerWaals.
     """
 
     COLORS = {
-        "interactions": {
-            "Hydrophobic": "#59e382",
-            "HBAcceptor": "#59bee3",
-            "HBDonor": "#59bee3",
-            "XBAcceptor": "#59bee3",
-            "XBDonor": "#59bee3",
-            "Cationic": "#e35959",
-            "Anionic": "#5979e3",
-            "CationPi": "#e359d8",
-            "PiCation": "#e359d8",
-            "PiStacking": "#b559e3",
-            "EdgeToFace": "#b559e3",
-            "FaceToFace": "#b559e3",
-            "VdWContact": "#59e3ad",
-        },
+        "interactions": {**grouped_interaction_colors},
         "atoms": {
             "C": "black",
             "N": "blue",
@@ -327,8 +316,8 @@ class LigNetwork:
             Added the ``display_all`` parameter.
         """
         if not hasattr(fp, "ifp"):
-            raise AttributeError(
-                "Please run the interaction fingerprint analysis before plotting."
+            raise RunRequiredError(
+                "Please run the fingerprint analysis before attempting to display results."
             )
         if kind == "frame":
             df = cls._make_frame_df_from_fp(fp, frame=frame, display_all=display_all)
diff --git a/prolif/plotting/utils.py b/prolif/plotting/utils.py
new file mode 100644
index 0000000..0e44bb5
--- /dev/null
+++ b/prolif/plotting/utils.py
@@ -0,0 +1,39 @@
+import builtins
+
+IS_NOTEBOOK = hasattr(builtins, "__IPYTHON__")
+
+separated_interaction_colors = {
+    "Hydrophobic": "#59e382",
+    "VdWContact": "#dfab43",
+    "HBAcceptor": "#59bee3",
+    "HBDonor": "#239fcd",
+    "XBAcceptor": "#ff9f02",
+    "XBDonor": "#ce8000",
+    "Cationic": "#e35959",
+    "Anionic": "#5979e3",
+    "CationPi": "#e359d8",
+    "PiCation": "#ea85e2",
+    "PiStacking": "#b559e3",
+    "EdgeToFace": "#c885ea",
+    "FaceToFace": "#a22ddc",
+    "MetalAcceptor": "#7da982",
+    "MetalDonor": "#609267",
+}
+
+grouped_interaction_colors = {
+    "Hydrophobic": "#59e382",
+    "VdWContact": "#dfab43",
+    "HBAcceptor": "#59bee3",
+    "HBDonor": "#59bee3",
+    "XBAcceptor": "#ff9f02",
+    "XBDonor": "#ff9f02",
+    "Cationic": "#e35959",
+    "Anionic": "#5979e3",
+    "CationPi": "#e359d8",
+    "PiCation": "#e359d8",
+    "PiStacking": "#b559e3",
+    "EdgeToFace": "#b559e3",
+    "FaceToFace": "#b559e3",
+    "MetalAcceptor": "#7da982",
+    "MetalDonor": "#7da982",
+}
diff --git a/pyproject.toml b/pyproject.toml
index 85a277b..d0c8343 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -48,6 +48,7 @@ build = [
 tests = [
     "pytest>=6.1.2",
     "pytest-cov",
+    "ipython",
 ]
 dev = [
     "prolif[build,tests]",


Test patch:
diff --git a/tests/plotting/test_barcode.py b/tests/plotting/test_barcode.py
new file mode 100644
index 0000000..dc6b68e
--- /dev/null
+++ b/tests/plotting/test_barcode.py
@@ -0,0 +1,70 @@
+import MDAnalysis as mda
+import pytest
+from matplotlib import pyplot as plt
+
+import prolif as plf
+from prolif.exceptions import RunRequiredError
+from prolif.plotting.barcode import Barcode
+
+
+class TestBarcode:
+    @pytest.fixture(scope="class")
+    def simple_fp(self) -> plf.Fingerprint:
+        return plf.Fingerprint(count=False)
+
+    @pytest.fixture(scope="class")
+    def count_fp(self) -> plf.Fingerprint:
+        return plf.Fingerprint(count=True)
+
+    @pytest.fixture(scope="class", params=["simple_fp", "count_fp"])
+    def fp(self, request: pytest.FixtureRequest) -> plf.Fingerprint:
+        return request.getfixturevalue(request.param)
+
+    @pytest.fixture(scope="class")
+    def fp_run(
+        self, u: mda.Universe, ligand_ag, protein_ag, fp: plf.Fingerprint
+    ) -> plf.Fingerprint:
+        fp.run(u.trajectory[0:2], ligand_ag, protein_ag)
+        return fp
+
+    @pytest.fixture(scope="class")
+    def barcode(self, fp_run: plf.Fingerprint) -> Barcode:
+        return Barcode.from_fingerprint(fp_run)
+
+    def test_display(self, barcode: Barcode) -> None:
+        ax = barcode.display()
+        assert isinstance(ax, plt.Axes)
+
+    def test_display_kwargs(self, barcode: Barcode) -> None:
+        ax = barcode.display(
+            figsize=(1, 2),
+            dpi=200,
+            interactive=True,
+            n_frame_ticks=2,
+            residues_tick_location="bottom",
+            xlabel="foobar",
+            subplots_kwargs={},
+            tight_layout_kwargs={"pad": 2},
+        )
+        assert isinstance(ax, plt.Axes)
+
+    def test_fp_to_barcode_plot(self, fp_run: plf.Fingerprint) -> None:
+        ax = fp_run.to_barcode_plot(
+            figsize=(1, 2),
+            dpi=200,
+            interactive=True,
+            n_frame_ticks=2,
+            residues_tick_location="bottom",
+            xlabel="foobar",
+            subplots_kwargs={},
+            tight_layout_kwargs={"pad": 2},
+        )
+        assert isinstance(ax, plt.Axes)
+
+    def test_from_fingerprint_raises_not_executed(self) -> None:
+        fp = plf.Fingerprint()
+        with pytest.raises(
+            RunRequiredError,
+            match="Please run the fingerprint analysis before attempting to display results",
+        ):
+            Barcode.from_fingerprint(fp)
diff --git a/tests/plotting/test_network.py b/tests/plotting/test_network.py
index 68d9e2e..dead134 100644
--- a/tests/plotting/test_network.py
+++ b/tests/plotting/test_network.py
@@ -5,6 +5,7 @@ import MDAnalysis as mda
 import pytest
 
 import prolif as plf
+from prolif.exceptions import RunRequiredError
 from prolif.plotting.network import LigNetwork
 
 
@@ -33,11 +34,13 @@ class TestLigNetwork:
     @pytest.fixture(scope="class")
     def get_ligplot(self, fp_mol):
         fp, lig_mol = fp_mol
-        return partial(fp.to_ligplot, lig_mol)
+        return partial(LigNetwork.from_fingerprint, fp, lig_mol)
 
     def test_integration_frame(self, fp_mol):
         fp, lig_mol = fp_mol
-        net = fp.to_ligplot(lig_mol, kind="frame", frame=0, display_all=fp.count)
+        net = LigNetwork.from_fingerprint(
+            fp, lig_mol, kind="frame", frame=0, display_all=fp.count
+        )
         with StringIO() as buffer:
             net.save(buffer)
             buffer.seek(0)
@@ -82,7 +85,12 @@ class TestLigNetwork:
     def test_from_fingerprint_raises_not_executed(self, ligand_mol):
         fp = plf.Fingerprint()
         with pytest.raises(
-            AttributeError,
-            match="Please run the interaction fingerprint analysis before plotting",
+            RunRequiredError,
+            match="Please run the fingerprint analysis before attempting to display results",
         ):
             LigNetwork.from_fingerprint(fp, ligand_mol)
+
+    def test_fp_to_ligplot(self, fp_mol):
+        fp, lig_mol = fp_mol
+        html = fp.to_ligplot(lig_mol, kind="frame", frame=0, display_all=fp.count)
+        assert "<iframe" in html.data