Skip to content

Visualization

spxtacular uses Plotly for interactive HTML visualizations. Every function on this page returns a plotly.graph_objects.Figure object. Plotly is a required dependency of spxtacular (as is pandas, which backs the plot-table API), so nothing extra needs installing.

How these plots are built

A few conventions apply to every figure on this page. They are worth knowing up front, because they explain defaults that would otherwise look surprising.

Relative intensity is the default y-axis. Peaks are scaled so the base peak is 100%, matching the convention every MS viewer uses. Raw counts are still available with intensity_scale="absolute", and the tooltip always reports the true intensity either way — rescaling changes the axis, never the number you are told. For data spanning orders of magnitude, intensity_transform="sqrt" or "log" compresses the range so low-abundance matched ions stay visible beside a dominant base peak.

Labels are vertical, and deliberately sparse. Direct labels are rotated to read bottom-to-top, the spectrum-viewer convention — a horizontal label occupies its full text width, so neighbours collide almost immediately, while a rotated one takes about one line-height and several times as many peaks can be labelled. Set label_angle to 0 in the plot table for horizontal labels.

They are still capped (max_labels, default 60) and collision-avoided along the m/z axis, strongest peak first: labelling every annotated peak turns a deconvoluted spectrum into an unreadable smear. Nothing is lost — the dropped values remain in the hover text, in the plot table, and in table_view().

Hovering does not require precision. Every figure gets an m/z crosshair from the shared template. Sticks are only about 1.5 pixels wide, so plot_spectrum() and annotate_spectrum() additionally lay a transparent hit layer over the peak tips — being near a peak is enough. (The other plots hover on their traces directly, which is fine for bubbles and subplot panels.)

Profile spectra are drawn as a trace, not sticks. plot_spectrum() reads spectrum_type: profile data becomes a continuous line with a light fill so the peak shape — the only reason profile data exists — survives. Everything else stays a stick plot. Override with render=.

Colour is assigned by the job it does, not by taste — see Theme below.


plot_spectrum()

from spxtacular.visualization import plot_spectrum

fig = plot_spectrum(
    spectrum,
    title=None,                  # plot title
    color="charge",              # "charge" | "im" | None
    show_scores=True,            # annotate scored peaks with their score value
    max_labels=60,               # cap on direct labels; None for no cap
    theme_mode=None,             # "light" | "dark"; None uses the global default
    intensity_scale="relative",  # "relative" (base peak = 100%) | "absolute"
    intensity_transform=None,    # None | "sqrt" | "log"
    show_precursor=True,         # draw precursor m/z + isolation window on MSn
    **layout_kwargs,             # passed to fig.update_layout()
)
fig.show()

Draws a stick plot of any Spectrum. With color="charge" (default), sticks are coloured by charge state when charge is present, on an ordinal ramp running light to dark — charge is ordered, so you read 1+ < 2+ < 3+ out of the colour. Unassigned peaks (charge = -1) and decharged ones (charge = 0) are neutral grey. With color="im", sticks are coloured by ion mobility on a single-hue sequential scale (falls back to "charge" when no IM array is present). With color=None, every stick takes one colour. When iso_score is present and show_scores=True, score values label the strongest scored peaks.

On an MsnSpectrum carrying precursor information, the precursor m/z and its isolation window are drawn as recessive reference chrome behind the peaks. Pass show_precursor=False to suppress them.

The legacy show_charges=True/False keyword is still accepted as a deprecated alias mapping to color="charge" / color=None.

Spectrum.plot() is a convenience wrapper around this function:

spec.plot(title="My spectrum", color="charge").show()

Raw spectrum:

Deconvoluted spectrum (coloured by charge state):

Deconvoluted + filtered (score ≥ 0.5):


mirror_plot()

from spxtacular.visualization import mirror_plot

fig = mirror_plot(
    raw,                 # Spectrum -- drawn inverted below the x-axis
    deconvoluted,        # Spectrum -- drawn upright above the x-axis
    title=None,
    normalize=True,      # scale each half to its own maximum independently
    show_charges=True,   # colour the deconvoluted half by charge state
    show_scores=True,    # annotate deconvoluted peaks with iso_score
    max_labels=60,       # cap on score labels, strongest first
    theme_mode=None,     # "light" | "dark"
    **layout_kwargs,
)
fig.show()

