Repository: aeye-lab/pymovements
Pull request #782 (2024-08-20 19:51:58)

enable scanpath plotting
## Description

add feature to plot scanpaths

## Type of change
- [x] New feature (non-breaking change which adds functionality)

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules
- [x] I have checked my code and corrected any misspellings


SiQube: still #774 for pre-commit ci
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aeye-lab/pymovements/pull/782?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`bac8912`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/bac89121d3a0e876cc5c344da8b683cfdac6cd34?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) to head [(`d8679a1`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/d8679a12bdd1bf5bc9aa23481bfebce10be9951a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main      #782   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           60        61    +1     
  Lines         2819      2837   +18     
  Branches       759       759           
=========================================
+ Hits          2819      2837   +18     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/782?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).


Patch:
diff --git a/src/pymovements/plotting/__init__.py b/src/pymovements/plotting/__init__.py
index ceaaefe..b331344 100644
--- a/src/pymovements/plotting/__init__.py
+++ b/src/pymovements/plotting/__init__.py
@@ -32,12 +32,14 @@
 """
 from pymovements.plotting.heatmap import heatmap
 from pymovements.plotting.main_sequence_plot import main_sequence_plot
+from pymovements.plotting.scanpathplot import scanpathplot
 from pymovements.plotting.traceplot import traceplot
 from pymovements.plotting.tsplot import tsplot
 
 __all__ = [
     'heatmap',
     'main_sequence_plot',
+    'scanpathplot',
     'traceplot',
     'tsplot',
 ]
diff --git a/src/pymovements/plotting/scanpathplot.py b/src/pymovements/plotting/scanpathplot.py
new file mode 100644
index 0000000..56ddf97
--- /dev/null
+++ b/src/pymovements/plotting/scanpathplot.py
@@ -0,0 +1,177 @@
+# Copyright (c) 2022-2024 The pymovements Project Authors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""Provides the scanpath plotting function."""
+from __future__ import annotations
+
+import math
+import sys
+
+import matplotlib.colors
+import matplotlib.pyplot as plt
+import matplotlib.scale
+import numpy as np
+from matplotlib.collections import LineCollection
+from matplotlib.patches import Circle
+
+from pymovements.events import EventDataFrame
+from pymovements.gaze import GazeDataFrame
+from pymovements.utils.plotting import LinearSegmentedColormapType
+from pymovements.utils.plotting import setup_matplotlib
+
+
+# This is really a dirty workaround to use the Agg backend if runnning pytest.
+# This is needed as Windows workers on GitHub fail randomly with other backends.
+# Unfortunately the Agg module cannot show plots in jupyter notebooks.
+if 'pytest' in sys.modules:  # pragma: no cover
+    matplotlib.use('Agg')
+
+
+def scanpathplot(
+        events: EventDataFrame,
+        gaze: GazeDataFrame | None = None,
+        position_column: str = 'location',
+        cval: np.ndarray | None = None,  # pragma: no cover
+        cmap: matplotlib.colors.Colormap | None = None,
+        cmap_norm: matplotlib.colors.Normalize | str | None = None,
+        cmap_segmentdata: LinearSegmentedColormapType | None = None,
+        cbar_label: str | None = None,
+        show_cbar: bool = False,
+        padding: float | None = None,
+        pad_factor: float | None = 0.05,
+        figsize: tuple[int, int] = (15, 5),
+        title: str | None = None,
+        savepath: str | None = None,
+        show: bool = True,
+        color: str = 'blue',
+        alpha: float = 0.5,
+        add_traceplot: bool = False,
+        gaze_position_column: str = 'pixel',
+) -> None:
+    """Plot scanpath from positional data.
+
+    Parameters
+    ----------
+    events: EventDataFrame
+        The EventDataFrame to plot.
+    gaze: GazeDataFrame | None
+        Optional Gaze Dataframe. (default: None)
+    position_column: str
+        The column name of the x and y position data (default: 'location')
+    cval: np.ndarray | None
+        Line color values. (default: None)
+    cmap: matplotlib.colors.Colormap | None
+        Color map for line color values. (default: None)
+    cmap_norm: matplotlib.colors.Normalize | str | None
+        Normalization for color values. (default: None)
+    cmap_segmentdata: LinearSegmentedColormapType | None
+        Color map segmentation to build color map. (default: None)
+    cbar_label: str | None
+        String label for color bar. (default: None)
+    show_cbar: bool
+        Shows color bar if True. (default: False)
+    padding: float | None
+        Absolute padding value.
+        If None it is inferred from pad_factor and limits. (default: None)
+    pad_factor: float | None
+        Relative padding factor to construct padding value if not given. (default: 0.5)
+    figsize: tuple[int, int]
+        Figure size. (default: (15, 5))
+    title: str | None
+        Set figure title. (default: None)
+    savepath: str | None
+        If given, figure will be saved to this path. (default: None)
+    show: bool
+        If True, figure will be shown. (default: True)
+    color: str
+        Color of fixations. (default: 'blue')
+    alpha: float
+        Alpha value of scanpath. (default: 0.5)
+    add_traceplot: bool
+        Boolean value indicating whether to add traceplot to the scanpath plot. (default: False)
+    gaze_position_column: str
+        Position column in the gaze dataframe. (default: 'pixel')
+
+    Raises
+    ------
+    ValueError
+        If length of x and y coordinates do not match or if ``cmap_norm`` is unknown.
+
+    """
+    x_signal = events.frame[position_column].list.get(0)
+    y_signal = events.frame[position_column].list.get(1)
+
+    fig, ax, cmap, cmap_norm, cval, show_cbar = setup_matplotlib(
+        x_signal,
+        figsize,
+        cmap,
+        cmap_norm,
+        cmap_segmentdata,
+        cval,
+        show_cbar,
+    )
+
+    for row in events.frame.iter_rows(named=True):
+        fixation = Circle(
+            row[position_column],
+            math.sqrt(row['duration']),
+            color=color,
+            fill=True,
+            alpha=alpha,
+            zorder=10,
+        )
+        ax.add_patch(fixation)
+
+    if add_traceplot:
+        assert gaze
+        gaze_x_signal = gaze.frame[gaze_position_column].list.get(0)
+        gaze_y_signal = gaze.frame[gaze_position_column].list.get(1)
+        points = np.array([gaze_x_signal, gaze_y_signal]).T.reshape((-1, 1, 2))
+        segments = np.concatenate([points[:-1], points[1:]], axis=1)
+
+        # Create a continuous norm to map from data points to colors
+        line_collection = LineCollection(segments, cmap=cmap, norm=cmap_norm)
+        # Set the values used for colormapping
+        line_collection.set_array(cval)
+        line_collection.set_linewidth(2)
+        line = ax.add_collection(line_collection)
+        if show_cbar:
+            # sm = matplotlib.cm.ScalarMappable(cmap=cmap, norm=cmap_norm)
+            # sm.set_array(cval)
+            fig.colorbar(line, label=cbar_label, ax=ax)
+
+    if padding is None:
+        x_pad = (np.nanmax(x_signal) - np.nanmin(x_signal)) * pad_factor
+        y_pad = (np.nanmax(y_signal) - np.nanmin(y_signal)) * pad_factor
+    else:
+        x_pad = padding
+        y_pad = padding
+
+    ax.set_xlim(np.nanmin(x_signal) - x_pad, np.nanmax(x_signal) + x_pad)
+    ax.set_ylim(np.nanmin(y_signal) - y_pad, np.nanmax(y_signal) + y_pad)
+
+    if title:
+        ax.set_title(title)
+
+    if savepath is not None:
+        fig.savefig(savepath)
+
+    if show:
+        plt.show()
+    plt.close(fig)
diff --git a/src/pymovements/plotting/traceplot.py b/src/pymovements/plotting/traceplot.py
index 9b3ce8a..85ceadb 100644
--- a/src/pymovements/plotting/traceplot.py
+++ b/src/pymovements/plotting/traceplot.py
@@ -21,17 +21,16 @@
 from __future__ import annotations
 
 import sys
-from collections.abc import Sequence
-from typing import Literal
 
 import matplotlib.colors
 import matplotlib.pyplot as plt
 import matplotlib.scale
 import numpy as np
 from matplotlib.collections import LineCollection
-from typing_extensions import TypeAlias
 
 from pymovements.gaze.gaze_dataframe import GazeDataFrame
+from pymovements.utils.plotting import LinearSegmentedColormapType
+from pymovements.utils.plotting import setup_matplotlib
 
 
 # This is really a dirty workaround to use the Agg backend if runnning pytest.
@@ -40,51 +39,6 @@ from pymovements.gaze.gaze_dataframe import GazeDataFrame
 if 'pytest' in sys.modules:  # pragma: no cover
     matplotlib.use('Agg')
 
-LinearSegmentedColormapType: TypeAlias = dict[
-    Literal['red', 'green', 'blue', 'alpha'], Sequence[tuple[float, ...]],
-]
-
-DEFAULT_SEGMENTDATA: LinearSegmentedColormapType = {
-    'red': [
-        (0.0, 0.0, 0.0),
-        (0.5, 1.0, 1.0),
-        (1.0, 1.0, 1.0),
-    ],
-    'green': [
-        (0.0, 0.0, 0.0),
-        (0.5, 1.0, 1.0),
-        (1.0, 0.0, 0.0),
-    ],
-    'blue': [
-        (0.0, 0.0, 0.0),
-        (0.5, 0.0, 0.0),
-        (1.0, 0.0, 0.0),
-    ],
-}
-
-
-DEFAULT_SEGMENTDATA_TWOSLOPE: LinearSegmentedColormapType = {
-    'red': [
-        (0.0, 0.0, 0.0),
-        (0.5, 0.0, 0.0),
-        (0.75, 1.0, 1.0),
-        (1.0, 1.0, 1.0),
-    ],
-    'green': [
-        (0.0, 0.0, 0.0),
-        (0.25, 1.0, 1.0),
-        (0.5, 0.0, 0.0),
-        (0.75, 1.0, 1.0),
-        (1.0, 0.0, 0.0),
-    ],
-    'blue': [
-        (0.0, 1.0, 1.0),
-        (0.25, 1.0, 1.0),
-        (0.5, 0.0, 0.0),
-        (1.0, 0.0, 0.0),
-    ],
-}
-
 
 def traceplot(
         gaze: GazeDataFrame,
@@ -145,57 +99,15 @@ def traceplot(
     x_signal = gaze.frame[position_column].list.get(0)
     y_signal = gaze.frame[position_column].list.get(1)
 
-    n = len(x_signal)
-
-    fig = plt.figure(figsize=figsize)
-    ax = fig.gca()
-
-    if cval is None:
-        cval = np.zeros(n)
-        show_cbar = False
-
-    cval_max = np.nanmax(np.abs(cval))
-    cval_min = np.nanmin(cval).astype(float)
-
-    if cmap_norm is None:
-        if cval_max and cval_min < 0:
-            cmap_norm = 'twoslope'
-        elif cval_max:
-            cmap_norm = 'normalize'
-        else:
-            cmap_norm = 'nonorm'
-
-    if cmap is None:
-        if cmap_segmentdata is None:
-            if cmap_norm == 'twoslope':
-                cmap_segmentdata = DEFAULT_SEGMENTDATA_TWOSLOPE
-            else:
-                cmap_segmentdata = DEFAULT_SEGMENTDATA
-
-        cmap = matplotlib.colors.LinearSegmentedColormap(
-            'line_cmap', segmentdata=cmap_segmentdata, N=512,
-        )
-
-    if cmap_norm == 'twoslope':
-        cmap_norm = matplotlib.colors.TwoSlopeNorm(
-            vcenter=0, vmin=-cval_max, vmax=cval_max,
-        )
-    elif cmap_norm == 'normalize':
-        cmap_norm = matplotlib.colors.Normalize(
-            vmin=cval_min, vmax=cval_max,
-        )
-    elif cmap_norm == 'nonorm':
-        cmap_norm = matplotlib.colors.NoNorm()
-
-    elif isinstance(cmap_norm, str):
-        # pylint: disable=protected-access
-        if (
-            scale_class := matplotlib.scale._scale_mapping.get(cmap_norm, None)
-        ) is None:
-            raise ValueError(f'cmap_norm string {cmap_norm} is not supported')
-
-        norm_class = matplotlib.colors.make_norm_from_scale(scale_class)
-        cmap_norm = norm_class(matplotlib.colors.Normalize)()
+    fig, ax, cmap, cmap_norm, cval, show_cbar = setup_matplotlib(
+        x_signal,
+        figsize,
+        cmap,
+        cmap_norm,
+        cmap_segmentdata,
+        cval,
+        show_cbar,
+    )
 
     # Create a set of line segments so that we can color them individually
     # This creates the points as a N x 1 x 2 array so that we can stack points
diff --git a/src/pymovements/utils/__init__.py b/src/pymovements/utils/__init__.py
index 2b512c3..2f0e95d 100644
--- a/src/pymovements/utils/__init__.py
+++ b/src/pymovements/utils/__init__.py
@@ -39,6 +39,7 @@ from pymovements.utils import decorators
 from pymovements.utils import downloads
 from pymovements.utils import parsing
 from pymovements.utils import paths
+from pymovements.utils import plotting
 
 
 __all__ = [
@@ -47,5 +48,6 @@ __all__ = [
     'decorators',
     'downloads',
     'parsing',
+    'plotting',
     'paths',
 ]
diff --git a/src/pymovements/utils/plotting.py b/src/pymovements/utils/plotting.py
index 8868603..c1e4abd 100644
--- a/src/pymovements/utils/plotting.py
+++ b/src/pymovements/utils/plotting.py
@@ -20,17 +20,173 @@
 """Functions for plotting."""
 from __future__ import annotations
 
+from collections.abc import Sequence
 from pathlib import Path
+from typing import Literal
+from typing import Union
 
+import matplotlib.pyplot
+import numpy as np
 import PIL.Image
-from matplotlib import pyplot
+from typing_extensions import TypeAlias
+
+LinearSegmentedColormapType: TypeAlias = dict[
+    Literal['red', 'green', 'blue', 'alpha'],
+    Sequence[tuple[float, ...]],
+]
+
+
+DEFAULT_SEGMENTDATA: LinearSegmentedColormapType = {
+    'red': [
+        (0.0, 0.0, 0.0),
+        (0.5, 1.0, 1.0),
+        (1.0, 1.0, 1.0),
+    ],
+    'green': [
+        (0.0, 0.0, 0.0),
+        (0.5, 1.0, 1.0),
+        (1.0, 0.0, 0.0),
+    ],
+    'blue': [
+        (0.0, 0.0, 0.0),
+        (0.5, 0.0, 0.0),
+        (1.0, 0.0, 0.0),
+    ],
+}
+
+
+DEFAULT_SEGMENTDATA_TWOSLOPE: LinearSegmentedColormapType = {
+    'red': [
+        (0.0, 0.0, 0.0),
+        (0.5, 0.0, 0.0),
+        (0.75, 1.0, 1.0),
+        (1.0, 1.0, 1.0),
+    ],
+    'green': [
+        (0.0, 0.0, 0.0),
+        (0.25, 1.0, 1.0),
+        (0.5, 0.0, 0.0),
+        (0.75, 1.0, 1.0),
+        (1.0, 0.0, 0.0),
+    ],
+    'blue': [
+        (0.0, 1.0, 1.0),
+        (0.25, 1.0, 1.0),
+        (0.5, 0.0, 0.0),
+        (1.0, 0.0, 0.0),
+    ],
+}
+
+CmapNormType: TypeAlias = Union[
+    matplotlib.colors.TwoSlopeNorm,
+    matplotlib.colors.Normalize,
+    matplotlib.colors.NoNorm,
+]
+MatplotlibSetupType: TypeAlias = tuple[
+    matplotlib.pyplot.figure,
+    matplotlib.pyplot.Axes,
+    matplotlib.colors.Colormap,
+    CmapNormType,
+    np.ndarray,
+    bool,
+]
+
+
+def setup_matplotlib(
+        signal: np.ndarray,
+        figsize: tuple[int, int],
+        cmap: matplotlib.colors.Colormap | None = None,
+        cmap_norm: matplotlib.colors.Normalize | str | None = None,
+        cmap_segmentdata: LinearSegmentedColormapType | None = None,
+        cval: np.ndarray | None = None,
+        show_cbar: bool = False,
+) -> MatplotlibSetupType:
+    """Configure cmap.
+
+    Parameters
+    ----------
+    signal: np.ndarray
+        Time-step array.
+    figsize: tuple[int, int]
+        Figure size.
+    cmap: matplotlib.colors.Colormap | None
+        Color map for line color values. (default: None)
+    cmap_norm: matplotlib.colors.Normalize | str | None
+        Normalization for color values. (default: None)
+    cmap_segmentdata: LinearSegmentedColormapType | None
+        Color map segmentation to build color map. (default: None)
+    cval: np.ndarray | None
+        Line color values. (default: None)
+    show_cbar: bool
+        Shows color bar if True. (default: False)
+
+    Returns
+    -------
+    MatplotlibSetupType
+        Configures fig, ax, cmap, cmap_norm, cmap_segmentdata, cval, and show_cbar.
+    """
+    n = len(signal)
+
+    fig = matplotlib.pyplot.figure(figsize=figsize)
+    ax = fig.gca()
+
+    if cval is None:
+        cval = np.zeros(n)
+        show_cbar = False
+
+    cval_max = np.nanmax(np.abs(cval))
+    cval_min = np.nanmin(cval).astype(float)
+
+    if cmap_norm is None:
+        if cval_max and cval_min < 0:
+            cmap_norm = 'twoslope'
+        elif cval_max:
+            cmap_norm = 'normalize'
+        else:
+            cmap_norm = 'nonorm'
+
+    if cmap is None:
+        if cmap_segmentdata is None:
+            if cmap_norm == 'twoslope':
+                cmap_segmentdata = DEFAULT_SEGMENTDATA_TWOSLOPE
+            else:
+                cmap_segmentdata = DEFAULT_SEGMENTDATA
+
+        cmap = matplotlib.colors.LinearSegmentedColormap(
+            'line_cmap', segmentdata=cmap_segmentdata, N=512,
+        )
+
+    if cmap_norm == 'twoslope':
+        cmap_norm = matplotlib.colors.TwoSlopeNorm(
+            vcenter=0, vmin=-cval_max, vmax=cval_max,
+        )
+    elif cmap_norm == 'normalize':
+        cmap_norm = matplotlib.colors.Normalize(
+            vmin=cval_min, vmax=cval_max,
+        )
+    elif cmap_norm == 'nonorm':
+        cmap_norm = matplotlib.colors.NoNorm()
+
+    elif isinstance(cmap_norm, str):
+        # pylint: disable=protected-access
+
+        # to handle after https://github.com/pydata/xarray/pull/8030 is merged
+        if (
+            scale_class := matplotlib.scale._scale_mapping.get(cmap_norm, None)  # type: ignore
+        ) is None:
+            raise ValueError(f'cmap_norm string {cmap_norm} is not supported')
+
+        norm_class = matplotlib.colors.make_norm_from_scale(scale_class)
+        cmap_norm = norm_class(matplotlib.colors.Normalize)()
+
+    return fig, ax, cmap, cmap_norm, cval, show_cbar
 
 
 def draw_image_stimulus(
         image_stimulus: str | Path,
         origin: str = 'upper',
         show: bool = False,
-) -> tuple[pyplot.figure, pyplot.Axes]:
+) -> tuple[matplotlib.pyplot.figure, matplotlib.pyplot.Axes]:
     """Draw stimulus.
 
     Parameters
@@ -44,12 +200,12 @@ def draw_image_stimulus(
 
     Returns
     -------
-    fig: pyplot.figure
-    ax: pyplot.Axes
+    fig: matplotlib.pyplot.figure
+    ax: matplotlib.pyplot.Axes
     """
     img = PIL.Image.open(image_stimulus)
-    fig, ax = pyplot.subplots()
+    fig, ax = matplotlib.pyplot.subplots()
     ax.imshow(img, origin=origin)
     if show:
-        pyplot.show()
+        matplotlib.pyplot.show()
     return fig, ax


Test patch:
diff --git a/tests/unit/plotting/scanpathplot_test.py b/tests/unit/plotting/scanpathplot_test.py
new file mode 100644
index 0000000..f01b087
--- /dev/null
+++ b/tests/unit/plotting/scanpathplot_test.py
@@ -0,0 +1,183 @@
+# Copyright (c) 2023-2024 The pymovements Project Authors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""Test scanpathplot."""
+from unittest.mock import Mock
+
+import matplotlib.colors
+import matplotlib.pyplot as plt
+import numpy as np
+import polars as pl
+import pytest
+from matplotlib import figure
+
+import pymovements as pm
+
+
+@pytest.fixture(name='events', scope='session')
+def event_fixture():
+    return pm.EventDataFrame(
+        pl.DataFrame(
+            data={
+                'trial': [1, 1],
+                'name': ['foo', 'foo'],
+                'onset': [0, 2],
+                'offset': [1, 3],
+                'duration': [1, 1],
+                'location': [(1, 2), (2, 3)],
+            },
+        ),
+    )
+
+
+@pytest.fixture(name='gaze', scope='session')
+def gaze_fixture():
+    experiment = pm.Experiment(
+        screen_width_px=1280,
+        screen_height_px=1024,
+        screen_width_cm=38,
+        screen_height_cm=30,
+        distance_cm=68,
+        origin='upper left',
+        sampling_rate=1000.0,
+    )
+    x = np.arange(-100, 100)
+    y = np.arange(-100, 100)
+    arr = np.column_stack((x, y)).transpose()
+    gaze = pm.gaze.from_numpy(
+        data=arr,
+        schema=['x_pix', 'y_pix'],
+        experiment=experiment,
+        pixel_columns=['x_pix', 'y_pix'],
+    )
+
+    gaze.pix2deg()
+    gaze.pos2vel()
+
+    return gaze
+
+
+@pytest.mark.parametrize(
+    'kwargs',
+    [
+        pytest.param(
+            {'cval': np.arange(-100, 100)},
+            id='cval_array',
+        ),
+        pytest.param(
+            {'cval': np.arange(-100, 100), 'cmap_norm': 'twoslope'},
+            id='cmap_norm_twoslope',
+        ),
+        pytest.param(
+            {'cval': np.arange(0, 200), 'cmap_norm': 'nonorm'},
+            id='cmap_norm_nonorm',
+        ),
+        pytest.param(
+            {'cval': np.arange(0, 200)},
+            id='cmap_norm_nonorm_implicit',
+        ),
+        pytest.param(
+            {'cval': np.arange(-100, 100), 'cmap_norm': 'normalize'},
+            id='cmap_norm_normalize',
+        ),
+        pytest.param(
+            {'cval': np.arange(0, 200), 'cmap_norm': 'linear'},
+            id='cmap_norm_linear',
+        ),
+        pytest.param(
+            {'cval': np.arange(0, 200), 'cmap_norm': matplotlib.colors.NoNorm()},
+            id='cmap_norm_class',
+        ),
+        pytest.param(
+            {'cmap': matplotlib.colors.LinearSegmentedColormap(name='test', segmentdata={})},
+            id='cmap_class',
+        ),
+        pytest.param(
+            {'cmap_segmentdata': {}},
+            id='cmap_segmentdata',
+        ),
+        pytest.param(
+            {'padding': 0.1},
+            id='padding',
+        ),
+        pytest.param(
+            {'cval': np.arange(0, 200), 'show_cbar': True},
+            id='show_cbar_true',
+        ),
+        pytest.param(
+            {'cval': np.arange(0, 200), 'show_cbar': False},
+            id='show_cbar_false',
+        ),
+        pytest.param(
+            {'cval': np.arange(0, 200), 'title': 'foo'},
+            id='set_title',
+        ),
+    ],
+)
+def test_scanpathplot_show(events, gaze, kwargs, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt, 'show', mock)
+    pm.plotting.scanpathplot(events=events, gaze=gaze, **kwargs)
+    plt.close()
+    mock.assert_called_once()
+
+
+def test_scanpathplot_noshow(events, gaze, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt, 'show', mock)
+    pm.plotting.scanpathplot(events=events, gaze=gaze, show=False)
+    plt.close()
+    mock.assert_not_called()
+
+
+def test_scanpathplot_save(events, gaze, monkeypatch, tmp_path):
+    mock = Mock()
+    monkeypatch.setattr(figure.Figure, 'savefig', mock)
+    pm.plotting.scanpathplot(
+        events=events,
+        gaze=gaze,
+        show=False,
+        savepath=str(
+            tmp_path /
+            'test.svg',
+        ),
+    )
+    plt.close()
+    mock.assert_called_once()
+
+
+@pytest.mark.parametrize(
+    ('kwargs', 'exception'),
+    [
+        pytest.param(
+            {
+                'cval': np.arange(0, 200),
+                'cmap_norm': 'invalid',
+            },
+            ValueError,
+            id='cmap_norm_unsupported',
+        ),
+    ],
+)
+def test_scanpathplot_exceptions(events, gaze, kwargs, exception, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt, 'show', mock)
+
+    with pytest.raises(exception):
+        pm.plotting.scanpathplot(events=events, gaze=gaze, **kwargs)