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. 6
      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 V 1.4.6
Add support for \n separated multiline titles (thanks sirlark) Add support for \n separated multiline titles (thanks sirlark)
New show_only_major_dots option (thanks Le-Stagiaire) 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'] line.x_labels_major = ['lol3']
return line.render_response() 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()) return filter(lambda x: x.startswith('test'), locals())

164
pygal/config.py

@ -46,7 +46,7 @@ class Key(object):
self.subdoc = subdoc self.subdoc = subdoc
self.subtype = subtype self.subtype = subtype
self.name = "Unbound" self.name = "Unbound"
if not category in self._categories: if category not in self._categories:
self._categories.append(category) self._categories.append(category)
CONFIG_ITEMS.append(self) CONFIG_ITEMS.append(self)
@ -101,7 +101,84 @@ class MetaConfig(type):
return type.__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""" """Class holding config values"""
style = Key( style = Key(
@ -113,7 +190,7 @@ class Config(MetaConfig('ConfigBase', (object,), {})):
"It can be an absolute file path or an external link", "It can be an absolute file path or an external link",
str) str)
############ Look ############ # Look #
title = Key( title = Key(
None, str, "Look", None, str, "Look",
"Graph title.", "Leave it to None to disable title.") "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", show_y_guides = Key(True, bool, "Look",
"Set to false to hide y guide lines") "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( show_legend = Key(
True, bool, "Look", "Set to false to remove legend") True, bool, "Look", "Set to false to remove legend")
@ -162,9 +224,6 @@ class Config(MetaConfig('ConfigBase', (object,), {})):
legend_box_size = Key( legend_box_size = Key(
12, int, "Look", "Size of legend boxes") 12, int, "Look", "Size of legend boxes")
rounded_bars = Key(
None, int, "Look", "Set this to the desired radius in px")
spacing = Key( spacing = Key(
10, int, "Look", 10, int, "Look",
"Space between titles/legend/axes") "Space between titles/legend/axes")
@ -175,10 +234,7 @@ class Config(MetaConfig('ConfigBase', (object,), {})):
tooltip_border_radius = Key(0, int, "Look", "Tooltip border radius") tooltip_border_radius = Key(0, int, "Look", "Tooltip border radius")
inner_radius = Key( # Label #
0, float, "Look", "Piechart inner radius (donut), must be <.9")
############ Label ############
x_labels = Key( x_labels = Key(
None, list, "Label", None, list, "Label",
"X labels, must have same len than data.", "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", "%Y-%m-%d %H:%M:%S.%f", str, "Label",
"Date format for strftime to display the DateY X labels") "Date format for strftime to display the DateY X labels")
############ Value ############ # Value #
human_readable = Key( human_readable = Key(
False, bool, "Value", "Display values in human readable format", False, bool, "Value", "Display values in human readable format",
"(ie: 12.4M)") "(ie: 12.4M)")
@ -274,7 +330,7 @@ class Config(MetaConfig('ConfigBase', (object,), {})):
"Set the ordinate zero value", "Set the ordinate zero value",
"Useful for filling to another base than abscissa") "Useful for filling to another base than abscissa")
############ Text ############ # Text #
no_data_text = Key( no_data_text = Key(
"No data", str, "Text", "Text to display when no data is given") "No data", str, "Text", "Text to display when no data is given")
@ -308,7 +364,7 @@ class Config(MetaConfig('ConfigBase', (object,), {})):
None, int, "Text", None, int, "Text",
"Label string length truncation threshold", "None = auto") "Label string length truncation threshold", "None = auto")
############ Misc ############ # Misc #
js = Key( js = Key(
('http://kozea.github.com/pygal.js/javascripts/svg.jquery.js', ('http://kozea.github.com/pygal.js/javascripts/svg.jquery.js',
'http://kozea.github.com/pygal.js/javascripts/pygal-tooltips.js'), 'http://kozea.github.com/pygal.js/javascripts/pygal-tooltips.js'),
@ -335,52 +391,10 @@ class Config(MetaConfig('ConfigBase', (object,), {})):
False, bool, "Misc", False, bool, "Misc",
"Don't prefix css") "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)
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): class SerieConfig(CommonConfig):
config = {} """Class holding serie config values"""
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): secondary = Key(
return deepcopy(self) 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 from __future__ import division
import io import io
import sys import sys
from pygal.config import Config
from pygal._compat import u, is_list_like from pygal._compat import u, is_list_like
from pygal.graph import CHARTS_NAMES from pygal.graph import CHARTS_NAMES
from pygal.config import Config, SerieConfig
from pygal.util import prepare_values from pygal.util import prepare_values
from uuid import uuid4 from uuid import uuid4
@ -73,14 +73,14 @@ class Ghost(object):
self.raw_series2 = [] self.raw_series2 = []
self.xml_filters = [] self.xml_filters = []
def add(self, title, values, secondary=False): def add(self, title, values, **kwargs):
"""Add a serie to this graph""" """Add a serie to this graph"""
if not is_list_like(values) and not isinstance(values, dict): if not is_list_like(values) and not isinstance(values, dict):
values = [values] values = [values]
if secondary: if kwargs.get('secondary', False):
self.raw_series2.append((title, values)) self.raw_series2.append((title, values, kwargs))
else: else:
self.raw_series.append((title, values)) self.raw_series.append((title, values, kwargs))
def add_xml_filter(self, callback): def add_xml_filter(self, callback):
self.xml_filters.append(callback) self.xml_filters.append(callback)

8
pygal/graph/bar.py

@ -36,7 +36,8 @@ class Bar(Graph):
self._x_ranges = None self._x_ranges = None
super(Bar, self).__init__(*args, **kwargs) 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 width = (self.view.x(1) - self.view.x(0)) / self._len
x, y = self.view((x, y)) x, y = self.view((x, y))
series_margin = width * self._series_margin series_margin = width * self._series_margin
@ -49,7 +50,7 @@ class Bar(Graph):
x += serie_margin x += serie_margin
width -= 2 * serie_margin width -= 2 * serie_margin
height = self.view.y(zero) - y 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( self.svg.transposable_node(
parent, 'rect', parent, 'rect',
x=x, y=y, rx=r, ry=r, width=width, height=height, 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]) val = self._format(serie.values[i])
x_center, y_center = self._bar( 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( self._tooltip_data(
bar, val, x_center, y_center, classes="centered") bar, val, x_center, y_center, classes="centered")
self._static_value(serie_node, val, x_center, y_center) self._static_value(serie_node, val, x_center, y_center)

6
pygal/graph/histogram.py

@ -70,7 +70,8 @@ class Histogram(Graph):
sum(map(abs, self.xvals)) != 0, sum(map(abs, self.xvals)) != 0,
sum(map(abs, self.yvals)) != 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)) x, y = self.view((x0, y))
x1, _ = self.view((x1, y)) x1, _ = self.view((x1, y))
width = x1 - x width = x1 - x
@ -104,7 +105,8 @@ class Histogram(Graph):
val = self._format(serie.values[i][0]) val = self._format(serie.values[i][0])
x_center, y_center = self._bar( 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( self._tooltip_data(
bar, val, x_center, y_center, classes="centered") bar, val, x_center, y_center, classes="centered")
self._static_value(serie_node, val, x_center, y_center) self._static_value(serie_node, val, x_center, y_center)

14
pygal/graph/line.py

@ -66,8 +66,8 @@ class Line(Graph):
else: else:
points = serie.points points = serie.points
view_values = list(map(self.view, points)) view_values = list(map(self.view, points))
if self.show_dots: if serie.show_dots:
if self.show_only_major_dots: if serie.show_only_major_dots:
major_dots_index = [] major_dots_index = []
if self.x_labels: if self.x_labels:
if self.x_labels_major: if self.x_labels_major:
@ -88,7 +88,7 @@ class Line(Graph):
0, len(self.x_labels), self.x_labels_major_every) 0, len(self.x_labels), self.x_labels_major_every)
for i, (x, y) in enumerate(view_values): 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): and i not in major_dots_index):
continue continue
metadata = serie.metadata.get(i) metadata = serie.metadata.get(i)
@ -103,7 +103,7 @@ class Line(Graph):
self.svg.node(serie_node['overlay'], class_="dots"), self.svg.node(serie_node['overlay'], class_="dots"),
metadata) metadata)
val = self._get_value(serie.points, i) 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') class_='dot reactive tooltip-trigger')
self._tooltip_data( self._tooltip_data(
dots, val, x, y) dots, val, x, y)
@ -112,14 +112,14 @@ class Line(Graph):
x + self.value_font_size, x + self.value_font_size,
y + self.value_font_size) y + self.value_font_size)
if self.stroke: if serie.stroke:
if self.interpolate: if self.interpolate:
view_values = list(map(self.view, serie.interpolated)) view_values = list(map(self.view, serie.interpolated))
if self.fill: if serie.fill:
view_values = self._fill(view_values) view_values = self._fill(view_values)
self.svg.line( self.svg.line(
serie_node['plot'], view_values, close=self._self_close, 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): def _compute(self):
# X Labels # X Labels

2
pygal/graph/pie.py

@ -59,7 +59,7 @@ class Pie(Graph):
big_radius = radius big_radius = radius
else: else:
big_radius = radius * .9 big_radius = radius * .9
small_radius = radius * self.config.inner_radius small_radius = radius * serie.inner_radius
self.svg.slice( self.svg.slice(
serie_node, slice_, big_radius, small_radius, 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( self._secondary_max = (positive_vals and max(
sum_(max(positive_vals)), self.zero)) or self.zero 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: if secondary:
cumulation = (self.secondary_negative_cumulation cumulation = (self.secondary_negative_cumulation
if y < self.zero else if y < self.zero else
@ -118,7 +119,7 @@ class StackedBar(Bar):
x += serie_margin x += serie_margin
width -= 2 * serie_margin width -= 2 * serie_margin
height = self.view.y(zero) - y 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( self.svg.transposable_node(
parent, 'rect', parent, 'rect',
x=x, y=y, rx=r, ry=r, width=width, height=height, 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) y)
for y in y_pos] 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: if index % 2:
y = -y y = -y
return super(VerticalPyramid, self)._bar( 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): class Serie(object):
"""Serie containing title, values and the graph serie index""" """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.title = title
self.values = values self.values = values
self.config = config
self.__dict__.update(config.to_dict())
self.metadata = metadata or {} self.metadata = metadata or {}
@cached_property @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 # You should have received a copy of the GNU Lesser General Public License
# along with pygal. If not, see <http://www.gnu.org/licenses/>. # along with pygal. If not, see <http://www.gnu.org/licenses/>.
from pygal import ( from pygal import (
Line, Dot, Pie, Radar, Config, Bar, Funnel, Worldmap, Line, Dot, Pie, Radar, Config, Bar, Funnel, Worldmap,
SupranationalWorldmap, Histogram, Gauge, Box, 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: if v is not None:
yield i, v yield i, v
from pygal.serie import Serie
def prepare_values(raw, config, cls): def prepare_values(raw, config, cls):
"""Prepare the values to start with sane values""" """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.datey import DateY
from pygal.graph.histogram import Histogram from pygal.graph.histogram import Histogram
from pygal.graph.worldmap import Worldmap from pygal.graph.worldmap import Worldmap
@ -349,13 +349,14 @@ def prepare_values(raw, config, cls):
raw = [( raw = [(
title, title,
list(raw_values) if not isinstance(raw_values, dict) else raw_values list(raw_values) if not isinstance(raw_values, dict) else raw_values,
) for title, raw_values in raw] 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 [])]) [len(config.x_labels or [])])
for title, raw_values in raw: for title, raw_values, serie_config_kwargs in raw:
metadata = {} metadata = {}
values = [] values = []
if isinstance(raw_values, dict): if isinstance(raw_values, dict):
@ -399,7 +400,10 @@ def prepare_values(raw, config, cls):
else: else:
value = adapter(value) value = adapter(value)
values.append(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 return series

Loading…
Cancel
Save