Mirror plot for comparing a raw spectrum (inverted, below) against its deconvoluted counterpart (upright, above). Useful for visually confirming that isotope clusters have been correctly identified and scored. With show_charges=True (default) deconvoluted peaks are coloured by charge state, using the same ordinal ramp as plot_spectrum() so a spectrum keeps its colours when the two figures sit side by side; with show_scores=True the iso_score annotations appear above each cluster. Each half is normalised to its own maximum, but the hover reports the true intensity.

The second parameter is named deconvoluted — pass it by that name if you use keywords.

Example:

from spxtacular import Spectrum
from spxtacular.visualization import mirror_plot

decon = spec.deconvolute(charge_range=(1, 5), tolerance=10, tolerance_type="ppm")
fig = mirror_plot(spec, decon, title="Raw vs deconvoluted")
fig.show()

Raw vs deconvoluted:

Raw vs deconvoluted + filtered:

Neutral mass spectrum:

Neutral mass + filtered:


annotate_spectrum()

import peptacular as pt
from spxtacular.visualization import annotate_spectrum

fragments = pt.fragment("PEPTIDE", ion_types=("b", "y"), charges=(1, 2))
fig = annotate_spectrum(
    spectrum,
    fragments,
    tolerance=0.02,
    tolerance_type="da",         # or "ppm"
    title=None,
    peak_selection="closest",    # "closest" | "largest" | "all"
    include_sequence=False,
    max_labels=60,
    theme_mode=None,
    intensity_scale="relative",
    intensity_transform=None,
    texture=False,
    show_precursor=True,
    **layout_kwargs,
)
fig.show()

Draws the spectrum as a stick plot and overlays matched fragment ion labels, coloured by ion series. Unmatched peaks stay in a recessive grey, drawn thinner and dimmer, so the annotated peaks lead rather than competing with the context behind them.

Parameters:

Parameter Default Description
spectrum Spectrum to plot
fragments Iterable of fragment objects from peptacular
tolerance 0.02 Matching tolerance
tolerance_type "da" "da" or "ppm"
title None Plot title
peak_selection "closest" How to resolve multiple peaks within tolerance — "closest", "largest", or "all"
include_sequence False Embed the residue sequence in each label (e.g. b3{PEP})
max_labels 60 Cap on direct labels, strongest first; None for no cap
theme_mode None "light" or "dark"; None uses the global default
intensity_scale "relative" "relative" (base peak = 100%) or "absolute"
intensity_transform None None, "sqrt" or "log"
texture False Give each ion series its own dash pattern
show_precursor True Draw the precursor m/z and isolation window on an MsnSpectrum

When one peak matches several ions, its colour is chosen by the fixed ion-series order rather than by whichever fragment you happened to list first, so reordering your fragment list never repaints the plot.

Annotated spectrum:

Note

This figure and the mass-error, coverage and facet figures below are drawn from a simulated MS2 — this peptide's own fragments displaced by a few ppm, over a noise floor — so the annotations and mass errors are genuine. The raw, deconvolution and mirror figures above use a real spectrum, where no peptide is involved.


mass_error_plot()

from spxtacular.visualization import mass_error_plot

fig = mass_error_plot(
    spectrum,
    fragments,
    tolerance=0.02,
    tolerance_type="da",         # or "ppm"
    peak_selection="closest",    # "closest" | "largest" | "all"
    unit="ppm",                  # error units
    title=None,
    theme_mode=None,
    **layout_kwargs,
)
fig.show()

Bubble chart of fragment mass errors vs m/z. Each matched fragment is a bubble whose x-position is the observed m/z, y-position is the mass error (ppm or Da), and size is proportional to the peak intensity. Bubbles are coloured by ion series. Useful for spotting calibration drifts or systematic mass errors. Also available as Spectrum.mass_error_plot().

A well-calibrated instrument gives a cloud centred on zero and comfortably inside the search tolerance, as above. A cloud offset from zero means a systematic calibration error; one that fans out with m/z means the calibration is drifting across the mass range.


facet_plot()

from spxtacular.visualization import facet_plot

fig = facet_plot(
    spectrum,
    fragments=None,           # adds annotated panel + mass-error panel when provided
    mirror_spectrum=None,     # adds a mirror panel below when provided
    title=None,
    tolerance=0.02,
    tolerance_type="da",
    peak_selection="closest",
    include_sequence=False,
    unit="ppm",
    max_labels=60,
    theme_mode=None,
    **layout_kwargs,
)
fig.show()

