From 4aa80e69c02efab949ab42e3eea66df9c99c6e18 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Tue, 13 May 2014 16:42:38 +0200 Subject: [PATCH] Add per-serie options. Fixes #124, References #114, Partly addresses #109 --- CHANGELOG | 3 + demo/moulinrouge/tests.py | 24 +++++ pygal/config.py | 164 +++++++++++++++++--------------- pygal/ghost.py | 10 +- pygal/graph/bar.py | 8 +- pygal/graph/histogram.py | 10 +- pygal/graph/line.py | 14 +-- pygal/graph/pie.py | 2 +- pygal/graph/stackedbar.py | 5 +- pygal/graph/verticalpyramid.py | 5 +- pygal/serie.py | 4 +- pygal/test/test_config.py | 1 + pygal/test/test_serie_config.py | 62 ++++++++++++ pygal/util.py | 18 ++-- 14 files changed, 223 insertions(+), 107 deletions(-) create mode 100644 pygal/test/test_serie_config.py diff --git a/CHANGELOG b/CHANGELOG index 0ec9c89e..ac6ac157 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,6 @@ +V 1.5.0 UNRELEASED + Add per serie configuration + V 1.4.6 Add support for \n separated multiline titles (thanks sirlark) New show_only_major_dots option (thanks Le-Stagiaire) diff --git a/demo/moulinrouge/tests.py b/demo/moulinrouge/tests.py index 372f759d..1ae3655d 100644 --- a/demo/moulinrouge/tests.py +++ b/demo/moulinrouge/tests.py @@ -439,4 +439,28 @@ def test_major_dots(): line.x_labels_major = ['lol3'] return line.render_response() + @app.route('/test/stroke_config') + def test_stroke_config(): + line = Line() + line.add('test_no_line', range(12), stroke=False) + line.add('test', reversed(range(12))) + line.add('test_no_dots', [5] * 12, show_dots=False) + line.add('test_big_dots', [ + randint(1, 12) for _ in range(12)], dots_size=5) + line.add('test_fill', [ + randint(1, 3) for _ in range(12)], fill=True) + + line.x_labels = [ + 'lol', 'lol1', 'lol2', 'lol3', 'lol4', 'lol5', + 'lol6', 'lol7', 'lol8', 'lol9', 'lol10', 'lol11'] + return line.render_response() + + @app.route('/test/pie_serie_radius') + def test_pie_serie_radius(): + pie = Pie() + for i in range(10): + pie.add(str(i), i, inner_radius=(10 - i) / 10) + + return pie.render_response() + return filter(lambda x: x.startswith('test'), locals()) diff --git a/pygal/config.py b/pygal/config.py index 08f23c97..753402fa 100644 --- a/pygal/config.py +++ b/pygal/config.py @@ -46,7 +46,7 @@ def __init__( self.subdoc = subdoc self.subtype = subtype self.name = "Unbound" - if not category in self._categories: + if category not in self._categories: self._categories.append(category) CONFIG_ITEMS.append(self) @@ -101,7 +101,84 @@ def __new__(mcs, classname, bases, classdict): return type.__new__(mcs, classname, bases, classdict) -class Config(MetaConfig('ConfigBase', (object,), {})): +class BaseConfig(MetaConfig('ConfigBase', (object,), {})): + + def __init__(self, **kwargs): + """Can be instanciated with config kwargs""" + for k in dir(self): + v = getattr(self, k) + if (k not in self.__dict__ and not + k.startswith('_') and not + hasattr(v, '__call__')): + if isinstance(v, Key): + if v.is_list and v.value is not None: + v = list(v.value) + else: + v = v.value + setattr(self, k, v) + self._update(kwargs) + + def __call__(self, **kwargs): + """Can be updated with kwargs""" + self._update(kwargs) + + def _update(self, kwargs): + self.__dict__.update( + dict([(k, v) for (k, v) in kwargs.items() + if not k.startswith('_') and k in dir(self)])) + + def font_sizes(self, with_unit=True): + """Getter for all font size configs""" + fs = FontSizes() + for name in dir(self): + if name.endswith('_font_size'): + setattr( + fs, + name.replace('_font_size', ''), + ('%dpx' % getattr(self, name)) + if with_unit else getattr(self, name)) + return fs + + def to_dict(self): + config = {} + for attr in dir(self): + if not attr.startswith('__'): + value = getattr(self, attr) + if hasattr(value, 'to_dict'): + config[attr] = value.to_dict() + elif not hasattr(value, '__call__'): + config[attr] = value + return config + + def copy(self): + return deepcopy(self) + + +class CommonConfig(BaseConfig): + stroke = Key( + True, bool, "Look", + "Line dots (set it to false to get a scatter plot)") + + show_dots = Key(True, bool, "Look", "Set to false to remove dots") + + show_only_major_dots = Key( + False, bool, "Look", + "Set to true to show only major dots according to their majored label") + + dots_size = Key(2.5, float, "Look", "Radius of the dots") + + fill = Key( + False, bool, "Look", "Fill areas under lines") + + rounded_bars = Key( + None, int, "Look", + "Set this to the desired radius in px (for Bar-like charts)") + + inner_radius = Key( + 0, float, "Look", "Piechart inner radius (donut), must be <.9") + + +class Config(CommonConfig): """Class holding config values""" style = Key( @@ -113,7 +190,7 @@ class Config(MetaConfig('ConfigBase', (object,), {})): "It can be an absolute file path or an external link", str) - ############ Look ############ + # Look # title = Key( None, str, "Look", "Graph title.", "Leave it to None to disable title.") @@ -138,21 +215,6 @@ class Config(MetaConfig('ConfigBase', (object,), {})): show_y_guides = Key(True, bool, "Look", "Set to false to hide y guide lines") - show_dots = Key(True, bool, "Look", "Set to false to remove dots") - - show_only_major_dots = Key( - False, bool, "Look", - "Set to true to show only major dots according to their majored label") - - dots_size = Key(2.5, float, "Look", "Radius of the dots") - - stroke = Key( - True, bool, "Look", - "Line dots (set it to false to get a scatter plot)") - - fill = Key( - False, bool, "Look", "Fill areas under lines") - show_legend = Key( True, bool, "Look", "Set to false to remove legend") @@ -162,9 +224,6 @@ class Config(MetaConfig('ConfigBase', (object,), {})): legend_box_size = Key( 12, int, "Look", "Size of legend boxes") - rounded_bars = Key( - None, int, "Look", "Set this to the desired radius in px") - spacing = Key( 10, int, "Look", "Space between titles/legend/axes") @@ -175,10 +234,7 @@ class Config(MetaConfig('ConfigBase', (object,), {})): tooltip_border_radius = Key(0, int, "Look", "Tooltip border radius") - inner_radius = Key( - 0, float, "Look", "Piechart inner radius (donut), must be <.9") - - ############ Label ############ + # Label # x_labels = Key( None, list, "Label", "X labels, must have same len than data.", @@ -235,7 +291,7 @@ class Config(MetaConfig('ConfigBase', (object,), {})): "%Y-%m-%d %H:%M:%S.%f", str, "Label", "Date format for strftime to display the DateY X labels") - ############ Value ############ + # Value # human_readable = Key( False, bool, "Value", "Display values in human readable format", "(ie: 12.4M)") @@ -274,7 +330,7 @@ class Config(MetaConfig('ConfigBase', (object,), {})): "Set the ordinate zero value", "Useful for filling to another base than abscissa") - ############ Text ############ + # Text # no_data_text = Key( "No data", str, "Text", "Text to display when no data is given") @@ -308,7 +364,7 @@ class Config(MetaConfig('ConfigBase', (object,), {})): None, int, "Text", "Label string length truncation threshold", "None = auto") - ############ Misc ############ + # Misc # js = Key( ('http://kozea.github.com/pygal.js/javascripts/svg.jquery.js', 'http://kozea.github.com/pygal.js/javascripts/pygal-tooltips.js'), @@ -335,52 +391,10 @@ class Config(MetaConfig('ConfigBase', (object,), {})): False, bool, "Misc", "Don't prefix css") - def __init__(self, **kwargs): - """Can be instanciated with config kwargs""" - for k in dir(self): - v = getattr(self, k) - if (k not in self.__dict__ and not - k.startswith('_') and not - hasattr(v, '__call__')): - if isinstance(v, Key): - v = v.value - setattr(self, k, v) - self.css = list(self.css) - self.js = list(self.js) - self._update(kwargs) +class SerieConfig(CommonConfig): + """Class holding serie config values""" - def __call__(self, **kwargs): - """Can be updated with kwargs""" - self._update(kwargs) - - def _update(self, kwargs): - self.__dict__.update( - dict([(k, v) for (k, v) in kwargs.items() - if not k.startswith('_') and k in dir(self)])) - - def font_sizes(self, with_unit=True): - """Getter for all font size configs""" - fs = FontSizes() - for name in dir(self): - if name.endswith('_font_size'): - setattr( - fs, - name.replace('_font_size', ''), - ('%dpx' % getattr(self, name)) - if with_unit else getattr(self, name)) - return fs - - def to_dict(self): - config = {} - for attr in dir(self): - if not attr.startswith('__'): - value = getattr(self, attr) - if hasattr(value, 'to_dict'): - config[attr] = value.to_dict() - elif not hasattr(value, '__call__'): - config[attr] = value - return config - - def copy(self): - return deepcopy(self) + secondary = Key( + False, bool, "Misc", + "Set it to put the serie in a second axis") diff --git a/pygal/ghost.py b/pygal/ghost.py index abd9dee0..d0ebd53c 100644 --- a/pygal/ghost.py +++ b/pygal/ghost.py @@ -26,9 +26,9 @@ from __future__ import division import io import sys -from pygal.config import Config from pygal._compat import u, is_list_like from pygal.graph import CHARTS_NAMES +from pygal.config import Config, SerieConfig from pygal.util import prepare_values from uuid import uuid4 @@ -73,14 +73,14 @@ def __init__(self, config=None, **kwargs): self.raw_series2 = [] self.xml_filters = [] - def add(self, title, values, secondary=False): + def add(self, title, values, **kwargs): """Add a serie to this graph""" if not is_list_like(values) and not isinstance(values, dict): values = [values] - if secondary: - self.raw_series2.append((title, values)) + if kwargs.get('secondary', False): + self.raw_series2.append((title, values, kwargs)) else: - self.raw_series.append((title, values)) + self.raw_series.append((title, values, kwargs)) def add_xml_filter(self, callback): self.xml_filters.append(callback) diff --git a/pygal/graph/bar.py b/pygal/graph/bar.py index a02c71df..26abb15e 100644 --- a/pygal/graph/bar.py +++ b/pygal/graph/bar.py @@ -36,7 +36,8 @@ def __init__(self, *args, **kwargs): self._x_ranges = None super(Bar, self).__init__(*args, **kwargs) - def _bar(self, parent, x, y, index, i, zero, shift=True, secondary=False): + def _bar(self, parent, x, y, index, i, zero, + shift=True, secondary=False, rounded=False): width = (self.view.x(1) - self.view.x(0)) / self._len x, y = self.view((x, y)) series_margin = width * self._series_margin @@ -49,7 +50,7 @@ def _bar(self, parent, x, y, index, i, zero, shift=True, secondary=False): x += serie_margin width -= 2 * serie_margin height = self.view.y(zero) - y - r = self.rounded_bars * 1 if self.rounded_bars else 0 + r = rounded * 1 if rounded else 0 self.svg.transposable_node( parent, 'rect', x=x, y=y, rx=r, ry=r, width=width, height=height, @@ -79,7 +80,8 @@ def bar(self, serie_node, serie, index, rescale=False): val = self._format(serie.values[i]) x_center, y_center = self._bar( - bar, x, y, index, i, self.zero, secondary=rescale) + bar, x, y, index, i, self.zero, secondary=rescale, + rounded=serie.rounded_bars) self._tooltip_data( bar, val, x_center, y_center, classes="centered") self._static_value(serie_node, val, x_center, y_center) diff --git a/pygal/graph/histogram.py b/pygal/graph/histogram.py index 16c2f007..7db4175e 100644 --- a/pygal/graph/histogram.py +++ b/pygal/graph/histogram.py @@ -67,10 +67,11 @@ def _has_data(self): """Check if there is any data""" return sum( map(len, map(lambda s: s.safe_values, self.series))) != 0 and any(( - sum(map(abs, self.xvals)) != 0, - sum(map(abs, self.yvals)) != 0)) + sum(map(abs, self.xvals)) != 0, + sum(map(abs, self.yvals)) != 0)) - def _bar(self, parent, x0, x1, y, index, i, zero, secondary=False): + def _bar(self, parent, x0, x1, y, index, i, zero, + secondary=False, rounded=False): x, y = self.view((x0, y)) x1, _ = self.view((x1, y)) width = x1 - x @@ -104,7 +105,8 @@ def bar(self, serie_node, serie, index, rescale=False): val = self._format(serie.values[i][0]) x_center, y_center = self._bar( - bar, x0, x1, y, index, i, self.zero, secondary=rescale) + bar, x0, x1, y, index, i, self.zero, secondary=rescale, + rounded=serie.rounded_bars) self._tooltip_data( bar, val, x_center, y_center, classes="centered") self._static_value(serie_node, val, x_center, y_center) diff --git a/pygal/graph/line.py b/pygal/graph/line.py index bf34ef6e..cc69bdd2 100644 --- a/pygal/graph/line.py +++ b/pygal/graph/line.py @@ -66,8 +66,8 @@ def line(self, serie_node, serie, rescale=False): else: points = serie.points view_values = list(map(self.view, points)) - if self.show_dots: - if self.show_only_major_dots: + if serie.show_dots: + if serie.show_only_major_dots: major_dots_index = [] if self.x_labels: if self.x_labels_major: @@ -88,7 +88,7 @@ def line(self, serie_node, serie, rescale=False): 0, len(self.x_labels), self.x_labels_major_every) for i, (x, y) in enumerate(view_values): - if None in (x, y) or (self.show_only_major_dots + if None in (x, y) or (serie.show_only_major_dots and i not in major_dots_index): continue metadata = serie.metadata.get(i) @@ -103,7 +103,7 @@ def line(self, serie_node, serie, rescale=False): self.svg.node(serie_node['overlay'], class_="dots"), metadata) val = self._get_value(serie.points, i) - self.svg.node(dots, 'circle', cx=x, cy=y, r=self.dots_size, + self.svg.node(dots, 'circle', cx=x, cy=y, r=serie.dots_size, class_='dot reactive tooltip-trigger') self._tooltip_data( dots, val, x, y) @@ -112,14 +112,14 @@ def line(self, serie_node, serie, rescale=False): x + self.value_font_size, y + self.value_font_size) - if self.stroke: + if serie.stroke: if self.interpolate: view_values = list(map(self.view, serie.interpolated)) - if self.fill: + if serie.fill: view_values = self._fill(view_values) self.svg.line( serie_node['plot'], view_values, close=self._self_close, - class_='line reactive' + (' nofill' if not self.fill else '')) + class_='line reactive' + (' nofill' if not serie.fill else '')) def _compute(self): # X Labels diff --git a/pygal/graph/pie.py b/pygal/graph/pie.py index f9c216d0..54e9159f 100644 --- a/pygal/graph/pie.py +++ b/pygal/graph/pie.py @@ -59,7 +59,7 @@ def slice(self, serie_node, start_angle, serie, total): big_radius = radius else: big_radius = radius * .9 - small_radius = radius * self.config.inner_radius + small_radius = radius * serie.inner_radius self.svg.slice( serie_node, slice_, big_radius, small_radius, diff --git a/pygal/graph/stackedbar.py b/pygal/graph/stackedbar.py index 8bd129c0..2b22e656 100644 --- a/pygal/graph/stackedbar.py +++ b/pygal/graph/stackedbar.py @@ -90,7 +90,8 @@ def _compute(self): self._secondary_max = (positive_vals and max( sum_(max(positive_vals)), self.zero)) or self.zero - def _bar(self, parent, x, y, index, i, zero, shift=False, secondary=False): + def _bar(self, parent, x, y, index, i, zero, + shift=False, secondary=False, rounded=False): if secondary: cumulation = (self.secondary_negative_cumulation if y < self.zero else @@ -118,7 +119,7 @@ def _bar(self, parent, x, y, index, i, zero, shift=False, secondary=False): x += serie_margin width -= 2 * serie_margin height = self.view.y(zero) - y - r = self.rounded_bars * 1 if self.rounded_bars else 0 + r = rounded * 1 if rounded else 0 self.svg.transposable_node( parent, 'rect', x=x, y=y, rx=r, ry=r, width=width, height=height, diff --git a/pygal/graph/verticalpyramid.py b/pygal/graph/verticalpyramid.py index 7c52ac90..6db4860b 100644 --- a/pygal/graph/verticalpyramid.py +++ b/pygal/graph/verticalpyramid.py @@ -81,8 +81,9 @@ def _compute_secondary(self): y) for y in y_pos] - def _bar(self, parent, x, y, index, i, zero, shift=True, secondary=False): + def _bar(self, parent, x, y, index, i, zero, + shift=True, secondary=False, rounded=False): if index % 2: y = -y return super(VerticalPyramid, self)._bar( - parent, x, y, index, i, zero, False, secondary) + parent, x, y, index, i, zero, False, secondary, rounded) diff --git a/pygal/serie.py b/pygal/serie.py index d3636b57..f48479c9 100644 --- a/pygal/serie.py +++ b/pygal/serie.py @@ -25,9 +25,11 @@ class Serie(object): """Serie containing title, values and the graph serie index""" - def __init__(self, title, values, metadata=None): + def __init__(self, title, values, config, metadata=None): self.title = title self.values = values + self.config = config + self.__dict__.update(config.to_dict()) self.metadata = metadata or {} @cached_property diff --git a/pygal/test/test_config.py b/pygal/test/test_config.py index 6510caec..b8ee2539 100644 --- a/pygal/test/test_config.py +++ b/pygal/test/test_config.py @@ -16,6 +16,7 @@ # # You should have received a copy of the GNU Lesser General Public License # along with pygal. If not, see . + from pygal import ( Line, Dot, Pie, Radar, Config, Bar, Funnel, Worldmap, SupranationalWorldmap, Histogram, Gauge, Box, diff --git a/pygal/test/test_serie_config.py b/pygal/test/test_serie_config.py new file mode 100644 index 00000000..f2f09691 --- /dev/null +++ b/pygal/test/test_serie_config.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2014 Kozea +# +# This library is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This library is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with pygal. If not, see . + +from pygal.test import pytest_generate_tests +from pygal import Line + + +def test_serie_config(): + s1 = [1, 3, 12, 3, 4] + s2 = [7, -4, 10, None, 8, 3, 1] + + chart = Line() + chart.add('1', s1) + chart.add('2', s2) + q = chart.render_pyquery() + assert len(q('.serie-0 .line')) == 1 + assert len(q('.serie-1 .line')) == 1 + assert len(q('.serie-0 .dot')) == 5 + assert len(q('.serie-1 .dot')) == 6 + + chart = Line(stroke=False) + chart.add('1', s1) + chart.add('2', s2) + q = chart.render_pyquery() + assert len(q('.serie-0 .line')) == 0 + assert len(q('.serie-1 .line')) == 0 + assert len(q('.serie-0 .dot')) == 5 + assert len(q('.serie-1 .dot')) == 6 + + chart = Line() + chart.add('1', s1, stroke=False) + chart.add('2', s2) + q = chart.render_pyquery() + assert len(q('.serie-0 .line')) == 0 + assert len(q('.serie-1 .line')) == 1 + assert len(q('.serie-0 .dot')) == 5 + assert len(q('.serie-1 .dot')) == 6 + + chart = Line(stroke=False) + chart.add('1', s1, stroke=True) + chart.add('2', s2) + q = chart.render_pyquery() + assert len(q('.serie-0 .line')) == 1 + assert len(q('.serie-1 .line')) == 0 + assert len(q('.serie-0 .dot')) == 5 + assert len(q('.serie-1 .dot')) == 6 diff --git a/pygal/util.py b/pygal/util.py index a0608a9f..4607cdee 100644 --- a/pygal/util.py +++ b/pygal/util.py @@ -318,11 +318,11 @@ def safe_enumerate(iterable): if v is not None: yield i, v -from pygal.serie import Serie - def prepare_values(raw, config, cls): """Prepare the values to start with sane values""" + from pygal.serie import Serie + from pygal.config import SerieConfig from pygal.graph.datey import DateY from pygal.graph.histogram import Histogram from pygal.graph.worldmap import Worldmap @@ -349,13 +349,14 @@ def prepare_values(raw, config, cls): raw = [( title, - list(raw_values) if not isinstance(raw_values, dict) else raw_values - ) for title, raw_values in raw] + list(raw_values) if not isinstance(raw_values, dict) else raw_values, + serie_config_kwargs + ) for title, raw_values, serie_config_kwargs in raw] - width = max([len(values) for _, values in raw] + + width = max([len(values) for _, values, _ in raw] + [len(config.x_labels or [])]) - for title, raw_values in raw: + for title, raw_values, serie_config_kwargs in raw: metadata = {} values = [] if isinstance(raw_values, dict): @@ -399,7 +400,10 @@ def prepare_values(raw, config, cls): else: value = adapter(value) values.append(value) - series.append(Serie(title, values, metadata)) + serie_config = SerieConfig() + serie_config(**config.to_dict()) + serie_config(**serie_config_kwargs) + series.append(Serie(title, values, serie_config, metadata)) return series