Kozea / pygal

PYthon svg GrAph plotting Library
https://www.pygal.org
GNU Lesser General Public License v3.0
2.62k stars 411 forks source link

Another combined line/bar plot example. #516

Open rouilj opened 3 years ago

rouilj commented 3 years ago

Since there seems to be a bit of interest in line/bar plotting, I thought I would put my attempt up here. I just started on this last night, so it might not be the best. I am recreating a previous series of plots using pyChart (python 2.x only and abandoned). Here is the original:

plotdash

and my pygal reproduction:

plotdash_pygal

This method builds on #399 and allows passing the graph type by passing a plotas parameter to the add() method. Then I grovel inside self.svg.graph.raw_serie to get the plotas value to determine which method to use.

There are a couple of issues that I had to work around. If I didn't set the range for the plot, the bars started below the 0 axis line. If I didn't add an extra empty label at the end, the bars overlapped the right hand border.

I also made it look a little more like the pyChart display by using inline css:


class LineBar(pygal.Line, pygal.Bar):
    def __init__(self, config=None, **kwargs):
        super(LineBar, self).__init__(config=config, **kwargs)
        self.y_title_secondary = kwargs.get('y_title_secondary')
        self.plotas = kwargs.get('plotas', 'line')

    def _make_y_title(self):
        super(LineBar, self)._make_y_title()

        # Add secondary title
        if self.y_title_secondary:
            yc = self.margin_box.top + self.view.height / 2
            xc = self.width - 10
            text2 = self.svg.node(
                self.nodes['title'], 'text', class_='title',
                x=xc,
                y=yc
            )
            text2.attrib['transform'] = "rotate(%d %f %f)" % (
                -90, xc, yc)
            text2.text = self.y_title_secondary

    def _plot(self):
        for i, serie in enumerate(self.series, 1):
            plottype = self.plotas

            raw_series_params = self.svg.graph.raw_series[serie.index][1]
            if 'plotas' in raw_series_params:
                plottype = raw_series_params['plotas']

            if plottype == 'bar':
                self.bar(serie)
            elif plottype == 'line':
                self.line(serie)
            else:
                raise ValueError('Unknown plottype for %s: %s'%(serie.title, plottype))

        for i, serie in enumerate(self.secondary_series, 1):
            plottype = self.plotas

            raw_series_params = self.svg.graph.raw_series[serie.index][1]
            if 'plotas' in raw_series_params:
                plottype = raw_series_params['plotas']

            if plottype == 'bar':
                self.bar(serie, True)
            elif plottype == 'line':
                self.line(serie, True)
            else:
                raise ValueError('Unknown plottype for %s: %s'%(serie.title, plottype))

# plot a dashboard for month time intervals:
# 1) number of open (backlog) tickets at that time
# 2) number of new tickets in interval
# 3) number of tickets closed in interval
# 4) in seperate graph look at turnaround time for tickets resolved
#    between X and X+T
# 5) in seperate graph look at first contact time for tickets resolved
#    between X and X+T

data = [('Apr 10', 20, 30,  5, 20, 3.2),
        ('May 10', 45, 33,  5, 20, 1.7),
        ('Jun 10', 73, 30, 20, 10, 2.5),
        ('Jul 10', 83, 12, 37, 28, 3.7),
        ('Aug 10', 58, 27, 23, 18, 1.9),
        ('Sep 10', 62, 10, 23, 11, 3.8),
        ('Oct 10', 49, 17, 29, 31, 3.6),
        ('Nov 10', 31, 27, 23, 13, 1.7),
        ('Dec 10', 35, 17, 32, 44, 0.9),
        ('Jan 11', 20, 30,  5, 24, 1.7),
        ('Feb 11', 45, 33,  5, 20, 8.6),
        ('Mar 11', 73, 30, 20, 10, 3.7),
        ('Apr 11', 83, 12, 37, 28, 2.1),]

config = pygal.Config()

# Customize CSS
# Almost all font-* here needs to be !important. Base styles include
#  the #custom-chart-anchor which gives the base setting higher
#  specificy/priority.  I don't know how to get that value so I can
#  add it to the rules. I suspect with code reading I could set some
#  of these using pygal.style....

# make axis titles size smaller
config.css.append('''inline:
  g.titles text.title {
    font-size: 12px !important;
  }''')
# Make plot_title larger and bold
# (override size by placing this later with !important)
config.css.append('''inline:
  g.titles text.title.plot_title {
    font-size: 18px !important;
    font-weight: bold;
  }''')
# shrink legend label text
config.css.append('''inline:
  g.legend text {
    font-size: 10px !important;
  }''')
# move line and points for turnround time plot to middle of the
# three related bars.
# Don't use just g.serie-3 as that gets the value labels as well.
# 12.88 is a magic number. Given the bar width(w) and number of bars (n=3),
#    calculate as: (w*n/2)+(w/2)
config.css.append('''inline:
  g.plot.overlay g.serie-3, g.graph g.serie-3 {
    transform: translate(12.88px,0);
  }''')
# Turn off printed values, I only want it for turnaround time
config.css.append('''inline:
  g.text-overlay text.value {
    display: none;
  }''')
# turn on and style printed values for turnaround time and move it above
# points. Translate values are all magic, no formula.
config.css.append('''inline:
  g.text-overlay g.serie-3 text.value {
    display: block;
    text-anchor: end;
    transform: translate(-3pt,-11pt);
  }''')
# make guide lines lighter, clashes with printed values.
config.css.append('''inline:
  g.guides path.guide.line{
    stroke: rgb(0,0,0,0.25) !important;
  }''')