Multi-panel plot combining (1) the annotated spectrum, (2) the mass-error bubble chart, and (3) a mirror spectrum — all on a shared m/z axis. Panels 2 and 3 are opt-in; supplying fragments enables the mass-error panel and the annotations, supplying mirror_spectrum enables the mirror. Also available as Spectrum.facet_plot().

The shared m/z axis is the point: zooming one panel zooms all three, so you can follow a single peak from its annotation, to its mass error, to its deconvoluted counterpart.


sequence_coverage_plot()

from spxtacular.visualization import sequence_coverage_plot

fig = sequence_coverage_plot(
    spectrum,
    "FDSFGDLSSASAIMGNPK",   # stripped residue sequence
    fragments,
    tolerance=5,
    tolerance_type="da",
    theme_mode=None,
    **layout_kwargs,
)
fig.show()

The coverage ladder: where along the peptide the evidence sits. An annotated spectrum tells you that peaks matched; this tells you which backbone bonds those matches actually confirm, which is what distinguishes a localised identification from one leaning on a single end of the molecule.

Residues run left to right. A tick above and to the left of a residue marks an N-terminal (a/b/c) fragment ending at that bond; a tick below and to the right marks a C-terminal (x/y/z) fragment starting there. A bond with ticks on both sides is confirmed from both directions. The title reports the fraction of backbone bonds covered.

Pass the stripped sequence — ProForma modification brackets are not rendered. An empty peptide raises ValueError.


Chromatograms and XICs

Everything above works on a single spectrum. These two work on a run — any iterable of spectra carrying retention times, which is what reader.ms1 yields.

with spx.Reader("run.d") as reader:
    spx.plot_chromatogram(reader.ms1).show()          # TIC, extracted for you
with spx.Reader("run.d") as reader:
    spx.plot_xic(reader.ms1, [599.3262, 599.8268, 600.3306], tolerance=20).show()

extract_chromatogram() and extract_xic() return Chromatogram objects if you want the numbers rather than a figure — each carries rt, intensity, apex_rt and total.

Two things shape the API:

Everything is one pass. A reader is expensive to walk (several seconds for a 65-frame timsTOF run) and reader.ms1 may be a generator that cannot be replayed. So extract_xic() takes a list of targets and extracts them all together — twenty traces cost one walk, not twenty.

Any m/z order is accepted. A timsTOF frame is ordered by ion-mobility scan and only sorted by m/z within each scan, so it is not globally sorted. Each frame is sorted once on arrival when needed, after which every target is a binary search.

Ion mobility

On mobility data an im_window is what makes a trace selective — two co-eluting species at the same m/z usually separate in mobility:

spx.plot_xic(reader.ms1, [599.3262], tolerance=20, im_window=(1.01, 1.11))

aggregate="sum" (default) totals the peaks in the window, the quantification convention; aggregate="max" takes the largest.


Profile spectra

A profile spectrum samples a continuous signal, so drawing each sample as its own stick from the baseline throws away the peak shape and triples the coordinate count. plot_spectrum() detects SpectrumType.PROFILE and draws a connected trace instead:

spec = Spectrum(mz=mz, intensity=intensity, spectrum_type=SpectrumType.PROFILE)
spec.plot()                      # continuous trace with a light fill
spec.plot(render="sticks")       # force the stick rendering

Thinning

Real profile scans run to hundreds of thousands of samples, well past what a screen can resolve or a browser can draw. Above max_points (default 4000) the trace is thinned by keeping the minimum and maximum of each bucket.

Warning

This matters more than it sounds. The obvious alternative — keeping every Nth sample — will step straight over the two or three samples that form a narrow peak, and that peak silently disappears from the plot. On a 200,000-sample test spectrum with 40 narrow peaks, every-Nth sampling preserved 0 of 40 apexes; min/max preserved 39 of 40, and the global maximum exactly. Pass max_points=None to draw every sample.

Checking centroiding

profile_centroid_plot() puts the centroided peaks on top of the profile trace, which is how you confirm centroiding did the right thing:

from spxtacular import profile_centroid_plot

profile_centroid_plot(profile)                    # centroids computed for you
profile_centroid_plot(profile, my_centroids)      # or supply your own

A stick off the apex means a mis-assigned centre; an apex with no stick means a peak was dropped. Two things are worth watching for:

  • centroid() applies no intensity threshold — every local maximum becomes a peak, so on noisy data it produces far more centroids than there are real peaks. On a test spectrum with 6 real peaks it returned 769; filter(min_intensity=…) afterwards brought it back to exactly 6.
  • It also requires a strict maximum (prev < curr > next), so a peak whose apex is a two-sample plateau — routine in quantised or saturated data — is discarded silently.

