Skip to content

Radar Chart with Polygonal Grid Lines #19981

Closed

Description

@prohde

Bug report

Bug summary

I was able to create radar charts with polygonal grid lines before. But the code is now producing circular grid lines.
It was working in version 3.2.x but since 3.3.0 the grid lines are circular. The official example appears to be the same in all the versions and still reads:

It's possible to get a polygon grid by setting GRIDLINE_INTERPOLATION_STEPS in matplotlib.axis to the desired number of vertices, but the orientation of the polygon is not aligned with the radial axes.

However, it is not working in the same manner as before.

Code for reproduction

The code below is the same as in the official example (link above) plus the method 'draw' which used to make the grid lines polygonal.

import numpy as np

import matplotlib.pyplot as plt
from matplotlib.patches import Circle, RegularPolygon
from matplotlib.path import Path
from matplotlib.projections.polar import PolarAxes
from matplotlib.projections import register_projection
from matplotlib.spines import Spine
from matplotlib.transforms import Affine2D


def radar_factory(num_vars, frame='circle'):
    """
    Create a radar chart with `num_vars` axes.

    This function creates a RadarAxes projection and registers it.

    Parameters
    ----------
    num_vars : int
        Number of variables for radar chart.
    frame : {'circle', 'polygon'}
        Shape of frame surrounding axes.

    """
    # calculate evenly-spaced axis angles
    theta = np.linspace(0, 2*np.pi, num_vars, endpoint=False)

    class RadarAxes(PolarAxes):

        name = 'radar'
        # use 1 line segment to connect specified points
        RESOLUTION = 1

        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            # rotate plot such that the first axis is at the top
            self.set_theta_zero_location('N')

        def fill(self, *args, closed=True, **kwargs):
            """Override fill so that line is closed by default"""
            return super().fill(closed=closed, *args, **kwargs)

        def plot(self, *args, **kwargs):
            """Override plot so that line is closed by default"""
            lines = super().plot(*args, **kwargs)
            for line in lines:
                self._close_line(line)

        def _close_line(self, line):
            x, y = line.get_data()
            # FIXME: markers at x[0], y[0] get doubled-up
            if x[0] != x[-1]:
                x = np.append(x, x[0])
                y = np.append(y, y[0])
                line.set_data(x, y)

        def set_varlabels(self, labels):
            self.set_thetagrids(np.degrees(theta), labels)

        def _gen_axes_patch(self):
            # The Axes patch must be centered at (0.5, 0.5) and of radius 0.5
            # in axes coordinates.
            if frame == 'circle':
                return Circle((0.5, 0.5), 0.5)
            elif frame == 'polygon':
                return RegularPolygon((0.5, 0.5), num_vars,
                                      radius=.5, edgecolor="k")
            else:
                raise ValueError("Unknown value for 'frame': %s" % frame)

        def _gen_axes_spines(self):
            if frame == 'circle':
                return super()._gen_axes_spines()
            elif frame == 'polygon':
                # spine_type must be 'left'/'right'/'top'/'bottom'/'circle'.
                spine = Spine(axes=self,
                              spine_type='circle',
                              path=Path.unit_regular_polygon(num_vars))
                # unit_regular_polygon gives a polygon of radius 1 centered at
                # (0, 0) but we want a polygon of radius 0.5 centered at (0.5,
                # 0.5) in axes coordinates.
                spine.set_transform(Affine2D().scale(.5).translate(.5, .5)
                                    + self.transAxes)
                return {'polar': spine}
            else:
                raise ValueError("Unknown value for 'frame': %s" % frame)

        def draw(self, renderer, *args, **kwargs):
            """ Draw. If frame is polygon, make gridlines polygon-shaped """
            if frame == 'polygon':
                gridlines = self.yaxis.get_gridlines()
                print(self.get_yaxis().get_gridlines())
                for gl in gridlines:
                    gl.get_path()._interpolation_steps = num_vars
            super().draw(renderer, *args, **kwargs)

    register_projection(RadarAxes)
    return theta