# If we hover over the label or the line, make line full black.
config.css.append('''inline:
  g.guides:hover path.guide.line {
    stroke: rgb(0,0,0,1) !important;
  }''')

# Would prefer legend_at_bottom = False. So legend is next to correct
# axis for plot. However this pushes the y_title_secondary away from
# the axis.  To compensate, set legend_at_bottom_columns to 3 so first
# row is left axis and second row is right axis. With second axis plot
# showing printed values, this should reduce confusion.

# Make range and secondary range integer mutiples so I end up with
# integer values on both axes.

style=pygal.style.DefaultStyle(value_font_size=8)

chart = LineBar(config,
                width=600,
                height=300,
                title="Tracker Dashboard",
                x_title="Month",
                y_title='Count',
                y_title_secondary='Days',
                legend_at_bottom=True,
                legend_at_bottom_columns=3,
                legend_box_size=10,
                range = (0,90), # Without this the bars start below the bottom axis
                secondary_range=(0,45),
                x_label_rotation=45,
                print_values=True,
                print_values_position='top',
                style=style,
                )

chart.x_labels = [ x[0] for x in data ]
chart.x_labels.append("") # without this the final bars overlap the secondary axis

chart.add("backlog",[ x[1] for x in data] , plotas='bar')
chart.add("new",[ x[2] for x in data] , plotas='bar')
chart.add("resolved", [ x[3] for x in data] , plotas='bar')
chart.add("turnaround time", [ x[4] for x in data] , plotas='line', secondary=True)

chart.render_to_file("plotdash_pygal.svg", pretty_print=True)
Xynonners commented 1 year ago

I just came across this, but my use case was a stackedbar + line system.

After some trial and error with it telling me that positive_cumulation doesn't exist, I found that for python's multi-inheritance to work for LineStackedBar the inheritance must be flipped so pygal.StackedBar is the primary.

CSS required is still the inline translate, but the serie number and px can be changed depending on use case.

rouilj commented 1 year ago

I just came across this, but my use case was a stackedbar + line system.

Nice use case.

After some trial and error with it telling me that positive_cumulation doesn't exist, I found that for python's multi-inheritance to work for LineStackedBar the inheritance must be flipped so pygal.StackedBar is the primary.

Interesting. If only LineStackedBar has that method, method resolution order should should pick it up regardless of the order of the inherited classes.

CSS required is still the inline translate, but the serie number and px can be changed depending on use case.

Can you post your class and an example of using it for anybody else trying to build on this idea.

rixx commented 1 year ago

I'll just drop my version of a LineBar chart here, to avoid opening more issues. My version assumes that all primary data is a line and all secondary data is a bar, to make things easier.

I ran into a whole bunch of CSS/offset issues, especially with the secondary series, that I tried not to hardcode too much, but my code is still quite a bit more hacky/invasive than yours. In the hopes that it helps at least somebody out there:

class LineBar(pygal.Line, pygal.Bar):
    """Class that renders primary data as line, and secondary data as bar."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.secondary_range = kwargs.get("secondary_range")

    def add(self, label, data, **kwargs):
        # We add an empty data point, because otherwise the secondary series (the bar chart)
        # would overlay the axis.
        super().add(label, data + [None], **kwargs)

    def _fix_style(self):
        # We render the plot twice, this time to find the width of a single bar
        # Would that you could just offset things in SVG by percentages without nested SVGs or similar dark magic.
        bar_width = int(
            float(
                self.render_tree().findall(".//*[@class='bar']/rect")[0].attrib["width"]
            )
        )
        line_offset = str(bar_width / 2 + 6)
        bar_offset = str(bar_width + 3)
        added_css = """
          {{ id }} g.series .line  {
            transform: translate({line_offset}px, 0);
          }
          {{ id }} g.series .dots  {
            transform: translate({line_offset}px, 0);
          }
          {{ id }} g.series .bar rect {
            transform: translate(-{bar_offset}px, 0);
          }
          """.replace(
            "{line_offset}", line_offset
        ).replace(
            "{bar_offset}", bar_offset
        )
        # We have to create a tempfile here because pygal only does templating
        # when loading CSS from files. Sadness. Cleanup takes place in render()
        timestamp = int(dt.datetime.now().timestamp())
        custom_css_file = f"/tmp/pygal_custom_style_{timestamp}.css"
        with open(custom_css_file, "w") as f:
            f.write(added_css)
        self.config.css.append("file://" + custom_css_file)

    def _plot(self):
        primary_range = (self.view.box.ymin, self.view.box.ymax)
        real_order = self._order

        if self.secondary_range:
            self.view.box.ymin = self.secondary_range[0]
            self.view.box.ymax = self.secondary_range[1]
        self._order = len(self.secondary_series)
        for i, serie in enumerate(self.secondary_series, 1):
            self.bar(serie, False)

        self._order = real_order
        self.view.box.ymin = primary_range[0]
        self.view.box.ymax = primary_range[1]

        for i, serie in enumerate(self.series, 1):
            self.line(serie)

    def render(self, *args, **kwargs):
        self._fix_style()
        result = super().render(*args, **kwargs)
        # remove all the custom css files
        for css_file in self.config.css:
            if css_file.startswith("file:///tmp"):
                os.remove(css_file[7:])
        return result

Usage is normal:

chart = LineBar(**config)
chart.x_labels = [x[0] for x in data] + [""]  # remember to add an extra label
chart.add("", [x[1] for x in data])  # line data
chart.add("", [x[2] for x in data], secondary=True)  # bar data

A lot of this is optional, but eg. the hacky _order change makes it so that the bars don't grow thinner with added lines, which they otherwise would, etc. The result looks something like this:

2023-06-18T14:38:33+02:00