Browse Source

Add per-serie options. Fixes #124, References #114, Partly addresses #109

113_errors
Florian Mounier 11 years ago
parent
commit
4aa80e69c0
  1. 3
      CHANGELOG
  2. 24
      demo/moulinrouge/tests.py
  3. 164
      pygal/config.py
  4. 10
      pygal/ghost.py
  5. 8
      pygal/graph/bar.py
  6. 10
      pygal/graph/histogram.py
  7. 14
      pygal/graph/line.py
  8. 2
      pygal/graph/pie.py
  9. 5
      pygal/graph/stackedbar.py
  10. 5
      pygal/graph/verticalpyramid.py
  11. 4
      pygal/serie.py
  12. 1
      pygal/test/test_config.py
  13. 62
      pygal/test/test_serie_config.py
  14. 18
      pygal/util.py

3
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)

24
demo/moulinrouge/tests.py

@ -439,4 +439,28 @@ def get_test_routes(app):
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())

164
pygal/config.py

@ -46,7 +46,7 @@ class Key(object):
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 @@ class MetaConfig(type):
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")

10
pygal/ghost.py

@ -26,9 +26,9 @@ It is used to delegate rendering to real objects but keeping config in place
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 @@ class Ghost(object):
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)

8
pygal/graph/bar.py

@ -36,7 +36,8 @@ class Bar(Graph):
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 @@ class Bar(Graph):
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 @@ class Bar(Graph):
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)

10
pygal/graph/histogram.py

@ -67,10 +67,11 @@ class Histogram(Graph):
"""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 @@ class Histogram(Graph):
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)

14
pygal/graph/line.py

@ -66,8 +66,8 @@ class Line(Graph):
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 @@ class Line(Graph):
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 @@ class Line(Graph):
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 @@ class Line(Graph):
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

2
pygal/graph/pie.py

@ -59,7 +59,7 @@ class Pie(Graph):
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,

5
pygal/graph/stackedbar.py

@ -90,7 +90,8 @@ class StackedBar(Bar):
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 @@ class StackedBar(Bar):
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,

5
pygal/graph/verticalpyramid.py

@ -81,8 +81,9 @@ class VerticalPyramid(StackedBar):
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)

4
pygal/serie.py

@ -25,9 +25,11 @@ from pygal.util import cached_property
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

1
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 <http://www.gnu.org/licenses/>.
from pygal import (
Line, Dot, Pie, Radar, Config, Bar, Funnel, Worldmap,
SupranationalWorldmap, Histogram, Gauge, Box,

62
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 <http://www.gnu.org/licenses/>.
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

18
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

Loading…
Cancel
Save