diff --git a/pyopenms_viz/_bokeh/core.py b/pyopenms_viz/_bokeh/core.py index 0450d71f..958ee05a 100644 --- a/pyopenms_viz/_bokeh/core.py +++ b/pyopenms_viz/_bokeh/core.py @@ -545,7 +545,11 @@ class BOKEHSpectrumPlot(BOKEH_MSPlot, SpectrumPlot): Class for assembling a Bokeh spectrum plot """ - pass + def plot_peptide_sequence(self, peptide_sequence: str, matched_fragments=None): + """ + Raises a NotImplementedError because peptide sequence plotting is not supported for Bokeh. + """ + raise NotImplementedError("Peptide sequence plotting is currently unsupported in the Bokeh backend.") class BOKEHPeakMapPlot(BOKEH_MSPlot, PeakMapPlot): diff --git a/pyopenms_viz/_config.py b/pyopenms_viz/_config.py index 0b79487c..d1e78136 100644 --- a/pyopenms_viz/_config.py +++ b/pyopenms_viz/_config.py @@ -362,6 +362,12 @@ class SpectrumConfig(VLineConfig): mirror_spectrum: bool = False peak_color: str | None = None + # New fields for peptide sequence plotting + display_peptide_sequence: bool = False + peptide_sequence: str = "" + matched_fragments: list[tuple] = field(default_factory=list) + + # Binning settings bin_peaks: Union[Literal["auto"], bool] = False bin_method: Literal["none", "sturges", "freedman-diaconis", "mz-tol-bin"] = ( diff --git a/pyopenms_viz/_core.py b/pyopenms_viz/_core.py index 62d51c02..0f73064b 100644 --- a/pyopenms_viz/_core.py +++ b/pyopenms_viz/_core.py @@ -688,6 +688,14 @@ def __init__( super().__init__(data, **kwargs) self.plot() + + @abstractmethod + def plot_peptide_sequence(self, peptide_sequence: str, matched_fragments=None): + """ + Renders a peptide sequence annotation on the spectrum. + Must be implemented by each backend (e.g., matplotlib). + """ + pass def load_config(self, **kwargs): if self._config is None: @@ -796,6 +804,13 @@ def plot(self): self.canvas, ann_texts, ann_xs, ann_ys, ann_colors ) + # If config says display_sequence, call the abstract method + if self.display_peptide_sequence and self.peptide_sequence: + self.plot_peptide_sequence( + self.peptide_sequence, + matched_fragments=self.matched_fragments + ) + # Mirror spectrum if self.mirror_spectrum and self.reference_spectrum is not None: ## create a mirror spectrum diff --git a/pyopenms_viz/_matplotlib/core.py b/pyopenms_viz/_matplotlib/core.py index 11e7c737..928e1f94 100644 --- a/pyopenms_viz/_matplotlib/core.py +++ b/pyopenms_viz/_matplotlib/core.py @@ -583,7 +583,102 @@ class MATPLOTLIBSpectrumPlot(MATPLOTLIB_MSPlot, SpectrumPlot): Class for assembling a matplotlib spectrum plot """ - pass + def plot_peptide_sequence( + self, + peptide_sequence: str, + matched_fragments=None, + x: float = 0.5, # center horizontal position (axes fraction) + y: float = 0.95, # vertical position (axes fraction) + spacing: float = 0.05, # spacing between residues (axes fraction) + fontsize: int = 12, + fontsize_frag: int = 10, + frag_len: float = 0.05 # length for fragment lines (in axes fraction) + ): + """ + Plot peptide sequence with matched fragments indicated. + + The peptide is displayed in the top corner and the fragmentation pattern + is drawn as lines coming out from the letters -- using the dataframe columns + 'ion_annotation' and 'ion_color' (or 'color_annotation'). No extra arguments are needed. + """ + ax = self.ax + + # Compute starting x so that the sequence is centered at x. + n_residues = len(peptide_sequence) + start_x = x - n_residues * spacing / 2 + spacing / 2 + + # Draw each amino acid letter. + for i, aa in enumerate(peptide_sequence): + ax.text( + start_x + i * spacing, + y, + aa, + fontsize=fontsize, + ha="center", + va="center", + transform=ax.transAxes, + ) + + # If matched_fragments is not provided, try to extract them from self.data. + if matched_fragments is None and hasattr(self, "data"): + df = self.data + # Check for column "ion_annotation" and either "ion_color" or "color_annotation" + color_col = "ion_color" if "ion_color" in df.columns else "color_annotation" + if "ion_annotation" in df.columns and color_col in df.columns: + matched_fragments = [] + for _, row in df.iterrows(): + annot = str(row.get("ion_annotation", "")).strip() + frag_color = row.get(color_col, "blue") + # Ensure frag_color is a string; if not, fallback. + if not isinstance(frag_color, str): + frag_color = "blue" + # Extract numeric index from annotation (e.g. "a3+"). + if len(annot) > 1: + ion_type = annot[0].lower() + try: + ion_index = int(annot[1:].rstrip("+")) + except ValueError: + continue + frag_x = start_x + (ion_index - 1) * spacing + if ion_type in "abc": + y_offset = frag_len + elif ion_type in "xyz": + y_offset = -frag_len + else: + y_offset = 0 + matched_fragments.append((annot, frag_color, frag_x, y_offset)) + # Draw the fragments if any. + if matched_fragments: + for frag in matched_fragments: + if len(frag) == 2: + annot, frag_color = frag + frag_x = x + y_offset = frag_len + elif len(frag) >= 4: + annot, frag_color, frag_x, y_offset = frag + else: + continue + + if not isinstance(frag_color, str): + frag_color = "blue" + + ax.plot( + [frag_x, frag_x], + [y, y + y_offset], + clip_on=False, + color=frag_color, + transform=ax.transAxes, + ) + ax.text( + frag_x, + y + (y_offset * 1.1), + annot, + color=frag_color, + fontsize=fontsize_frag, + ha="center", + va="bottom" if y_offset >= 0 else "top", + transform=ax.transAxes, + ) class MATPLOTLIBPeakMapPlot(MATPLOTLIB_MSPlot, PeakMapPlot): diff --git a/pyopenms_viz/_plotly/core.py b/pyopenms_viz/_plotly/core.py index b106a044..0c3618b0 100644 --- a/pyopenms_viz/_plotly/core.py +++ b/pyopenms_viz/_plotly/core.py @@ -619,6 +619,12 @@ def _prepare_data(self, df, label_suffix=" (ref)"): self.reference_spectrum[self.by] + label_suffix ) return df + + def plot_peptide_sequence(self, peptide_sequence: str, matched_fragments=None): + """ + Raises a NotImplementedError because peptide sequence plotting is not supported for Plotly. + """ + raise NotImplementedError("Peptide sequence plotting is currently unsupported in the Plotly backend.") class PLOTLYPeakMapPlot(PLOTLY_MSPlot, PeakMapPlot): diff --git a/test/__snapshots__/test_peakmap_marginals/test_peakmap_marginals[ms_bokeh].html b/test/__snapshots__/test_peakmap_marginals/test_peakmap_marginals[ms_bokeh].html index 112f1fa6..faeacc5a 100644 --- a/test/__snapshots__/test_peakmap_marginals/test_peakmap_marginals[ms_bokeh].html +++ b/test/__snapshots__/test_peakmap_marginals/test_peakmap_marginals[ms_bokeh].html @@ -18,10 +18,10 @@
- + - - + -