Theme

Colour lives in spxtacular.theme, and is assigned by the job it does rather than by taste:

Job What it encodes How it is coloured
Ion type which fragment series The proteomics convention: b blue · y red · a green · c teal · x purple · z orange
Charge state 1+, 2+, 3+ … Ordinal — one hue, light to dark
iso_score, ion mobility magnitude Sequential — one hue, light to dark
Unmatched peaks context, not subject Recessive grey, thinner and dimmer

Ion colours follow the convention used by Skyline, MetaDraw, IPSA and spectrum_utils, so a spectrum from spxtacular reads the way you expect. The hues are this palette's own validated steps picked from each conventional family, rather than copied hex values, so the convention is honoured without giving up the colour-vision checks — b and y, the pair present in nearly every spectrum, separate at CVD ΔE 21.6 (light) and 19.2 (dark), well above the ≥8 target.

The one pair to know about is a vs y — green against red, the classic confusion pair — which sits at ΔE 7.2 in light mode. That is inside the band that is only safe alongside a second channel; annotated spectra always carry direct ion labels, which provides it, and texture=True adds dash patterns if you want more. Dark mode clears the target outright at 8.6.

Two further consequences. Charge is ordinal, so it takes a ramp rather than a categorical cycle — you see the ordering in the colour, and charges beyond the ramp clamp to its dark end instead of wrapping around to an earlier colour. And ion types past the eighth slot — including internal fragments, whose types are two letters like by — fold to a neutral colour rather than inventing a ninth hue that nobody could distinguish.

Every palette was checked with a colour-vision-deficiency validator (protanopia and deuteranopia) against both the light and dark surfaces.

Light and dark

from spxtacular import theme

theme.set_plot_theme("dark")          # global default for every later plot
spec.plot(theme_mode="dark")          # or per call

The dark palette is its own set of steps chosen for the dark surface, not an automatic inversion of the light one.

Compressed dynamic range

intensity_transform="log" (or "sqrt") keeps low-abundance matched ions readable when one base peak would otherwise flatten everything else:

Brand colours

theme.set_palette(
    categorical={"light": [...8 hues...], "dark": [...8 hues...]},
)

set_palette also accepts charge_ramp and sequential. Each takes both modes and raises ValueError if one is missing, or if a categorical palette has fewer than eight hues.

Substituted palettes are not validated for you. The shipped hues were chosen to stay distinguishable under colour-vision deficiency; if you replace them, check your own.

Texture

texture=True on annotate_spectrum() gives each ion series its own dash pattern, so identity survives print, forced-colours, and readers who cannot separate two hues:

spx.annotate_spectrum(spec, fragments, tolerance=5, tolerance_type="da", texture=True)

table_view()

from spxtacular import build_annot_plot_table, table_view

table = build_annot_plot_table(spec, fragments, tolerance=5, tolerance_type="da")
html = table_view(table, max_rows=50, annotated_only=True)

Renders a plot table as an HTML <table>. A tooltip enhances a figure, it should never gate it — and label capping deliberately drops labels off the plot, while a hover is unusable for keyboard and screen-reader users. This gives those values a home that is not the tooltip. Label text is HTML-escaped.

annotated_only=True keeps just the peaks carrying a label; max_rows keeps the n most intense.

The result renders as an ordinary table — here the six most intense annotated peaks from the spectrum above:

Peak list
m/zIntensityAnnotation
122.58651.599e+05y2^2
649.79901.862e+05b13^2
956.39971.194e+05b9
1290.63649.6e+04y13
1486.65031.283e+05b15
1600.69381.022e+05b16

Note the Intensity column carries the true value, not the relative-scaled one the y-axis shows — the table is the place values are reported exactly.


save_figure()

from spxtacular import save_figure

save_figure(fig, "spectrum.html")        # always works
save_figure(fig, "figure.png", scale=2)  # needs: pip install kaleido

The file extension picks the writer. .html (or no suffix) needs nothing extra. Static formats — .png, .svg, .pdf, .jpg, .webp, .eps — go through Plotly's static export and raise ImportError naming kaleido if it is not installed. An unrecognised suffix raises ValueError. scale=2 renders at twice the device resolution, which is what you want for a paper figure.