From d11aa40600442dfe3401351216333d84f21236b8 Mon Sep 17 00:00:00 2001 From: Jean-Marc Martins Date: Tue, 13 May 2014 12:20:10 +0200 Subject: [PATCH] Broken errors are broken --- pygal/config.py | 2 + pygal/css/style.css | 4 + pygal/graph/#box.py# | 208 +++++++++++++++++++++++++++ pygal/graph/#pie.py# | 87 +++++++++++ pygal/graph/bar.py | 24 +++- pygal/graph/base.py | 20 ++- pygal/graph/datey.py | 21 +-- pygal/graph/frenchmap.py | 6 +- pygal/graph/graph.py | 18 ++- pygal/graph/histogram.py | 12 +- pygal/graph/line.py | 12 ++ pygal/graph/stackedbar.py | 6 +- pygal/graph/supranationalworldmap.py | 6 +- pygal/graph/verticalpyramid.py | 5 +- pygal/graph/worldmap.py | 8 +- pygal/graph/xy.py | 9 +- pygal/serie.py | 81 ++++++++++- pygal/svg.py | 29 ++++ pygal/util.py | 14 +- 19 files changed, 519 insertions(+), 53 deletions(-) create mode 100644 pygal/graph/#box.py# create mode 100644 pygal/graph/#pie.py# diff --git a/pygal/config.py b/pygal/config.py index 08f23c9..9f225cc 100644 --- a/pygal/config.py +++ b/pygal/config.py @@ -126,6 +126,8 @@ class Config(MetaConfig('ConfigBase', (object,), {})): None, str, "Look", "Graph Y-Axis title.", "Leave it to None to disable Y-Axis title.") + y_errors = Key(False, bool, "Look", "Set to True to display y-errors.") + width = Key( 800, int, "Look", "Graph width") diff --git a/pygal/css/style.css b/pygal/css/style.css index bc8f31e..5784964 100644 --- a/pygal/css/style.css +++ b/pygal/css/style.css @@ -129,6 +129,10 @@ stroke-width: 10; } +{{ id }}.err_marks .errors { + stroke: {{ style.foreground_dark }}; +} + {{ colors }} diff --git a/pygal/graph/#box.py# b/pygal/graph/#box.py# new file mode 100644 index 0000000..de928e0 --- /dev/null +++ b/pygal/graph/#box.py# @@ -0,0 +1,208 @@ +# -*- 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 . +""" +Box plot +""" + +from __future__ import division +from pygal.graph.graph import Graph +from pygal.util import compute_scale, decorate +from pygal._compat import is_list_like + + +class Box(Graph): + """ + Box plot + For each series, shows the median value, the 25th and 75th percentiles, + and the values within + 1.5 times the interquartile range of the 25th and 75th percentiles. + + See http://en.wikipedia.org/wiki/Box_plot + """ + _series_margin = .06 + + def __init__(self, *args, **kwargs): + super(Box, self).__init__(*args, **kwargs) + + @property + def _format(self): + """Return the value formatter for this graph""" + sup = super(Box, self)._format + + def format_maybe_quartile(x): + if is_list_like(x): + if len(x) == 5: + return 'Q1: %s Q2: %s Q3: %s' % tuple(map(sup, x[1:4])) + else: + return sup(x) + return format_maybe_quartile + + def _compute(self): + """ + Compute parameters necessary for later steps + within the rendering process + """ + for serie in self.series: + serie.values = self._box_points(serie.values) + + if self._min: + self._box.ymin = min(self._min, self.zero) + if self._max: + self._box.ymax = max(self._max, self.zero) + + x_pos = [ + x / self._len for x in range(self._len + 1) + ] if self._len > 1 else [0, 1] # Center if only one value + + self._points(x_pos) + + y_pos = compute_scale( + self._box.ymin, self._box.ymax, self.logarithmic, self.order_min + ) if not self.y_labels else list(map(float, self.y_labels)) + + self._x_labels = self.x_labels and list(zip(self.x_labels, [ + (i + .5) / self._order for i in range(self._order)])) + self._y_labels = list(zip(map(self._format, y_pos), y_pos)) + + def _plot(self): + """ + Plot the series data + """ + for index, serie in enumerate(self.series): + self._boxf(self._serie(index), serie, index) + + def _boxf(self, serie_node, serie, index): + """ + For a specific series, draw the box plot. + """ + # Note: q0 and q4 do not literally mean the zero-th quartile + # and the fourth quartile, but rather the distance from 1.5 times + # the inter-quartile range to Q1 and Q3, respectively. + boxes = self.svg.node(serie_node['plot'], class_="boxes") + + metadata = serie.metadata.get(0) + + box = decorate( + self.svg, + self.svg.node(boxes, class_='box'), + metadata) + val = self._format(serie.values) + + x_center, y_center = self._draw_box(box, serie.values, index) + self._tooltip_data(box, val, x_center, y_center, classes="centered") + self._static_value(serie_node, val, x_center, y_center) + + def _draw_box(self, parent_node, quartiles, box_index): + """ + Return the center of a bounding box defined by a box plot. + Draws a box plot on self.svg. + """ + width = (self.view.x(1) - self.view.x(0)) / self._order + series_margin = width * self._series_margin + left_edge = self.view.x(0) + width * box_index + series_margin + width -= 2 * series_margin + + # draw lines for whiskers - bottom, median, and top + for i, whisker in enumerate( + (quartiles[0], quartiles[2], quartiles[4])): + whisker_width = width if i == 1 else width / 2 + shift = (width - whisker_width) / 2 + xs = left_edge + shift + xe = left_edge + width - shift + self.svg.line( + parent_node, + coords=[(xs, self.view.y(whisker)), + (xe, self.view.y(whisker))], + class_='reactive tooltip-trigger', + attrib={'stroke-width': 3}) + + # draw lines connecting whiskers to box (Q1 and Q3) + self.svg.line( + parent_node, + coords=[(left_edge + width / 2, self.view.y(quartiles[0])), + (left_edge + width / 2, self.view.y(quartiles[1]))], + class_='reactive tooltip-trigger', + attrib={'stroke-width': 2}) + self.svg.line( + parent_node, + coords=[(left_edge + width / 2, self.view.y(quartiles[4])), + (left_edge + width / 2, self.view.y(quartiles[3]))], + class_='reactive tooltip-trigger', + attrib={'stroke-width': 2}) + + # box, bounded by Q1 and Q3 + self.svg.node( + parent_node, + tag='rect', + x=left_edge, + y=self.view.y(quartiles[1]), + height=self.view.y(quartiles[3]) - self.view.y(quartiles[1]), + width=width, + class_='subtle-fill reactive tooltip-trigger') + + return (left_edge + width / 2, self.view.y( + sum(quartiles) / len(quartiles))) + + @staticmethod + def _box_points(values): + """ + Return a 5-tuple of Q1 - 1.5 * IQR, Q1, Median, Q3, + and Q3 + 1.5 * IQR for a list of numeric values. + + The iterator values may include None values. + + Uses quartile definition from Mendenhall, W. and + Sincich, T. L. Statistics for Engineering and the + Sciences, 4th ed. Prentice-Hall, 1995. + """ + def median(seq): + n = len(seq) + if n % 2 == 0: # seq has an even length + return (seq[n // 2] + seq[n // 2 - 1]) / 2 + else: # seq has an odd length + return seq[n // 2] + + # sort the copy in case the originals must stay in original order + s = sorted([x for x in values if x is not None]) + n = len(s) + if not n: + return 0, 0, 0, 0, 0 + else: + q2 = median(s) + # See 'Method 3' in http://en.wikipedia.org/wiki/Quartile + if n % 2 == 0: # even + q1 = median(s[:n // 2]) + q3 = median(s[n // 2:]) + else: # odd + if n == 1: # special case + q1 = s[0] + q3 = s[0] + elif n % 4 == 1: # n is of form 4n + 1 where n >= 1 + m = (n - 1) // 4 + q1 = 0.25 * s[m-1] + 0.75 * s[m] + q3 = 0.75 * s[3*m] + 0.25 * s[3*m + 1] + else: # n is of form 4n + 3 where n >= 1 + m = (n - 3) // 4 + q1 = 0.75 * s[m] + 0.25 * s[m+1] + q3 = 0.25 * s[3*m+1] + 0.75 * s[3*m+2] + + iqr = q3 - q1 + q0 = q1 - 1.5 * iqr + q4 = q3 + 1.5 * iqr + return q0, q1, q2, q3, q4 diff --git a/pygal/graph/#pie.py# b/pygal/graph/#pie.py# new file mode 100644 index 0000000..3e0826e --- /dev/null +++ b/pygal/graph/#pie.py# @@ -0,0 +1,87 @@ +# -*- 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 . +""" +Pie chart + +""" + +from __future__ import division +from pygal.util import decorate +from pygal.graph.graph import Graph +from pygal.adapters import positive, none_to_zero +from math import pi + + +class Pie(Graph): + """Pie graph""" + + _adapters = [positive, none_to_zero] + + def slice(self, serie_node, start_angle, serie, total): + """Make a serie slice""" + dual = self._len > 1 and not self._order == 1 + + slices = self.svg.node(serie_node['plot'], class_="slices") + serie_angle = 0 + total_perc = 0 + original_start_angle = start_angle + center = ((self.width - self.margin.x) / 2., + (self.height - self.margin.y) / 2.) + radius = min(center) + for i, val in enumerate(serie.values): + perc = val / total + angle = 2 * pi * perc + serie_angle += angle + val = '{0:.2%}'.format(perc) + metadata = serie.metadata.get(i) + slice_ = decorate( + self.svg, + self.svg.node(slices, class_="slice"), + metadata) + if dual: + small_radius = radius * .9 + big_radius = radius + else: + big_radius = radius * .9 + small_radius = radius * self.config.inner_radius + + self.svg.slice( + serie_node, slice_, big_radius, small_radius, + angle, start_angle, center, val) + start_angle += angle + total_perc += perc + + if dual: + val = '{0:.2%}'.format(total_perc) + self.svg.slice(serie_node, + self.svg.node(slices, class_="big_slice"), + radius * .9, 0, serie_angle, + original_start_angle, center, val) + return serie_angle + + def _plot(self): + total = sum(map(sum, map(lambda x: x.values, self.series))) + + if total == 0: + return + current_angle = 0 + for index, serie in enumerate(self.series): + angle = self.slice( + self._serie(index), current_angle, serie, total) + current_angle += angle diff --git a/pygal/graph/bar.py b/pygal/graph/bar.py index a02c71d..879f272 100644 --- a/pygal/graph/bar.py +++ b/pygal/graph/bar.py @@ -23,7 +23,8 @@ Bar chart from __future__ import division from pygal.graph.graph import Graph -from pygal.util import swap, ident, compute_scale, decorate +from pygal.util import compute_scale, decorate +from pygal.serie import NestedSerie class Bar(Graph): @@ -36,8 +37,10 @@ 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, errors_node, shift=True, + secondary=False, nested=None): width = (self.view.x(1) - self.view.x(0)) / self._len + x, y = self.view((x, y)) series_margin = width * self._series_margin x += series_margin @@ -54,12 +57,17 @@ class Bar(Graph): parent, 'rect', x=x, y=y, rx=r, ry=r, width=width, height=height, class_='rect reactive tooltip-trigger') - transpose = swap if self.horizontal else ident - return transpose((x + width / 2, y + height / 2)) + transpose = self._transpose() + centers = transpose((x + width / 2, y + height / 2)) + if nested: + error_coords = (self.view.y(nested.min), self.view.y(nested.max)) + self._draw_error_marks(errors_node, x, error_coords, width, index) + return centers def bar(self, serie_node, serie, index, rescale=False): """Draw a bar graph for a serie""" bars = self.svg.node(serie_node['plot'], class_="bars") + errors_node = self.svg.node(serie_node['plot'], class_="errors_marks") if rescale and self.secondary_series: points = [ (x, self._scale_diff + (y - self._scale_min_2nd) * self._scale) @@ -76,10 +84,12 @@ class Bar(Graph): self.svg, self.svg.node(bars, class_='bar'), metadata) - val = self._format(serie.values[i]) - + val = self._get_value(points, i) + nested = serie.values[i] if isinstance(serie.values[i], + NestedSerie) else None x_center, y_center = self._bar( - bar, x, y, index, i, self.zero, secondary=rescale) + bar, x, y, index, i, self.zero, errors_node, + secondary=rescale, nested=nested) 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/base.py b/pygal/graph/base.py index bf77066..778d1a4 100644 --- a/pygal/graph/base.py +++ b/pygal/graph/base.py @@ -1,4 +1,4 @@ - # -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- # This file is part of pygal # # A python svg graph plotting library @@ -24,7 +24,8 @@ Base for pygal charts from __future__ import division from pygal.view import Margin, Box from pygal.util import ( - get_text_box, get_texts_box, cut, rad, humanize, truncate, split_title) + get_text_box, get_texts_box, cut, rad, humanize, truncate, + split_title) from pygal.svg import Svg from pygal.util import cached_property from math import sin, cos, sqrt @@ -203,25 +204,29 @@ class BaseGraph(object): def _secondary_min(self): """Getter for the minimum series value""" return (self.range[0] if (self.range and self.range[0] is not None) - else (min(self._secondary_values) if self._secondary_values else None)) + else (min(serie.min for serie in self.secondary_series) + if self._secondary_values else None)) @cached_property def _min(self): """Getter for the minimum series value""" - return (self.range[0] if (self.range and self.range[0] is not None) - else (min(self._values) if self._values else None)) + return (self.range[0] if (self.range and self.range[0] is not None) + else (min(serie.min for serie in self.series) + if self._values else None)) @cached_property def _max(self): """Getter for the maximum series value""" return (self.range[1] if (self.range and self.range[1] is not None) - else (max(self._values) if self._values else None)) + else (max(serie.max for serie in self.series) + if self._values else None)) @cached_property def _secondary_max(self): """Getter for the maximum series value""" return (self.range[1] if (self.range and self.range[1] is not None) - else (max(self._secondary_values) if self._secondary_values else None)) + else (max(serie.max for serie in self.secondary_series) + if self._secondary_values else None)) @cached_property def _order(self): @@ -245,6 +250,7 @@ class BaseGraph(object): return sum( map(len, map(lambda s: s.safe_values, self.series))) != 0 and ( sum(map(abs, self._values)) != 0) + return any(map(lambda s: s.has_data, self.series)) def render(self, is_unicode=False): """Render the graph, and return the svg string""" diff --git a/pygal/graph/datey.py b/pygal/graph/datey.py index bda6d6e..f218f00 100644 --- a/pygal/graph/datey.py +++ b/pygal/graph/datey.py @@ -40,6 +40,7 @@ from pygal._compat import total_seconds from pygal.adapters import date from pygal.util import compute_scale from pygal.graph.xy import XY +from pygal.serie import NestedSerie import datetime @@ -67,21 +68,25 @@ class DateY(XY): # Approximatively the same code as in XY. # The only difference is the transformation of dates to numbers # (beginning) and the reversed transformation to dates (end) - self._offset = min([val[0] - for serie in self.series - for val in serie.values - if val[0] is not None] - or [datetime.datetime.fromtimestamp(0)]) + self._offset = min([s.min for s in self.series] + or [datetime.datetime.fromtimestamp(0)]) + # self._offset = min( + # [val[0].min if isinstance(val[0], NestedSerie) + # else val[0] + # for serie in self.series + # for val in serie._values + # if val[0] is not None] + # or [datetime.datetime.fromtimestamp(0)]) for serie in self.all_series: - serie.values = [(self._tonumber(v[0]), v[1]) for v in serie.values] + serie._values = [(self._tonumber(v[0]), v[1]) for v in serie._values] xvals = [val[0] for serie in self.series - for val in serie.values + for val in serie._values if val[0] is not None] yvals = [val[1] for serie in self.series - for val in serie.values + for val in serie._values if val[1] is not None] if xvals: xmin = min(xvals) diff --git a/pygal/graph/frenchmap.py b/pygal/graph/frenchmap.py index 951c86d..f9202db 100644 --- a/pygal/graph/frenchmap.py +++ b/pygal/graph/frenchmap.py @@ -198,7 +198,7 @@ class FrenchMapDepartments(Graph): """Getter for series values (flattened)""" return [val[1] for serie in self.series - for val in serie.values + for val in serie._values if val[1] is not None] def _plot(self): @@ -208,12 +208,12 @@ class FrenchMapDepartments(Graph): for i, serie in enumerate(self.series): safe_vals = list(filter( - lambda x: x is not None, cut(serie.values, 1))) + lambda x: x is not None, cut(serie._values, 1))) if not safe_vals: continue min_ = min(safe_vals) max_ = max(safe_vals) - for j, (area_code, value) in enumerate(serie.values): + for j, (area_code, value) in enumerate(serie._values): if isinstance(area_code, Number): area_code = '%2d' % area_code if value is None: diff --git a/pygal/graph/graph.py b/pygal/graph/graph.py index 3e025cc..8737915 100644 --- a/pygal/graph/graph.py +++ b/pygal/graph/graph.py @@ -24,9 +24,11 @@ Commmon graphing functions from __future__ import division from pygal.interpolate import INTERPOLATIONS from pygal.graph.base import BaseGraph +from pygal.serie import NestedSerie from pygal.view import View, LogView, XYLogView from pygal.util import ( - majorize, truncate, reverse_text_len, get_texts_box, cut, rad, decorate) + majorize, truncate, reverse_text_len, get_texts_box, cut, rad, decorate, + swap, ident) from math import sqrt, ceil, cos from itertools import repeat, chain @@ -493,12 +495,13 @@ class Graph(BaseGraph): def _get_value(self, values, i): """Get the value formatted for tooltip""" - return self._format(values[i][1]) + return self._format(values[i][1].mean if isinstance( + values[i][1], NestedSerie) else values[i][1]) def _points(self, x_pos): for serie in self.all_series: serie.points = [ - (x_pos[i], v) + (x_pos[i], v.mean if isinstance(v, NestedSerie) else v) for i, v in enumerate(serie.values)] if serie.points and self.interpolate: serie.interpolated = self._interpolate(x_pos, serie.values) @@ -528,3 +531,12 @@ class Graph(BaseGraph): def _post_compute(self): pass + + def _transpose(self): + return swap if self.horizontal else ident + + def _draw_error_marks(self, node, x, error_coords, width, index): + """Draw error marks using std deviation.""" + error_node = node + x = x + width / 2 + self.svg.draw_errors(error_node, self._transpose(), x, error_coords) diff --git a/pygal/graph/histogram.py b/pygal/graph/histogram.py index 16c2f00..91ebcbc 100644 --- a/pygal/graph/histogram.py +++ b/pygal/graph/histogram.py @@ -37,7 +37,7 @@ class Histogram(Graph): """Getter for secondary series values (flattened)""" return [val[0] for serie in self.series - for val in serie.values + for val in serie._values if val[0] is not None] @cached_property @@ -45,14 +45,14 @@ class Histogram(Graph): """Getter for secondary series values (flattened)""" return [val[0] for serie in self.secondary_series - for val in serie.values + for val in serie._values if val[0] is not None] @cached_property def xvals(self): return [val for serie in self.all_series - for dval in serie.values + for dval in serie._values for val in dval[1:3] if val is not None] @@ -60,7 +60,7 @@ class Histogram(Graph): def yvals(self): return [val[0] for serie in self.series - for val in serie.values + for val in serie._values if val[0] is not None] def _has_data(self): @@ -92,7 +92,7 @@ class Histogram(Graph): bars = self.svg.node(serie_node['plot'], class_="histbars") points = serie.points - for i, (y, x0, x1) in enumerate(points): + for i, (y, x0, x1) in enumerate(serie._values): if None in (x0, x1, y) or (self.logarithmic and y <= 0): continue metadata = serie.metadata.get(i) @@ -101,7 +101,7 @@ class Histogram(Graph): self.svg, self.svg.node(bars, class_='histbar'), metadata) - val = self._format(serie.values[i][0]) + val = self._format(serie._values[i][0]) x_center, y_center = self._bar( bar, x0, x1, y, index, i, self.zero, secondary=rescale) diff --git a/pygal/graph/line.py b/pygal/graph/line.py index bf34ef6..f5b5889 100644 --- a/pygal/graph/line.py +++ b/pygal/graph/line.py @@ -22,6 +22,7 @@ Line chart """ from __future__ import division from pygal.graph.graph import Graph +from pygal.serie import NestedSerie from pygal.util import cached_property, compute_scale, decorate @@ -121,6 +122,17 @@ class Line(Graph): serie_node['plot'], view_values, close=self._self_close, class_='line reactive' + (' nofill' if not self.fill else '')) + for i, (x, y) in enumerate(points): + x = self.view.x(x) + errors_node = self.svg.node(serie_node['overlay'], + class_="errors_marks") + nested = serie.values[i] if isinstance(serie.values[i], + NestedSerie) else None + if nested: + error_coords = (self.view.y(nested.min), + self.view.y(nested.max)) + self._draw_error_marks(errors_node, x, error_coords, 0, i) + def _compute(self): # X Labels x_pos = [ diff --git a/pygal/graph/stackedbar.py b/pygal/graph/stackedbar.py index 8bd129c..442faad 100644 --- a/pygal/graph/stackedbar.py +++ b/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, errors_node, shift=False, + secondary=False, nested=None): if secondary: cumulation = (self.secondary_negative_cumulation if y < self.zero else @@ -124,4 +125,7 @@ class StackedBar(Bar): x=x, y=y, rx=r, ry=r, width=width, height=height, class_='rect reactive tooltip-trigger') transpose = swap if self.horizontal else ident + if nested: + error_coords = (self.view.y(nested.min), self.view.y(nested.max)) + self._draw_error_marks(errors_node, x, error_coords, width, index) return transpose((x + width / 2, y + height / 2)) diff --git a/pygal/graph/supranationalworldmap.py b/pygal/graph/supranationalworldmap.py index 4cc9a16..5bcf92d 100644 --- a/pygal/graph/supranationalworldmap.py +++ b/pygal/graph/supranationalworldmap.py @@ -43,13 +43,13 @@ class SupranationalWorldmap(Worldmap): for i, serie in enumerate(self.series): safe_vals = list(filter( - lambda x: x is not None, cut(serie.values, 1))) + lambda x: x is not None, cut(serie._values, 1))) if not safe_vals: continue min_ = min(safe_vals) max_ = max(safe_vals) - serie.values = self.replace_supranationals(serie.values) - for j, (country_code, value) in enumerate(serie.values): + serie.values = self.replace_supranationals(serie._values) + for j, (country_code, value) in enumerate(serie._values): if value is None: continue if max_ == min_: diff --git a/pygal/graph/verticalpyramid.py b/pygal/graph/verticalpyramid.py index 7c52ac9..cbd8f51 100644 --- a/pygal/graph/verticalpyramid.py +++ b/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, errors_node, shift=True, + secondary=False, nested=None): 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, nested) diff --git a/pygal/graph/worldmap.py b/pygal/graph/worldmap.py index 8e144d6..cc86eb3 100644 --- a/pygal/graph/worldmap.py +++ b/pygal/graph/worldmap.py @@ -44,7 +44,7 @@ class Worldmap(Graph): def countries(self): return [val[0] for serie in self.all_series - for val in serie.values + for val in serie._values if val[0] is not None] @cached_property @@ -52,7 +52,7 @@ class Worldmap(Graph): """Getter for series values (flattened)""" return [val[1] for serie in self.series - for val in serie.values + for val in serie._values if val[1] is not None] def _plot(self): @@ -62,12 +62,12 @@ class Worldmap(Graph): for i, serie in enumerate(self.series): safe_vals = list(filter( - lambda x: x is not None, cut(serie.values, 1))) + lambda x: x is not None, cut(serie._values, 1))) if not safe_vals: continue min_ = min(safe_vals) max_ = max(safe_vals) - for j, (country_code, value) in enumerate(serie.values): + for j, (country_code, value) in enumerate(serie._values): if value is None: continue if max_ == min_: diff --git a/pygal/graph/xy.py b/pygal/graph/xy.py index 24272ac..a6ea110 100644 --- a/pygal/graph/xy.py +++ b/pygal/graph/xy.py @@ -24,6 +24,7 @@ XY Line graph from __future__ import division from pygal.util import compute_scale, cached_property from pygal.graph.line import Line +from pygal.serie import NestedSerie class XY(Line): @@ -32,16 +33,16 @@ class XY(Line): @cached_property def xvals(self): - return [val[0] + return [val[0].mean if isinstance(val[0], NestedSerie) else val[0] for serie in self.all_series - for val in serie.values + for val in serie._values if val[0] is not None] @cached_property def yvals(self): - return [val[1] + return [val[1].mean if isinstance(val[1], NestedSerie) else val[1] for serie in self.series - for val in serie.values + for val in serie._values if val[1] is not None] def _has_data(self): diff --git a/pygal/serie.py b/pygal/serie.py index d3636b5..6b45b05 100644 --- a/pygal/serie.py +++ b/pygal/serie.py @@ -20,20 +20,95 @@ Little helpers for series """ -from pygal.util import cached_property +from pygal.util import cached_property, cut +from math import fsum, sqrt class Serie(object): """Serie containing title, values and the graph serie index""" - def __init__(self, title, values, metadata=None): + def __init__(self, title, values, metadata=None, parent=None, dual=False): self.title = title - self.values = values + self._values = values self.metadata = metadata or {} + self.parent = parent + self.dual = dual + + + @cached_property + def values(self): + if self.dual: + return cut(self._values) + return self._values @cached_property def safe_values(self): return list(filter(lambda x: x is not None, self.values)) + @cached_property + def min(self): + """Returns the lowest value of the serie.""" + return min([val.min if isinstance(val, NestedSerie) else val + for val in self.values if val is not None] or [None]) + + @cached_property + def max(self): + """Returns the lowest value of the serie.""" + return max([val.max if isinstance(val, NestedSerie) else val + for val in self.values if val is not None] or [None]) + + @cached_property + def length(self): + """Returns the serie size.""" + return len(self.values) + + @cached_property + def has_data(self): + """True if data is provided.""" + datalen = len(self.safe_values) + total = 0 + for v in self.safe_values: + if v: + if isinstance(v, NestedSerie): + total += v.abs + elif isinstance(v, tuple): + total += any([abs(v[0] or 0) != 0, abs(v[1] or 0) != 0]) + else: + total += abs(v) + return datalen and total != 0 + + +class NestedSerie(Serie): + """Class that handles nested series.""" + @cached_property + def mean(self): + """Returns the average on the serie (mean).""" + return fsum([v for v in self.values]) / self.length + + @cached_property + def variance(self): + """Returns the variance for the serie.""" + return 1/self.length * fsum((v-self.mean) ** 2 for v in self.values) + + @cached_property + def deviation(self): + """Returns the deviation for the serie.""" + return sqrt(self.variance) + + @cached_property + def min(self): + """Returns the lowest value of the serie.""" + return self.mean - self.deviation + + @cached_property + def max(self): + """Returns the lowest value of the serie.""" + return self.mean + self.deviation + + @cached_property + def abs(self): + """Returns the absolute value of the serie.""" + return abs(self.mean) + class Label(object): """A label with his position""" diff --git a/pygal/svg.py b/pygal/svg.py index 76956fe..f1f872e 100644 --- a/pygal/svg.py +++ b/pygal/svg.py @@ -235,3 +235,32 @@ class Svg(object): if self.graph.disable_xml_declaration or is_unicode: svg = svg.decode('utf-8') return svg + + def draw_errors(self, parent_node, transpose, x, y_coords): + """Draws the chart errors aka confidence level.""" + width = ( + self.graph.view.x(1) - self.graph.view.x(0)) / self.graph._len + series_margin = width * getattr(self.graph, '_series_margin', 1) + width -= 2 * series_margin + y_begin = y_coords[0] + y_end = y_coords[1] + line_edges = transpose((x, y_begin)), transpose((x, y_end)) + line_feet = (transpose((x - width / 4, y_begin)), + transpose((x + width / 4, y_begin))) + line_hat = (transpose((x - width / 4, y_end)), + transpose((x + width / 4, y_end))) + self.line( + parent_node, + coords=[line_edges[0], line_edges[1]], + class_='errors' + ) + self.line( + parent_node, + coords=[line_feet[0], line_feet[1]], + class_='errors' + ) + self.line( + parent_node, + coords=[line_hat[0], line_hat[1]], + class_='errors' + ) diff --git a/pygal/util.py b/pygal/util.py index a0608a9..c33308a 100644 --- a/pygal/util.py +++ b/pygal/util.py @@ -318,7 +318,7 @@ def safe_enumerate(iterable): if v is not None: yield i, v -from pygal.serie import Serie +from pygal.serie import Serie, NestedSerie def prepare_values(raw, config, cls): @@ -376,6 +376,12 @@ def prepare_values(raw, config, cls): raw_value = dict(raw_value) value = raw_value.pop('value', None) metadata[index] = raw_value + elif is_list_like(raw_value) and not cls._dual: + value = NestedSerie(title, raw_value, metadata) + elif (is_list_like(raw_value) and cls._dual and + len(raw_value) > 1 and is_list_like(raw_value[1])): + value = (raw_value[0], + NestedSerie(title, raw_value[1], metadata)) else: value = raw_value @@ -399,7 +405,11 @@ def prepare_values(raw, config, cls): else: value = adapter(value) values.append(value) - series.append(Serie(title, values, metadata)) + serie = Serie(title, values, metadata, dual=cls._dual) + for v in serie.values: + if isinstance(v, NestedSerie): + v.parent = serie + series.append(serie) return series