def example_data():
    # The following data is from the Denver Aerosol Sources and Health study.
    # See doi:10.1016/j.atmosenv.2008.12.017
    #
    # The data are pollution source profile estimates for five modeled
    # pollution sources (e.g., cars, wood-burning, etc) that emit 7-9 chemical
    # species. The radar charts are experimented with here to see if we can
    # nicely visualize how the modeled source profiles change across four
    # scenarios:
    #  1) No gas-phase species present, just seven particulate counts on
    #     Sulfate
    #     Nitrate
    #     Elemental Carbon (EC)
    #     Organic Carbon fraction 1 (OC)
    #     Organic Carbon fraction 2 (OC2)
    #     Organic Carbon fraction 3 (OC3)
    #     Pyrolized Organic Carbon (OP)
    #  2)Inclusion of gas-phase specie carbon monoxide (CO)
    #  3)Inclusion of gas-phase specie ozone (O3).
    #  4)Inclusion of both gas-phase species is present...
    data = [
        ['Sulfate', 'Nitrate', 'EC', 'OC1', 'OC2', 'OC3', 'OP', 'CO', 'O3'],
        ('Basecase', [
            [0.88, 0.01, 0.03, 0.03, 0.00, 0.06, 0.01, 0.00, 0.00],
            [0.07, 0.95, 0.04, 0.05, 0.00, 0.02, 0.01, 0.00, 0.00],
            [0.01, 0.02, 0.85, 0.19, 0.05, 0.10, 0.00, 0.00, 0.00],
            [0.02, 0.01, 0.07, 0.01, 0.21, 0.12, 0.98, 0.00, 0.00],
            [0.01, 0.01, 0.02, 0.71, 0.74, 0.70, 0.00, 0.00, 0.00]]),
        ('With CO', [
            [0.88, 0.02, 0.02, 0.02, 0.00, 0.05, 0.00, 0.05, 0.00],
            [0.08, 0.94, 0.04, 0.02, 0.00, 0.01, 0.12, 0.04, 0.00],
            [0.01, 0.01, 0.79, 0.10, 0.00, 0.05, 0.00, 0.31, 0.00],
            [0.00, 0.02, 0.03, 0.38, 0.31, 0.31, 0.00, 0.59, 0.00],
            [0.02, 0.02, 0.11, 0.47, 0.69, 0.58, 0.88, 0.00, 0.00]]),
        ('With O3', [
            [0.89, 0.01, 0.07, 0.00, 0.00, 0.05, 0.00, 0.00, 0.03],
            [0.07, 0.95, 0.05, 0.04, 0.00, 0.02, 0.12, 0.00, 0.00],
            [0.01, 0.02, 0.86, 0.27, 0.16, 0.19, 0.00, 0.00, 0.00],
            [0.01, 0.03, 0.00, 0.32, 0.29, 0.27, 0.00, 0.00, 0.95],
            [0.02, 0.00, 0.03, 0.37, 0.56, 0.47, 0.87, 0.00, 0.00]]),
        ('CO & O3', [
            [0.87, 0.01, 0.08, 0.00, 0.00, 0.04, 0.00, 0.00, 0.01],
            [0.09, 0.95, 0.02, 0.03, 0.00, 0.01, 0.13, 0.06, 0.00],
            [0.01, 0.02, 0.71, 0.24, 0.13, 0.16, 0.00, 0.50, 0.00],
            [0.01, 0.03, 0.00, 0.28, 0.24, 0.23, 0.00, 0.44, 0.88],
            [0.02, 0.00, 0.18, 0.45, 0.64, 0.55, 0.86, 0.00, 0.16]])
    ]
    return data


if __name__ == '__main__':
    N = 9
    theta = radar_factory(N, frame='polygon')

    data = example_data()
    spoke_labels = data.pop(0)

    fig, axs = plt.subplots(figsize=(9, 9), nrows=2, ncols=2,
                            subplot_kw=dict(projection='radar'))
    fig.subplots_adjust(wspace=0.25, hspace=0.20, top=0.85, bottom=0.05)

    colors = ['b', 'r', 'g', 'm', 'y']
    # Plot the four cases from the example data on separate axes
    for ax, (title, case_data) in zip(axs.flat, data):
        ax.set_rgrids([0.2, 0.4, 0.6, 0.8])
        ax.set_title(title, weight='bold', size='medium', position=(0.5, 1.1),
                     horizontalalignment='center', verticalalignment='center')
        for d, color in zip(case_data, colors):
            ax.plot(theta, d, color=color)
            ax.fill(theta, d, facecolor=color, alpha=0.25)
        ax.set_varlabels(spoke_labels)

    # add legend relative to top-left plot
    labels = ('Factor 1', 'Factor 2', 'Factor 3', 'Factor 4', 'Factor 5')
    legend = axs[0, 0].legend(labels, loc=(0.9, .95),
                              labelspacing=0.1, fontsize='small')

    fig.text(0.5, 0.965, '5-Factor Solution Profiles Across Four Scenarios',
             horizontalalignment='center', color='black', weight='bold',
             size='large')

    plt.show()

Actual outcome

fig_actual

Expected outcome

It used to work in older versions, like in 3.2.1. But it stopped working in 3.3.0.
fig_expected

Matplotlib version

  • Operating system: Ubuntu 20.04
  • Matplotlib version (import matplotlib; print(matplotlib.__version__)): 3.4.1
  • Matplotlib backend (print(matplotlib.get_backend())): backend_interagg
  • Python version: 3.8.5
  • Jupyter version (if applicable): NA
  • Other libraries: numpy 1.20.2

Python was installed via the package manager of Ubuntu. matplotlib via pip3.

Activity

  1. anntzer commented on Apr 16, 2021

    @anntzer
    Contributor

    fwiw per https://matplotlib.org/3.2.1/gallery/specialty_plots/radar_chart.html (as well as by repro'ing locally) this seems broken on 3.2.1 as well, and in fact as far back as 3.0 at least?

  2. prohde commented on Apr 16, 2021

    @prohde
    ContributorAuthor

    @anntzer True, the code shown there does create circular grid lines. But it was possible to create the polygonal grid lines by adding

            def draw(self, renderer, *args, **kwargs):
                """ Draw. If frame is polygon, make gridlines polygon-shaped """
                if frame == 'polygon':
                    gridlines = self.yaxis.get_gridlines()
                    print(self.get_yaxis().get_gridlines())
                    for gl in gridlines:
                        gl.get_path()._interpolation_steps = num_vars
                super().draw(renderer, *args, **kwargs)

    to the class RadarAxes (as in the code to reproduce the issue).

  3. anntzer commented on Apr 16, 2021

    @anntzer
    Contributor

    Fair enough.
    This is related to #14852, although a fix is not obvious (even assuming that we're willing to put in a semi-arbitrary threshold to decide whether to perform arc() interpolation or not).

  4. prohde commented on Feb 9, 2022

    @prohde
    ContributorAuthor

    Any news on this or any workaround?

  5. prohde commented on Feb 11, 2022

    @prohde
    ContributorAuthor

    The following code could serve as a workaround. I included a class RadarTransform that is basically the old PolarTransform of version 3.2.2. What is your opinion on that?

    However, I experience a new issue when using matplotlib >= 3.5.0. As can be seen in the documentation the legend is no longer correct for versions >= 3.5.0. It used the lines (ax.plot) only in versions < 3.5.0 but starting with 3.5.0 it also considers the filled area (ax.fill), hence, the associated 'colors' are wrong in the legend.

    Edit: Solved it by adding label="_nolegend_" to the ax.fill() commands.

    import matplotlib.path as mpath
    import matplotlib.pyplot as plt
    import matplotlib.transforms as mtransforms
    import numpy as np
    from matplotlib.patches import Circle, RegularPolygon
    from matplotlib.path import Path
    from matplotlib.projections import register_projection
    from matplotlib.projections.polar import PolarAxes
    from matplotlib.spines import Spine
    from matplotlib.transforms import Affine2D
    
    
    def radar_factory(num_vars: int, frame: str = 'circle'):
        """
        This function creates a RadarAxes projection with 'num_vars' axes and registers it.
    
    
        :param int num_vars: number of axes for the radar chart
        :param str frame: shape of frame surrounding axes {'circle' | 'polygon'}
        """
        # calculate evenly-spaced axis angles
        theta = np.linspace(0, 2*np.pi, num_vars, endpoint=False)
    
        class RadarTransform(mtransforms.Transform):
            """
            The base polar transform. This handles projection *theta* and
            *r* into Cartesian coordinate space *x* and *y*, but does not
            perform the ultimate affine transformation into the correct
            position.
    
            This is copied from Matplotlib version 3.2.2 since in 3.3.0
            the grid lines are using a different interpolation method.
            """
            input_dims = output_dims = 2
    
            def __init__(self, axis=None, use_rmin=True,
                         _apply_theta_transforms=True):
                mtransforms.Transform.__init__(self)
                self._axis = axis
                self._use_rmin = use_rmin
                self._apply_theta_transforms = _apply_theta_transforms
    
            def transform_non_affine(self, tr):
                # docstring inherited
                t, r = np.transpose(tr)
                # PolarAxes does not use the theta transforms here, but apply them for
                # backwards-compatibility if not being used by it.
                if self._apply_theta_transforms and self._axis is not None:
                    t *= self._axis.get_theta_direction()
                    t += self._axis.get_theta_offset()
                if self._use_rmin and self._axis is not None:
                    r = (r - self._axis.get_rorigin()) * self._axis.get_rsign()
                r = np.where(r >= 0, r, np.nan)
                return np.column_stack([r * np.cos(t), r * np.sin(t)])
    
            def transform_path_non_affine(self, path):
                # docstring inherited
                vertices = path.vertices
                if len(vertices) == 2 and vertices[0, 0] == vertices[1, 0]:
                    return mpath.Path(self.transform(vertices), path.codes)
                ipath = path.interpolated(path._interpolation_steps)
                return mpath.Path(self.transform(ipath.vertices), ipath.codes)
    
            def inverted(self):
                # docstring inherited
                return PolarAxes.InvertedPolarTransform(self._axis, self._use_rmin, self._apply_theta_transforms)
    
        class RadarAxes(PolarAxes):
            """
            Axes for the radar plot. The layout can either be a circle or a polygon with 'num_vars' vertices.
            """
            name = 'radar'
    
            def __init__(self, *args, **kwargs):
                super().__init__(*args, **kwargs)
                # rotate plot such that the first axis is at the top
                self.set_theta_zero_location('N')
    
            def fill(self, *args, closed=True, **kwargs):
                """Override fill so that line is closed by default"""
                return super().fill(closed=closed, *args, **kwargs)
    
            def plot(self, *args, **kwargs):
                """Override plot so that line is closed by default"""
                lines = super().plot(*args, **kwargs)
                for line in lines:
                    self._close_line(line)
    
            def _close_line(self, line):
                x, y = line.get_data()
                # FIXME: markers at x[0], y[0] get doubled-up
                if x[0] != x[-1]:
                    x = np.concatenate((x, [x[0]]))
                    y = np.concatenate((y, [y[0]]))
                    line.set_data(x, y)
    
            def set_varlabels(self, labels):
                self.set_thetagrids(np.degrees(theta), labels)
    
            def _gen_axes_patch(self):
                # The Axes patch must be centered at (0.5, 0.5) and of radius 0.5 in axes coordinates.
                if frame == 'circle':
                    return Circle((0.5, 0.5), 0.5)
                elif frame == 'polygon':
                    return RegularPolygon((0.5, 0.5), num_vars, radius=.5, edgecolor="k")
                else:
                    raise ValueError("unknown value for 'frame': %s" % frame)
    
            def draw(self, renderer, *args, **kwargs):
                """ Draw. If frame is polygon, make gridlines polygon-shaped """
                if frame == 'polygon':
                    gridlines = self.yaxis.get_gridlines()
                    for gl in gridlines:
                        gl.get_path()._interpolation_steps = num_vars
                super().draw(renderer, *args, **kwargs)
    
            def _gen_axes_spines(self):
                if frame == 'circle':
                    return super()._gen_axes_spines()
                elif frame == 'polygon':
                    # spine_type must be 'left'/'right'/'top'/'bottom'/'circle'.
                    spine = Spine(axes=self,
                                  spine_type='circle',
                                  path=Path.unit_regular_polygon(num_vars))
                    # unit_regular_polygon gives a polygon of radius 1 centered at (0, 0) but we want a polygon
                    # of radius 0.5 centered at (0.5, 0.5) in axes coordinates.
                    spine.set_transform(Affine2D().scale(.5).translate(.5, .5) + self.transAxes)
    
                    return {'polar': spine}
                else:
                    raise ValueError("Unknown value for 'frame': %s" % frame)
    
        RadarAxes.PolarTransform = RadarTransform
        register_projection(RadarAxes)
        return theta
    
    
    def example_data():
        # The following data is from the Denver Aerosol Sources and Health study.
        # See doi:10.1016/j.atmosenv.2008.12.017
        #
        # The data are pollution source profile estimates for five modeled
        # pollution sources (e.g., cars, wood-burning, etc) that emit 7-9 chemical
        # species. The radar charts are experimented with here to see if we can
        # nicely visualize how the modeled source profiles change across four
        # scenarios:
        #  1) No gas-phase species present, just seven particulate counts on
        #     Sulfate
        #     Nitrate
        #     Elemental Carbon (EC)
        #     Organic Carbon fraction 1 (OC)
        #     Organic Carbon fraction 2 (OC2)
        #     Organic Carbon fraction 3 (OC3)
        #     Pyrolized Organic Carbon (OP)
        #  2)Inclusion of gas-phase specie carbon monoxide (CO)
        #  3)Inclusion of gas-phase specie ozone (O3).
        #  4)Inclusion of both gas-phase species is present...
        data = [
            ['Sulfate', 'Nitrate', 'EC', 'OC1', 'OC2', 'OC3', 'OP', 'CO', 'O3'],
            ('Basecase', [
                [0.88, 0.01, 0.03, 0.03, 0.00, 0.06, 0.01, 0.00, 0.00],
                [0.07, 0.95, 0.04, 0.05, 0.00, 0.02, 0.01, 0.00, 0.00],
                [0.01, 0.02, 0.85, 0.19, 0.05, 0.10, 0.00, 0.00, 0.00],
                [0.02, 0.01, 0.07, 0.01, 0.21, 0.12, 0.98, 0.00, 0.00],
                [0.01, 0.01, 0.02, 0.71, 0.74, 0.70, 0.00, 0.00, 0.00]]),
            ('With CO', [
                [0.88, 0.02, 0.02, 0.02, 0.00, 0.05, 0.00, 0.05, 0.00],
                [0.08, 0.94, 0.04, 0.02, 0.00, 0.01, 0.12, 0.04, 0.00],
                [0.01, 0.01, 0.79, 0.10, 0.00, 0.05, 0.00, 0.31, 0.00],
                [0.00, 0.02, 0.03, 0.38, 0.31, 0.31, 0.00, 0.59, 0.00],
                [0.02, 0.02, 0.11, 0.47, 0.69, 0.58, 0.88, 0.00, 0.00]]),
            ('With O3', [
                [0.89, 0.01, 0.07, 0.00, 0.00, 0.05, 0.00, 0.00, 0.03],
                [0.07, 0.95, 0.05, 0.04, 0.00, 0.02, 0.12, 0.00, 0.00],
                [0.01, 0.02, 0.86, 0.27, 0.16, 0.19, 0.00, 0.00, 0.00],
                [0.01, 0.03, 0.00, 0.32, 0.29, 0.27, 0.00, 0.00, 0.95],
                [0.02, 0.00, 0.03, 0.37, 0.56, 0.47, 0.87, 0.00, 0.00]]),
            ('CO & O3', [
                [0.87, 0.01, 0.08, 0.00, 0.00, 0.04, 0.00, 0.00, 0.01],
                [0.09, 0.95, 0.02, 0.03, 0.00, 0.01, 0.13, 0.06, 0.00],
                [0.01, 0.02, 0.71, 0.24, 0.13, 0.16, 0.00, 0.50, 0.00],
                [0.01, 0.03, 0.00, 0.28, 0.24, 0.23, 0.00, 0.44, 0.88],
                [0.02, 0.00, 0.18, 0.45, 0.64, 0.55, 0.86, 0.00, 0.16]])
        ]
        return data
    
    
    if __name__ == '__main__':
        N = 9
        theta = radar_factory(N, frame='polygon')
    
        data = example_data()
        spoke_labels = data.pop(0)
    
        fig, axs = plt.subplots(figsize=(9, 9), nrows=2, ncols=2,
                                subplot_kw=dict(projection='radar'))
        fig.subplots_adjust(wspace=0.25, hspace=0.20, top=0.85, bottom=0.05)
    
        colors = ['b', 'r', 'g', 'm', 'y']
        # Plot the four cases from the example data on separate axes
        for ax, (title, case_data) in zip(axs.flat, data):
            ax.set_rgrids([0.2, 0.4, 0.6, 0.8])
            ax.set_title(title, weight='bold', size='medium', position=(0.5, 1.1),
                         horizontalalignment='center', verticalalignment='center')
            for d, color in zip(case_data, colors):
                ax.plot(theta, d, color=color)
                ax.fill(theta, d, facecolor=color, alpha=0.25, label="_nolegend_")
            ax.set_varlabels(spoke_labels)
    
        # add legend relative to top-left plot
        labels = ('Factor 1', 'Factor 2', 'Factor 3', 'Factor 4', 'Factor 5')
        legend = axs[0, 0].legend(labels, loc=(0.9, .95),
                                  labelspacing=0.1, fontsize='small')
    
        fig.text(0.5, 0.965, '5-Factor Solution Profiles Across Four Scenarios',
                 horizontalalignment='center', color='black', weight='bold',
                 size='large')
    
        plt.show()
  6. anntzer commented on Feb 12, 2022

    @anntzer
    Contributor

    Ah, thanks for figuring this out. I allowed myself to distill this down to something more minimal:

    diff --git i/examples/specialty_plots/radar_chart.py w/examples/specialty_plots/radar_chart.py
    index 93c915893f..81667df28d 100644
    --- i/examples/specialty_plots/radar_chart.py
    +++ w/examples/specialty_plots/radar_chart.py
    @@ -42,11 +42,21 @@ def radar_factory(num_vars, frame='circle'):
         # calculate evenly-spaced axis angles
         theta = np.linspace(0, 2*np.pi, num_vars, endpoint=False)
     
    +    class RadarTransform(PolarAxes.PolarTransform):
    +        def transform_path_non_affine(self, path):
    +            # Paths with non-unit interpolation steps correspond to gridlines,
    +            # in which case we force interpolation (to defeat PolarTransform's
    +            # autoconversion to circular arcs).
    +            if path._interpolation_steps > 1:
    +                path = path.interpolated(num_vars)
    +            return Path(self.transform(path.vertices), path.codes)
    +
         class RadarAxes(PolarAxes):
     
             name = 'radar'
             # use 1 line segment to connect specified points
             RESOLUTION = 1
    +        PolarTransform = RadarTransform
     
             def __init__(self, *args, **kwargs):
                 super().__init__(*args, **kwargs)
    @@ -175,7 +185,7 @@ if __name__ == '__main__':
                          horizontalalignment='center', verticalalignment='center')
             for d, color in zip(case_data, colors):
                 ax.plot(theta, d, color=color)
    -            ax.fill(theta, d, facecolor=color, alpha=0.25)
    +            ax.fill(theta, d, facecolor=color, alpha=0.25, label='_nolegend_')
             ax.set_varlabels(spoke_labels)
     
         # add legend relative to top-left plot

    Assuming this works for you, feel free to make a PR out of it (or I can do it myself, whatever you prefer).

  7. prohde commented on Feb 12, 2022

    @prohde
    ContributorAuthor

    Many thanks @anntzer. I just created the PR.

  8. added this to the v3.5-doc milestone on Feb 15, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions