commit 5dd79b8c8946de280ff56557ff47d65a1882fafd Author: Ulises Vitulli Date: Sat Sep 16 01:03:42 2017 +0200 Import python-pygal_2.4.0.orig.tar.gz [dgit import orig python-pygal_2.4.0.orig.tar.gz] diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..9c7c33e --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,20 @@ +Metadata-Version: 1.1 +Name: pygal +Version: 2.4.0 +Summary: A python svg graph plotting library +Home-page: http://pygal.org/ +Author: Florian Mounier +Author-email: florian.mounier@kozea.fr +License: GNU LGPL v3+ +Description: UNKNOWN +Keywords: svg,chart,graph,diagram,plot,histogram,kiviat +Platform: Any +Classifier: Development Status :: 4 - Beta +Classifier: Environment :: Console +Classifier: Intended Audience :: End Users/Desktop +Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+) +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 2 +Classifier: Programming Language :: Python :: 3 +Classifier: Topic :: Multimedia :: Graphics :: Presentation +Provides: pygal diff --git a/README b/README new file mode 100644 index 0000000..3f5ec2a --- /dev/null +++ b/README @@ -0,0 +1,67 @@ +# Pygal + + +[![Build Status](https://travis-ci.org/Kozea/pygal.svg?branch=master)](https://travis-ci.org/Kozea/pygal) +[![Coverage Status](https://coveralls.io/repos/Kozea/pygal/badge.svg?branch=master&service=github)](https://coveralls.io/github/Kozea/pygal?branch=master) +[![Documentation Status](https://readthedocs.org/projects/pygal/badge/?version=latest)](https://readthedocs.org/projects/pygal/?badge=latest) + + +@@TOC@@ + +## Description + +**pygal** is a dynamic SVG charting library written in python. +All the documentation is on http://pygal.org + + +## Installation + +As simple as: + +``` + $ pip install pygal +``` + + + +## Test + +Pygal is tested with py.test: + + +``` + $ pip install pytest + $ py.test +``` + + +## Contribute + +You are welcomed to fork the project and make pull requests. +Be sure to create a branch for each feature, write tests if needed and run the current tests ! + + +You can also support the project: + +[![Flattr](http://api.flattr.com/button/flattr-badge-large.png)](https://flattr.com/submit/auto?user_id=paradoxxx_zero&url=https://github.com/Kozea/pygal&title=Pygal&tags=github&category=software) +[![gittip](http://i.imgur.com/IKcQB2P.png)](https://www.gittip.com/paradoxxxzero/) + + + +## License + +Copyright © 2012-2016 Kozea +LGPLv3: + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . diff --git a/pygal.egg-info/PKG-INFO b/pygal.egg-info/PKG-INFO new file mode 100644 index 0000000..9c7c33e --- /dev/null +++ b/pygal.egg-info/PKG-INFO @@ -0,0 +1,20 @@ +Metadata-Version: 1.1 +Name: pygal +Version: 2.4.0 +Summary: A python svg graph plotting library +Home-page: http://pygal.org/ +Author: Florian Mounier +Author-email: florian.mounier@kozea.fr +License: GNU LGPL v3+ +Description: UNKNOWN +Keywords: svg,chart,graph,diagram,plot,histogram,kiviat +Platform: Any +Classifier: Development Status :: 4 - Beta +Classifier: Environment :: Console +Classifier: Intended Audience :: End Users/Desktop +Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+) +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 2 +Classifier: Programming Language :: Python :: 3 +Classifier: Topic :: Multimedia :: Graphics :: Presentation +Provides: pygal diff --git a/pygal.egg-info/SOURCES.txt b/pygal.egg-info/SOURCES.txt new file mode 100644 index 0000000..d01f037 --- /dev/null +++ b/pygal.egg-info/SOURCES.txt @@ -0,0 +1,81 @@ +README +pygal_gen.py +setup.cfg +setup.py +pygal/__about__.py +pygal/__init__.py +pygal/_compat.py +pygal/adapters.py +pygal/colors.py +pygal/config.py +pygal/etree.py +pygal/formatters.py +pygal/interpolate.py +pygal/serie.py +pygal/state.py +pygal/stats.py +pygal/style.py +pygal/svg.py +pygal/table.py +pygal/util.py +pygal/view.py +pygal.egg-info/PKG-INFO +pygal.egg-info/SOURCES.txt +pygal.egg-info/dependency_links.txt +pygal.egg-info/requires.txt +pygal.egg-info/top_level.txt +pygal/css/base.css +pygal/css/graph.css +pygal/css/style.css +pygal/graph/__init__.py +pygal/graph/bar.py +pygal/graph/base.py +pygal/graph/box.py +pygal/graph/dot.py +pygal/graph/dual.py +pygal/graph/funnel.py +pygal/graph/gauge.py +pygal/graph/graph.py +pygal/graph/histogram.py +pygal/graph/horizontal.py +pygal/graph/horizontalbar.py +pygal/graph/horizontalline.py +pygal/graph/horizontalstackedbar.py +pygal/graph/horizontalstackedline.py +pygal/graph/line.py +pygal/graph/map.py +pygal/graph/pie.py +pygal/graph/public.py +pygal/graph/pyramid.py +pygal/graph/radar.py +pygal/graph/solidgauge.py +pygal/graph/stackedbar.py +pygal/graph/stackedline.py +pygal/graph/time.py +pygal/graph/treemap.py +pygal/graph/xy.py +pygal/maps/__init__.py +pygal/test/__init__.py +pygal/test/conftest.py +pygal/test/test_bar.py +pygal/test/test_box.py +pygal/test/test_colors.py +pygal/test/test_config.py +pygal/test/test_date.py +pygal/test/test_formatters.py +pygal/test/test_graph.py +pygal/test/test_histogram.py +pygal/test/test_interpolate.py +pygal/test/test_line.py +pygal/test/test_line_log_none_max_solved.py +pygal/test/test_maps.py +pygal/test/test_pie.py +pygal/test/test_serie_config.py +pygal/test/test_sparktext.py +pygal/test/test_stacked.py +pygal/test/test_style.py +pygal/test/test_table.py +pygal/test/test_util.py +pygal/test/test_view.py +pygal/test/test_xml_filters.py +pygal/test/utils.py \ No newline at end of file diff --git a/pygal.egg-info/dependency_links.txt b/pygal.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/pygal.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/pygal.egg-info/requires.txt b/pygal.egg-info/requires.txt new file mode 100644 index 0000000..49472bb --- /dev/null +++ b/pygal.egg-info/requires.txt @@ -0,0 +1,26 @@ + +[docs] +sphinx +sphinx_rtd_theme +pygal_sphinx_directives + +[lxml] +lxml + +[png] +cairosvg + +[test] +pyquery +flask +cairosvg +lxml +pygal_maps_world +pygal_maps_fr +pygal_maps_ch +coveralls +pytest-runner +pytest-cov +pytest-flake8 +pytest-isort +pytest diff --git a/pygal.egg-info/top_level.txt b/pygal.egg-info/top_level.txt new file mode 100644 index 0000000..c5a9b66 --- /dev/null +++ b/pygal.egg-info/top_level.txt @@ -0,0 +1 @@ +pygal diff --git a/pygal/__about__.py b/pygal/__about__.py new file mode 100644 index 0000000..70af074 --- /dev/null +++ b/pygal/__about__.py @@ -0,0 +1,15 @@ +__title__ = "pygal" +__version__ = "2.4.0" + +__summary__ = "A python svg graph plotting library" +__uri__ = "http://pygal.org/" +__author__ = "Florian Mounier" +__email__ = "florian.mounier@kozea.fr" + +__license__ = "GNU LGPL v3+" +__copyright__ = "Copyright 2017 %s" % __author__ + +__all__ = [ + '__title__', '__version__', '__summary__', '__uri__', '__author__', + '__email__', '__license__', '__copyright__' +] diff --git a/pygal/__init__.py b/pygal/__init__.py new file mode 100644 index 0000000..ca01709 --- /dev/null +++ b/pygal/__init__.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +""" +Main pygal package. + +This package holds all available charts in pygal, the Config class +and the maps extensions namespace module. + +""" +from .__about__ import * # noqa: F401,F403 + +import pkg_resources +import sys +import traceback +import warnings + +from pygal.graph.bar import Bar +from pygal.graph.box import Box +from pygal.graph.dot import Dot +from pygal.graph.funnel import Funnel +from pygal.graph.gauge import Gauge +from pygal.graph.solidgauge import SolidGauge +from pygal.graph.histogram import Histogram +from pygal.graph.horizontalbar import HorizontalBar +from pygal.graph.horizontalstackedbar import HorizontalStackedBar +from pygal.graph.line import Line +from pygal.graph.horizontalline import HorizontalLine +from pygal.graph.horizontalstackedline import HorizontalStackedLine +from pygal.graph.pie import Pie +from pygal.graph.pyramid import Pyramid, VerticalPyramid +from pygal.graph.radar import Radar +from pygal.graph.stackedbar import StackedBar +from pygal.graph.stackedline import StackedLine +from pygal.graph.time import DateLine, DateTimeLine, TimeLine, TimeDeltaLine +from pygal.graph.treemap import Treemap +from pygal.graph.xy import XY +from pygal.graph.graph import Graph +from pygal.config import Config +from pygal import maps + + +CHARTS_BY_NAME = dict( + [(k, v) for k, v in locals().items() + if isinstance(v, type) and issubclass(v, Graph) and v != Graph]) + + +from pygal.graph.map import BaseMap +for entry in pkg_resources.iter_entry_points('pygal.maps'): + try: + module = entry.load() + except Exception: + warnings.warn('Unable to load %s pygal plugin \n\n%s' % ( + entry, traceback.format_exc()), Warning) + continue + setattr(maps, entry.name, module) + for k, v in module.__dict__.items(): + if isinstance(v, type) and issubclass(v, BaseMap) and v != BaseMap: + CHARTS_BY_NAME[entry.name.capitalize() + k + 'Map'] = v + +CHARTS_NAMES = list(CHARTS_BY_NAME.keys()) +CHARTS = list(CHARTS_BY_NAME.values()) + + +class PluginImportFixer(object): + + """ + Allow external map plugins to be imported from pygal.maps package. + + It is a ``sys.meta_path`` loader. + """ + + def find_module(self, fullname, path=None): + """ + Tell if the module to load can be loaded by + the load_module function, ie: if it is a ``pygal.maps.*`` + module. + """ + if fullname.startswith('pygal.maps.') and hasattr( + maps, fullname.split('.')[2]): + return self + return None + + def load_module(self, name): + """ + Load the ``pygal.maps.name`` module from the previously + loaded plugin + """ + if name not in sys.modules: + sys.modules[name] = getattr(maps, name.split('.')[2]) + return sys.modules[name] + + +sys.meta_path += [PluginImportFixer()] diff --git a/pygal/_compat.py b/pygal/_compat.py new file mode 100644 index 0000000..5debb4b --- /dev/null +++ b/pygal/_compat.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +"""Various hacks for transparent python 2 / python 3 support""" +from __future__ import division + +import sys +from collections import Iterable +from datetime import datetime, timedelta, tzinfo + +if sys.version_info[0] == 3: + base = (str, bytes) + coerce = str + _ellipsis = eval('...') +else: + base = basestring + coerce = unicode + + class EllipsisGetter(object): + def __getitem__(self, key): + return key + + _ellipsis = EllipsisGetter()[...] + + +def is_list_like(value): + """Return whether value is an iterable but not a mapping / string""" + return isinstance(value, Iterable) and not isinstance(value, (base, dict)) + + +def is_str(string): + """Return whether value is a string or a byte list""" + return isinstance(string, base) + + +def to_str(obj): + """Cast obj to unicode string""" + if not is_str(obj): + return coerce(obj) + return obj + + +def to_unicode(string): + """Force string to be a string in python 3 or a unicode in python 2""" + if not isinstance(string, coerce): + return string.decode('utf-8') + return string + + +def u(s): + """Emulate u'str' in python 2, do nothing in python 3""" + if sys.version_info[0] == 2: + return s.decode('utf-8') + return s + + +try: + from datetime import timezone + utc = timezone.utc +except ImportError: + class UTC(tzinfo): + def tzname(self, dt): + return 'UTC' + + def utcoffset(self, dt): + return timedelta(0) + + def dst(self, dt): + return None + utc = UTC() + + +def timestamp(x): + """Get a timestamp from a date in python 3 and python 2""" + if x.tzinfo is None: + # Naive dates to utc + x = x.replace(tzinfo=utc) + + if hasattr(x, 'timestamp'): + return x.timestamp() + else: + return (x - datetime(1970, 1, 1, tzinfo=utc)).total_seconds() + + +try: + from urllib import quote_plus +except ImportError: + from urllib.parse import quote_plus diff --git a/pygal/adapters.py b/pygal/adapters.py new file mode 100644 index 0000000..1a7bc0e --- /dev/null +++ b/pygal/adapters.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +"""Value adapters to use when a chart doesn't accept all value types""" +from decimal import Decimal + +from pygal._compat import is_str + + +def positive(x): + """Return zero if value is negative""" + if x is None: + return + if is_str(x): + return x + if x < 0: + return 0 + return x + + +def not_zero(x): + """Return None if value is zero""" + if x == 0: + return + return x + + +def none_to_zero(x): + """Return 0 if value is None""" + if x is None: + return 0 + return x + + +def decimal_to_float(x): + """Cast Decimal values to float""" + if isinstance(x, Decimal): + return float(x) + return x diff --git a/pygal/colors.py b/pygal/colors.py new file mode 100644 index 0000000..385863e --- /dev/null +++ b/pygal/colors.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +""" +This package is an utility package oriented on color alteration. +This is used by the :py:mod:`pygal.style` package to generate +parametric styles. + +""" +from __future__ import division + + +def normalize_float(f): + """Round float errors""" + if abs(f - round(f)) < .0000000000001: + return round(f) + return f + + +def rgb_to_hsl(r, g, b): + """Convert a color in r, g, b to a color in h, s, l""" + r = r or 0 + g = g or 0 + b = b or 0 + r /= 255 + g /= 255 + b /= 255 + max_ = max((r, g, b)) + min_ = min((r, g, b)) + d = max_ - min_ + + if not d: + h = 0 + elif r is max_: + h = 60 * (g - b) / d + elif g is max_: + h = 60 * (b - r) / d + 120 + else: + h = 60 * (r - g) / d + 240 + + l = .5 * (max_ + min_) + if not d: + s = 0 + elif l < 0.5: + s = .5 * d / l + else: + s = .5 * d / (1 - l) + return tuple(map(normalize_float, (h % 360, s * 100, l * 100))) + + +def hsl_to_rgb(h, s, l): + """Convert a color in h, s, l to a color in r, g, b""" + h /= 360 + s /= 100 + l /= 100 + + m2 = l * (s + 1) if l <= .5 else l + s - l * s + m1 = 2 * l - m2 + + def h_to_rgb(h): + h = h % 1 + if 6 * h < 1: + return m1 + 6 * h * (m2 - m1) + if 2 * h < 1: + return m2 + if 3 * h < 2: + return m1 + 6 * (2 / 3 - h) * (m2 - m1) + return m1 + r, g, b = map(lambda x: round(x * 255), + map(h_to_rgb, (h + 1 / 3, h, h - 1 / 3))) + + return r, g, b + + +def parse_color(color): + """Take any css color definition and give back a tuple containing the + r, g, b, a values along with a type which can be: #rgb, #rgba, #rrggbb, + #rrggbbaa, rgb, rgba + """ + r = g = b = a = type = None + if color.startswith('#'): + color = color[1:] + if len(color) == 3: + type = '#rgb' + color = color + 'f' + if len(color) == 4: + type = type or '#rgba' + color = ''.join([c * 2 for c in color]) + if len(color) == 6: + type = type or '#rrggbb' + color = color + 'ff' + assert len(color) == 8 + type = type or '#rrggbbaa' + r, g, b, a = [ + int(''.join(c), 16) for c in zip(color[::2], color[1::2])] + a /= 255 + elif color.startswith('rgb('): + type = 'rgb' + color = color[4:-1] + r, g, b, a = [int(c) for c in color.split(',')] + [1] + elif color.startswith('rgba('): + type = 'rgba' + color = color[5:-1] + r, g, b, a = [int(c) for c in color.split(',')[:-1]] + [ + float(color.split(',')[-1])] + return r, g, b, a, type + + +def unparse_color(r, g, b, a, type): + """ + Take the r, g, b, a color values and give back + a type css color string. This is the inverse function of parse_color + """ + if type == '#rgb': + # Don't lose precision on rgb shortcut + if r % 17 == 0 and g % 17 == 0 and b % 17 == 0: + return '#%x%x%x' % (int(r / 17), int(g / 17), int(b / 17)) + type = '#rrggbb' + + if type == '#rgba': + if r % 17 == 0 and g % 17 == 0 and b % 17 == 0: + return '#%x%x%x%x' % (int(r / 17), int(g / 17), int(b / 17), + int(a * 15)) + type = '#rrggbbaa' + + if type == '#rrggbb': + return '#%02x%02x%02x' % (r, g, b) + + if type == '#rrggbbaa': + return '#%02x%02x%02x%02x' % (r, g, b, int(a * 255)) + + if type == 'rgb': + return 'rgb(%d, %d, %d)' % (r, g, b) + + if type == 'rgba': + return 'rgba(%d, %d, %d, %g)' % (r, g, b, a) + + +def is_foreground_light(color): + """ + Determine if the background color need a light or dark foreground color + """ + return rgb_to_hsl(*parse_color(color)[:3])[2] < 17.9 + + +_clamp = lambda x: max(0, min(100, x)) + + +def _adjust(hsl, attribute, percent): + """Internal adjust function""" + hsl = list(hsl) + if attribute > 0: + hsl[attribute] = _clamp(hsl[attribute] + percent) + else: + hsl[attribute] += percent + + return hsl + + +def adjust(color, attribute, percent): + """Adjust an attribute of color by a percent""" + r, g, b, a, type = parse_color(color) + r, g, b = hsl_to_rgb(*_adjust(rgb_to_hsl(r, g, b), attribute, percent)) + return unparse_color(r, g, b, a, type) + + +def rotate(color, percent): + """Rotate a color by changing its hue value by percent""" + return adjust(color, 0, percent) + + +def saturate(color, percent): + """Saturate a color by increasing its saturation by percent""" + return adjust(color, 1, percent) + + +def desaturate(color, percent): + """Desaturate a color by decreasing its saturation by percent""" + return adjust(color, 1, -percent) + + +def lighten(color, percent): + """Lighten a color by increasing its lightness by percent""" + return adjust(color, 2, percent) + + +def darken(color, percent): + """Darken a color by decreasing its lightness by percent""" + return adjust(color, 2, -percent) diff --git a/pygal/config.py b/pygal/config.py new file mode 100644 index 0000000..9a6d2a3 --- /dev/null +++ b/pygal/config.py @@ -0,0 +1,545 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +"""Config module holding all options and their default values.""" + +from copy import deepcopy + +from pygal import formatters +from pygal.interpolate import INTERPOLATIONS +from pygal.style import DefaultStyle, Style + +CONFIG_ITEMS = [] +callable = type(lambda: 1) + + +class Key(object): + + """ + Represents a config parameter. + + A config parameter has a name, a default value, a type, + a category, a documentation, an optional longer documentatation + and an optional subtype for list style option. + + Most of these informations are used in cabaret to auto generate + forms representing these options. + """ + + _categories = [] + + def __init__( + self, default_value, type_, category, doc, + subdoc="", subtype=None): + """Create a configuration key""" + self.value = default_value + self.type = type_ + self.doc = doc + self.category = category + self.subdoc = subdoc + self.subtype = subtype + self.name = "Unbound" + if category not in self._categories: + self._categories.append(category) + + CONFIG_ITEMS.append(self) + + def __repr__(self): + """ + Make a documentation repr. + This is a hack to generate doc from inner doc + """ + return """ + Type: %s%s      + Default: %r      + %s%s + """ % ( + self.type.__name__, + (' of %s' % self.subtype.__name__) if self.subtype else '', + self.value, + self.doc, + (' %s' % self.subdoc) if self.subdoc else '' + ) + + @property + def is_boolean(self): + """Return `True` if this parameter is a boolean""" + return self.type == bool + + @property + def is_numeric(self): + """Return `True` if this parameter is numeric (int or float)""" + return self.type in (int, float) + + @property + def is_string(self): + """Return `True` if this parameter is a string""" + return self.type == str + + @property + def is_dict(self): + """Return `True` if this parameter is a mapping""" + return self.type == dict + + @property + def is_list(self): + """Return `True` if this parameter is a list""" + return self.type == list + + def coerce(self, value): + """Cast a string into this key type""" + if self.type == Style: + return value + elif self.type == list: + return self.type( + map( + self.subtype, map( + lambda x: x.strip(), value.split(',')))) + elif self.type == dict: + rv = {} + for pair in value.split(','): + key, val = pair.split(':') + key = key.strip() + val = val.strip() + try: + rv[key] = self.subtype(val) + except: + rv[key] = val + return rv + return self.type(value) + + +class MetaConfig(type): + + """Config metaclass. Used to get the key name and set it on the value.""" + + def __new__(mcs, classname, bases, classdict): + """Get the name of the key and set it on the key""" + for k, v in classdict.items(): + if isinstance(v, Key): + v.name = k + + return type.__new__(mcs, classname, bases, classdict) + + +class BaseConfig(MetaConfig('ConfigBase', (object,), {})): + + """ + This class holds the common method for configs. + + A config object can be instanciated with keyword arguments and + updated on call with keyword arguments. + """ + + 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): + """Update the config with the given dictionary""" + from pygal.util import merge + dir_self_set = set(dir(self)) + merge( + self.__dict__, dict([ + (k, v) for (k, v) in kwargs.items() + if not k.startswith('_') and k in dir_self_set])) + + def to_dict(self): + """Export a JSON serializable dictionary of the config""" + 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): + """Copy this config object into another""" + return deepcopy(self) + + +class CommonConfig(BaseConfig): + + """Class holding options used in both chart and serie configuration""" + + 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") + + stroke_style = Key(None, dict, "Look", "Stroke style of serie element.", + "This is a dict which can contain a " + "'width', 'linejoin', 'linecap', 'dasharray' " + "and 'dashoffset'") + + 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") + + allow_interruptions = Key( + False, bool, "Look", "Break lines on None values") + + formatter = Key( + None, callable, "Value", + "A function to convert raw value to strings for this chart or serie", + "Default to value_formatter in most charts, it depends on dual charts." + "(Can be overriden by value with the formatter metadata.)") + + +class Config(CommonConfig): + + """Class holding config values""" + + style = Key( + DefaultStyle, Style, "Style", "Style holding values injected in css") + + css = Key( + ('file://style.css', 'file://graph.css'), list, "Style", + "List of css file", + "It can be any uri from file:///tmp/style.css to //domain/style.css", + str) + + classes = Key( + ('pygal-chart',), + list, "Style", "Classes of the root svg node", + str) + + defs = Key( + [], + list, "Misc", "Extraneous defs to be inserted in svg", + "Useful for adding gradients / patterns…", + str) + + # Look # + title = Key( + None, str, "Look", + "Graph title.", "Leave it to None to disable title.") + + x_title = Key( + None, str, "Look", + "Graph X-Axis title.", "Leave it to None to disable X-Axis title.") + + y_title = Key( + None, str, "Look", + "Graph Y-Axis title.", "Leave it to None to disable Y-Axis title.") + + width = Key( + 800, int, "Look", "Graph width") + + height = Key( + 600, int, "Look", "Graph height") + + show_x_guides = Key(False, bool, "Look", + "Set to true to always show x guide lines") + + show_y_guides = Key(True, bool, "Look", + "Set to false to hide y guide lines") + + show_legend = Key( + True, bool, "Look", "Set to false to remove legend") + + legend_at_bottom = Key( + False, bool, "Look", "Set to true to position legend at bottom") + + legend_at_bottom_columns = Key( + None, int, "Look", "Set to true to position legend at bottom") + + 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") + + stack_from_top = Key( + False, bool, "Look", "Stack from top to zero, this makes the stacked " + "data match the legend order") + + spacing = Key( + 10, int, "Look", + "Space between titles/legend/axes") + + margin = Key( + 20, int, "Look", + "Margin around chart") + + margin_top = Key( + None, int, "Look", + "Margin around top of chart") + + margin_right = Key( + None, int, "Look", + "Margin around right of chart") + + margin_bottom = Key( + None, int, "Look", + "Margin around bottom of chart") + + margin_left = Key( + None, int, "Look", + "Margin around left of chart") + + tooltip_border_radius = Key(0, int, "Look", "Tooltip border radius") + + tooltip_fancy_mode = Key( + True, bool, "Look", "Fancy tooltips", + "Print legend, x label in tooltip and use serie color for value.") + + inner_radius = Key( + 0, float, "Look", "Piechart inner radius (donut), must be <.9") + + half_pie = Key( + False, bool, "Look", "Create a half-pie chart") + + x_labels = Key( + None, list, "Label", + "X labels, must have same len than data.", + "Leave it to None to disable x labels display.", + str) + + x_labels_major = Key( + None, list, "Label", + "X labels that will be marked major.", + subtype=str) + + x_labels_major_every = Key( + None, int, "Label", + "Mark every n-th x label as major.") + + x_labels_major_count = Key( + None, int, "Label", + "Mark n evenly distributed labels as major.") + + show_x_labels = Key( + True, bool, "Label", "Set to false to hide x-labels") + + show_minor_x_labels = Key( + True, bool, "Label", "Set to false to hide x-labels not marked major") + + y_labels = Key( + None, list, "Label", + "You can specify explicit y labels", + "Must be a list of numbers", float) + + y_labels_major = Key( + None, list, "Label", + "Y labels that will be marked major. Default: auto", + subtype=str) + + y_labels_major_every = Key( + None, int, "Label", + "Mark every n-th y label as major.") + + y_labels_major_count = Key( + None, int, "Label", + "Mark n evenly distributed y labels as major.") + + show_minor_y_labels = Key( + True, bool, "Label", "Set to false to hide y-labels not marked major") + + show_y_labels = Key( + True, bool, "Label", "Set to false to hide y-labels") + + x_label_rotation = Key( + 0, int, "Label", "Specify x labels rotation angles", "in degrees") + + y_label_rotation = Key( + 0, int, "Label", "Specify y labels rotation angles", "in degrees") + + missing_value_fill_truncation = Key( + "x", str, "Look", + "Filled series with missing x and/or y values at the end of a series " + "are closed at the first value with a missing " + "'x' (default), 'y' or 'either'") + + # Value # + x_value_formatter = Key( + formatters.default, callable, "Value", + "A function to convert abscissa numeric value to strings " + "(used in XY and Date charts)") + + value_formatter = Key( + formatters.default, callable, "Value", + "A function to convert ordinate numeric value to strings") + + logarithmic = Key( + False, bool, "Value", "Display values in logarithmic scale") + + interpolate = Key( + None, str, "Value", "Interpolation", + "May be %s" % ' or '.join(INTERPOLATIONS)) + + interpolation_precision = Key( + 250, int, "Value", "Number of interpolated points between two values") + + interpolation_parameters = Key( + {}, dict, "Value", "Various parameters for parametric interpolations", + "ie: For hermite interpolation, you can set the cardinal tension with" + "{'type': 'cardinal', 'c': .5}", int) + + box_mode = Key( + 'extremes', str, "Value", "Sets the mode to be used. " + "(Currently only supported on box plot)", + "May be %s" % ' or '.join([ + "1.5IQR", "extremes", "tukey", "stdev", "pstdev"])) + + order_min = Key( + None, int, "Value", + "Minimum order of scale, defaults to None") + + min_scale = Key( + 4, int, "Value", + "Minimum number of scale graduation for auto scaling") + + max_scale = Key( + 16, int, "Value", + "Maximum number of scale graduation for auto scaling") + + range = Key( + None, list, "Value", "Explicitly specify min and max of values", + "(ie: (0, 100))", int) + + secondary_range = Key( + None, list, "Value", + "Explicitly specify min and max of secondary values", + "(ie: (0, 100))", int) + + xrange = Key( + None, list, "Value", "Explicitly specify min and max of x values " + "(used in XY and Date charts)", + "(ie: (0, 100))", int) + + include_x_axis = Key( + False, bool, "Value", "Always include x axis") + + zero = Key( + 0, int, "Value", + "Set the ordinate zero value", + "Useful for filling to another base than abscissa") + + # Text # + no_data_text = Key( + "No data", str, "Text", "Text to display when no data is given") + + print_values = Key( + False, bool, + "Text", "Display values as text over plot") + + dynamic_print_values = Key( + False, bool, + "Text", "Show values only on hover") + + print_values_position = Key( + 'center', str, + "Text", "Customize position of `print_values`. " + "(For bars: `top`, `center` or `bottom`)") + + print_zeroes = Key( + True, bool, + "Text", "Display zero values as well") + + print_labels = Key( + False, bool, + "Text", "Display value labels") + + truncate_legend = Key( + None, int, "Text", + "Legend string length truncation threshold", + "None = auto, Negative for none") + + truncate_label = Key( + None, int, "Text", + "Label string length truncation threshold", + "None = auto, Negative for none") + + # Misc # + js = Key( + ('//kozea.github.io/pygal.js/2.0.x/pygal-tooltips.min.js',), + list, "Misc", "List of js file", + "It can be any uri from file:///tmp/ext.js to //domain/ext.js", + str) + + disable_xml_declaration = Key( + False, bool, "Misc", + "Don't write xml declaration and return str instead of string", + "useful for writing output directly in html") + + force_uri_protocol = Key( + 'https', str, "Misc", + "Default uri protocol", + "Default protocol for external files. " + "Can be set to None to use a // uri") + + explicit_size = Key( + False, bool, "Misc", "Write width and height attributes") + + pretty_print = Key( + False, bool, "Misc", "Pretty print the svg") + + strict = Key( + False, bool, "Misc", + "If True don't try to adapt / filter wrong values") + + no_prefix = Key( + False, bool, "Misc", + "Don't prefix css") + + inverse_y_axis = Key(False, bool, "Misc", "Inverse Y axis direction") + + +class SerieConfig(CommonConfig): + + """Class holding serie config values""" + + title = Key( + None, str, "Look", + "Serie title.", "Leave it to None to disable title.") + + secondary = Key( + False, bool, "Misc", + "Set it to put the serie in a second axis") diff --git a/pygal/css/base.css b/pygal/css/base.css new file mode 100644 index 0000000..01f5d5b --- /dev/null +++ b/pygal/css/base.css @@ -0,0 +1,69 @@ +/* + * This file is part of pygal + * + * A python svg graph plotting library + * Copyright © 2012 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 . +*/ + +/* + * Font-sizes from config, override with care + */ + +{{ id }} { + -webkit-user-select: none; + -webkit-font-smoothing: antialiased; + font-family: {{ style.font_family }}; +} + +{{ id }}.title { + font-family: {{ style.title_font_family }}; + font-size: {{ style.title_font_size }}px; +} + +{{ id }}.legends .legend text { + font-family: {{ style.legend_font_family }}; + font-size: {{ style.legend_font_size }}px; +} + +{{ id }}.axis text { + font-family: {{ style.label_font_family }}; + font-size: {{ style.label_font_size }}px; +} + +{{ id }}.axis text.major { + font-family: {{ style.major_label_font_family }}; + font-size: {{ style.major_label_font_size }}px; +} + +{{ id }}.text-overlay text.value { + font-family: {{ style.value_font_family }}; + font-size: {{ style.value_font_size }}px; +} + +{{ id }}.text-overlay text.label { + font-family: {{ style.value_label_font_family }}; + font-size: {{ style.value_label_font_size }}px; +} + +{{ id }}.tooltip { + font-family: {{ style.tooltip_font_family }}; + font-size: {{ style.tooltip_font_size }}px; +} + +{{ id }}text.no_data { + font-family: {{ style.no_data_font_family }}; + font-size: {{ style.no_data_font_size }}px; +} diff --git a/pygal/css/graph.css b/pygal/css/graph.css new file mode 100644 index 0000000..a638fb7 --- /dev/null +++ b/pygal/css/graph.css @@ -0,0 +1,152 @@ +/* + * This file is part of pygal + * + * A python svg graph plotting library + * Copyright © 2012 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 . +*/ + +{{ id }}text.no_data { + text-anchor: middle; +} + +{{ id }}.guide.line { + fill: none; +} + +{{ id }}.centered { + text-anchor: middle; +} + +{{ id }}.title { + text-anchor: middle; +} + +{{ id }}.legends .legend text { + fill-opacity: 1; +} + +{{ id }}.axis.x text { + text-anchor: middle; +} + +{{ id }}.axis.x:not(.web) text[transform] { + text-anchor: start; +} + +{{ id }}.axis.x:not(.web) text[transform].backwards { + text-anchor: end; +} + +{{ id }}.axis.y text { + text-anchor: end; +} + +{{ id }}.axis.y text[transform].backwards { + text-anchor: start; +} + +{{ id }}.axis.y2 text { + text-anchor: start; +} + +{{ id }}.axis.y2 text[transform].backwards { + text-anchor: end; +} + +{{ id }}.axis .guide.line { + stroke-dasharray: {{ style.guide_stroke_dasharray }}; +} + +{{ id }}.axis .major.guide.line { + stroke-dasharray: {{ style.major_guide_stroke_dasharray }}; +} + +{{ id }}.horizontal .axis.y .guide.line, +{{ id }}.horizontal .axis.y2 .guide.line, +{{ id }}.vertical .axis.x .guide.line { + opacity: 0; +} + +{{ id }}.horizontal .axis.always_show .guide.line, +{{ id }}.vertical .axis.always_show .guide.line { + opacity: 1 !important; +} + +{{ id }}.axis.y .guides:hover .guide.line, +{{ id }}.axis.y2 .guides:hover .guide.line, +{{ id }}.axis.x .guides:hover .guide.line { + opacity: 1; +} + +{{ id }}.axis .guides:hover text { + opacity: 1; +} + +{{ id }}.nofill { + fill: none; +} + +{{ id }}.subtle-fill { + fill-opacity: .2; +} + +{{ id }}.dot { + stroke-width: 1px; + fill-opacity: 1; +} + +{{ id }}.dot.active { + stroke-width: 5px; +} + +{{ id }}.dot.negative { + fill: transparent; +} + +{{ id }} text, {{ id }} tspan { + stroke: none !important; +} + +{{ id }}.series text.active { + opacity: 1; +} + +{{ id }}.tooltip rect { + fill-opacity: .95; + stroke-width: .5; +} + +{{ id }}.tooltip text { + fill-opacity: 1; +} + +{{ id }}.showable { + visibility: hidden; +} + +{{ id }}.showable.shown { + visibility: visible; +} + +{{ id }}.gauge-background { + fill: {{ style.value_background }}; + stroke: none; +} + +{{ id }}.bg-lines { + stroke: {{ style.background }}; + stroke-width: 2px; +} diff --git a/pygal/css/style.css b/pygal/css/style.css new file mode 100644 index 0000000..cd01f06 --- /dev/null +++ b/pygal/css/style.css @@ -0,0 +1,174 @@ +/* + * This file is part of pygal + * + * A python svg graph plotting library + * Copyright © 2012 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 . +*/ + +/* + * Styles from config + */ + +{{ id }}{ + background-color: {{ style.background }}; +} + +{{ id }}path, +{{ id }}line, +{{ id }}rect, +{{ id }}circle { + -webkit-transition: {{ style.transition }}; + -moz-transition: {{ style.transition }}; + transition: {{ style.transition }}; +} + +{{ id }}.graph > .background { + fill: {{ style.background }}; +} + +{{ id }}.plot > .background { + fill: {{ style.plot_background }}; +} + +{{ id }}.graph { + fill: {{ style.foreground }}; +} + +{{ id }}text.no_data { + fill: {{ style.foreground_strong }}; +} + +{{ id }}.title { + fill: {{ style.foreground_strong }}; +} + +{{ id }}.legends .legend text { + fill: {{ style.foreground }}; +} + +{{ id }}.legends .legend:hover text { + fill: {{ style.foreground_strong }}; +} + +{{ id }}.axis .line { + stroke: {{ style.foreground_strong }}; +} + +{{ id }}.axis .guide.line { + stroke: {{ style.foreground_subtle }}; +} + +{{ id }}.axis .major.line { + stroke: {{ style.foreground }}; +} + +{{ id }}.axis text.major { + fill: {{ style.foreground_strong }}; +} + +{{ id }}.axis.y .guides:hover .guide.line, +{{ id }}.line-graph .axis.x .guides:hover .guide.line, +{{ id }}.stackedline-graph .axis.x .guides:hover .guide.line, +{{ id }}.xy-graph .axis.x .guides:hover .guide.line { + stroke: {{ style.foreground_strong }}; +} + +{{ id }}.axis .guides:hover text { + fill: {{ style.foreground_strong }}; +} + + +{{ id }}.reactive { + fill-opacity: {{ style.opacity }}; + stroke-opacity: {{ style.stroke_opacity }}; +} + +{{ id }}.ci { + stroke: {{ style.foreground }}; +} + +{{ id }}.reactive.active, +{{ id }}.active .reactive { + fill-opacity: {{ style.opacity_hover }}; + stroke-opacity: {{ style.stroke_opacity_hover }}; + stroke-width: 4; +} + +{{ id }}.ci .reactive.active { + stroke-width: 1.5; +} + +{{ id }}.series text { + fill: {{ style.foreground_strong }}; +} + +{{ id }}.tooltip rect { + fill: {{ style.plot_background }}; + stroke: {{ style.foreground_strong }}; + -webkit-transition: opacity {{ style.transition }}; + -moz-transition: opacity {{ style.transition }}; + transition: opacity {{ style.transition }}; +} + +{{ id }}.tooltip .label { + fill: {{ style.foreground }}; +} + +{{ id }}.tooltip .label { + fill: {{ style.foreground }}; +} + +{{ id }}.tooltip .legend { + font-size: .8em; + fill: {{ style.foreground_subtle }}; +} + +{{ id }}.tooltip .x_label { + font-size: .6em; + fill: {{ style.foreground_strong }}; +} + +{{ id }}.tooltip .xlink { + font-size: .5em; + text-decoration: underline; +} + +{{ id }}.tooltip .value { + font-size: 1.5em; +} + +{{ id }}.bound { + font-size: .5em; +} + +{{ id }}.max-value { + font-size: .75em; + fill: {{ style.foreground_subtle }}; +} + +{{ id }}.map-element { + fill: {{ style.plot_background }}; + stroke: {{ style.foreground_subtle }} !important; +} + +{{ id }}.map-element .reactive { + fill-opacity: inherit; + stroke-opacity: inherit; +} + +{{ colors }} + +{{ strokes }} diff --git a/pygal/etree.py b/pygal/etree.py new file mode 100644 index 0000000..6ed513f --- /dev/null +++ b/pygal/etree.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +""" +Wrapper for seamless lxml.etree / xml.etree usage +depending on whether lxml is installed or not. +""" + +import os + + +class Etree(object): + + """Etree wrapper using lxml.etree or standard xml.etree""" + + def __init__(self): + """Create the wrapper""" + from xml.etree import ElementTree as _py_etree + self._py_etree = _py_etree + try: + from lxml import etree as _lxml_etree + self._lxml_etree = _lxml_etree + except ImportError: + self._lxml_etree = None + + if os.getenv('NO_LXML', None): + self._etree = self._py_etree + else: + self._etree = self._lxml_etree or self._py_etree + self.lxml = self._etree is self._lxml_etree + + def __getattribute__(self, attr): + """Retrieve attr from current active etree implementation""" + if (attr not in object.__getattribute__(self, '__dict__') and + attr not in Etree.__dict__): + return object.__getattribute__(self._etree, attr) + return object.__getattribute__(self, attr) + + def to_lxml(self): + """Force lxml.etree to be used""" + self._etree = self._lxml_etree + self.lxml = True + + def to_etree(self): + """Force xml.etree to be used""" + self._etree = self._py_etree + self.lxml = False + + +etree = Etree() diff --git a/pygal/formatters.py b/pygal/formatters.py new file mode 100644 index 0000000..fe901b5 --- /dev/null +++ b/pygal/formatters.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +""" +Formatters to use with `value_formatter` and `x_value_formatter` configs + +""" +from __future__ import division + +from datetime import date, datetime, time +from math import floor, log + +from pygal._compat import to_str, u +from pygal.util import float_format + + +class Formatter(object): + pass + + +class HumanReadable(Formatter): + """Format a number to engineer scale""" + ORDERS = u("yzafpnµm kMGTPEZY") + + def __init__(self, none_char=u('∅')): + self.none_char = none_char + + def __call__(self, val): + if val is None: + return self.none_char + order = val and int(floor(log(abs(val)) / log(1000))) + orders = self.ORDERS.split(" ")[int(order > 0)] + if order == 0 or order > len(orders): + return float_format(val / (1000 ** int(order))) + return ( + float_format(val / (1000 ** int(order))) + + orders[int(order) - int(order > 0)]) + + +class Significant(Formatter): + """Show precision significant digit of float""" + def __init__(self, precision=10): + self.format = '%%.%dg' % precision + + def __call__(self, val): + if val is None: + return '' + return self.format % val + + +class Integer(Formatter): + """Cast number to integer""" + + def __call__(self, val): + if val is None: + return '' + return '%d' % val + + +class Raw(Formatter): + """Cast everything to string""" + + def __call__(self, val): + if val is None: + return '' + return to_str(val) + + +class IsoDateTime(Formatter): + """Iso format datetimes""" + + def __call__(self, val): + if val is None: + return '' + return val.isoformat() + + +class Default(Significant, IsoDateTime, Raw): + """Try to guess best format from type""" + + def __call__(self, val): + if val is None: + return '' + if isinstance(val, (int, float)): + return Significant.__call__(self, val) + if isinstance(val, (date, time, datetime)): + return IsoDateTime.__call__(self, val) + return Raw.__call__(self, val) + + +# Formatters with default options +human_readable = HumanReadable() +significant = Significant() +integer = Integer() +raw = Raw() + +# Default config formatter +default = Default() diff --git a/pygal/graph/__init__.py b/pygal/graph/__init__.py new file mode 100644 index 0000000..98c527c --- /dev/null +++ b/pygal/graph/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Graph package containing all builtin charts""" diff --git a/pygal/graph/bar.py b/pygal/graph/bar.py new file mode 100644 index 0000000..488b759 --- /dev/null +++ b/pygal/graph/bar.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +""" +Bar chart that presents grouped data with rectangular bars with lengths +proportional to the values that they represent. +""" + +from __future__ import division + +from pygal.graph.graph import Graph +from pygal.util import alter, decorate, ident, swap + + +class Bar(Graph): + + """Bar graph class""" + + _series_margin = .06 + _serie_margin = .06 + + def _bar(self, serie, parent, x, y, i, zero, secondary=False): + """Internal bar drawing function""" + 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 + width -= 2 * series_margin + width /= self._order + if self.horizontal: + serie_index = self._order - serie.index - 1 + else: + serie_index = serie.index + x += serie_index * width + + serie_margin = width * self._serie_margin + x += serie_margin + width -= 2 * serie_margin + height = self.view.y(zero) - y + r = serie.rounded_bars * 1 if serie.rounded_bars else 0 + alter(self.svg.transposable_node( + parent, 'rect', + x=x, y=y, rx=r, ry=r, width=width, height=height, + class_='rect reactive tooltip-trigger'), serie.metadata.get(i)) + return x, y, width, height + + def _tooltip_and_print_values( + self, serie_node, serie, parent, i, val, metadata, + x, y, width, height): + transpose = swap if self.horizontal else ident + x_center, y_center = transpose((x + width / 2, y + height / 2)) + x_top, y_top = transpose((x + width, y + height)) + x_bottom, y_bottom = transpose((x, y)) + if self._dual: + v = serie.values[i][0] + else: + v = serie.values[i] + sign = -1 if v < self.zero else 1 + self._tooltip_data( + parent, val, x_center, y_center, "centered", + self._get_x_label(i)) + + if self.print_values_position == 'top': + if self.horizontal: + x = x_bottom + sign * self.style.value_font_size / 2 + y = y_center + else: + x = x_center + y = y_bottom - sign * self.style.value_font_size / 2 + elif self.print_values_position == 'bottom': + if self.horizontal: + x = x_top + sign * self.style.value_font_size / 2 + y = y_center + else: + x = x_center + y = y_top - sign * self.style.value_font_size / 2 + else: + x = x_center + y = y_center + self._static_value(serie_node, val, x, y, metadata, "middle") + + def bar(self, serie, rescale=False): + """Draw a bar graph for a serie""" + serie_node = self.svg.serie(serie) + bars = self.svg.node(serie_node['plot'], class_="bars") + if rescale and self.secondary_series: + points = self._rescale(serie.points) + else: + points = serie.points + + for i, (x, y) in enumerate(points): + if None in (x, y) or (self.logarithmic and y <= 0): + continue + metadata = serie.metadata.get(i) + val = self._format(serie, i) + + bar = decorate( + self.svg, + self.svg.node(bars, class_='bar'), + metadata) + + x_, y_, width, height = self._bar( + serie, bar, x, y, i, self.zero, secondary=rescale) + + self._confidence_interval( + serie_node['overlay'], x_ + width / 2, y_, serie.values[i], + metadata) + + self._tooltip_and_print_values( + serie_node, serie, bar, i, val, metadata, + x_, y_, width, height) + + def _compute(self): + """Compute y min and max and y scale and set labels""" + if self._min: + self._box.ymin = min(self._min, self.zero) + if self._max: + self._box.ymax = max(self._max, self.zero) + self._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(self._x_pos) + + self._x_pos = [(i + .5) / self._len for i in range(self._len)] + + def _plot(self): + """Draw bars for series and secondary series""" + for serie in self.series: + self.bar(serie) + for serie in self.secondary_series: + self.bar(serie, True) diff --git a/pygal/graph/base.py b/pygal/graph/base.py new file mode 100644 index 0000000..cd33a74 --- /dev/null +++ b/pygal/graph/base.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Base for pygal charts""" + +from __future__ import division + +import os +from functools import reduce +from uuid import uuid4 + +from pygal._compat import is_list_like +from pygal.adapters import decimal_to_float, not_zero, positive +from pygal.config import Config, SerieConfig +from pygal.serie import Serie +from pygal.state import State +from pygal.svg import Svg +from pygal.util import compose, ident +from pygal.view import Box, Margin + + +class BaseGraph(object): + + """Chart internal behaviour related functions""" + + _adapters = [] + + def __init__(self, config=None, **kwargs): + """Config preparation and various initialization""" + if config: + if isinstance(config, type): + config = config() + else: + config = config.copy() + else: + config = Config() + + config(**kwargs) + self.config = config + self.state = None + self.uuid = str(uuid4()) + self.raw_series = [] + self.xml_filters = [] + + def __setattr__(self, name, value): + """Set an attribute on the class or in the state if there is one""" + if name.startswith('__') or getattr(self, 'state', None) is None: + super(BaseGraph, self).__setattr__(name, value) + else: + setattr(self.state, name, value) + + def __getattribute__(self, name): + """Get an attribute from the class or from the state if there is one""" + if name.startswith('__') or name == 'state' or getattr( + self, 'state', None + ) is None or name not in self.state.__dict__: + return super(BaseGraph, self).__getattribute__(name) + return getattr(self.state, name) + + def prepare_values(self, raw, offset=0): + """Prepare the values to start with sane values""" + from pygal.graph.map import BaseMap + from pygal import Histogram + + if self.zero == 0 and isinstance(self, BaseMap): + self.zero = 1 + + if self.x_label_rotation: + self.x_label_rotation %= 360 + + if self.y_label_rotation: + self.y_label_rotation %= 360 + + for key in ('x_labels', 'y_labels'): + if getattr(self, key): + setattr(self, key, list(getattr(self, key))) + if not raw: + return + + adapters = list(self._adapters) or [lambda x:x] + if self.logarithmic: + for fun in not_zero, positive: + if fun in adapters: + adapters.remove(fun) + adapters = adapters + [positive, not_zero] + adapters = adapters + [decimal_to_float] + + self._adapt = reduce(compose, adapters) if not self.strict else ident + self._x_adapt = reduce( + compose, self._x_adapters) if not self.strict and getattr( + self, '_x_adapters', None) else ident + + series = [] + + raw = [( + list(raw_values) if not isinstance( + raw_values, dict) else raw_values, + serie_config_kwargs + ) for raw_values, serie_config_kwargs in raw] + + width = max([len(values) for values, _ in raw] + + [len(self.x_labels or [])]) + + for raw_values, serie_config_kwargs in raw: + metadata = {} + values = [] + if isinstance(raw_values, dict): + if isinstance(self, BaseMap): + raw_values = list(raw_values.items()) + else: + value_list = [None] * width + for k, v in raw_values.items(): + if k in (self.x_labels or []): + value_list[self.x_labels.index(k)] = v + raw_values = value_list + + for index, raw_value in enumerate( + raw_values + ( + (width - len(raw_values)) * [None] # aligning values + if len(raw_values) < width else [])): + if isinstance(raw_value, dict): + raw_value = dict(raw_value) + value = raw_value.pop('value', None) + metadata[index] = raw_value + else: + value = raw_value + + # Fix this by doing this in charts class methods + if isinstance(self, Histogram): + if value is None: + value = (None, None, None) + elif not is_list_like(value): + value = (value, self.zero, self.zero) + elif len(value) == 2: + value = (1, value[0], value[1]) + value = list(map(self._adapt, value)) + elif self._dual: + if value is None: + value = (None, None) + elif not is_list_like(value): + value = (value, self.zero) + if self._x_adapt: + value = ( + self._x_adapt(value[0]), + self._adapt(value[1])) + if isinstance(self, BaseMap): + value = (self._adapt(value[0]), value[1]) + else: + value = list(map(self._adapt, value)) + else: + value = self._adapt(value) + + values.append(value) + serie_config = SerieConfig() + serie_config(**dict((k, v) for k, v in self.state.__dict__.items() + if k in dir(serie_config))) + serie_config(**serie_config_kwargs) + series.append( + Serie(offset + len(series), values, serie_config, metadata)) + return series + + def setup(self, **kwargs): + """Set up the transient state prior rendering""" + # Keep labels in case of map + if getattr(self, 'x_labels', None) is not None: + self.x_labels = list(self.x_labels) + if getattr(self, 'y_labels', None) is not None: + self.y_labels = list(self.y_labels) + self.state = State(self, **kwargs) + if isinstance(self.style, type): + self.style = self.style() + self.series = self.prepare_values( + [rs for rs in self.raw_series if not rs[1].get('secondary')]) or [] + self.secondary_series = self.prepare_values( + [rs for rs in self.raw_series if rs[1].get('secondary')], + len(self.series)) or [] + self.horizontal = getattr(self, 'horizontal', False) + self.svg = Svg(self) + self._x_labels = None + self._y_labels = None + self._x_2nd_labels = None + self._y_2nd_labels = None + self.nodes = {} + self.margin_box = Margin( + self.margin_top or self.margin, + self.margin_right or self.margin, + self.margin_bottom or self.margin, + self.margin_left or self.margin) + self._box = Box() + self.view = None + if self.logarithmic and self.zero == 0: + # Explicit min to avoid interpolation dependency + positive_values = list(filter( + lambda x: x > 0, + [val[1] or 1 if self._dual else val + for serie in self.series for val in serie.safe_values])) + + self.zero = min(positive_values or (1,)) or 1 + if self._len < 3: + self.interpolate = None + self._draw() + self.svg.pre_render() + + def teardown(self): + """Remove the transient state after rendering""" + if os.getenv('PYGAL_KEEP_STATE'): + return + + del self.state + self.state = None + + def _repr_svg_(self): + """Display svg in IPython notebook""" + return self.render(disable_xml_declaration=True) + + def _repr_png_(self): + """Display png in IPython notebook""" + return self.render_to_png() diff --git a/pygal/graph/box.py b/pygal/graph/box.py new file mode 100644 index 0000000..5a77afc --- /dev/null +++ b/pygal/graph/box.py @@ -0,0 +1,287 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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: a convenient way to display series as box with whiskers and outliers +Different types are available throught the box_mode option +""" + +from __future__ import division + +from bisect import bisect_left, bisect_right + +from pygal.graph.graph import Graph +from pygal.util import alter, decorate + + +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 _value_format(self, value, serie): + """ + Format value for dual value display. + """ + if self.box_mode == "extremes": + return ( + 'Min: %s\nQ1 : %s\nQ2 : %s\nQ3 : %s\nMax: %s' % tuple( + map(self._y_format, serie.points[1:6]))) + elif self.box_mode in ["tukey", "stdev", "pstdev"]: + return ( + 'Min: %s\nLower Whisker: %s\nQ1: %s\nQ2: %s\nQ3: %s\n' + 'Upper Whisker: %s\nMax: %s' % tuple(map( + self._y_format, serie.points))) + elif self.box_mode == '1.5IQR': + # 1.5IQR mode + return 'Q1: %s\nQ2: %s\nQ3: %s' % tuple(map( + self._y_format, serie.points[2:5])) + else: + return self._y_format(serie.points) + + def _compute(self): + """ + Compute parameters necessary for later steps + within the rendering process + """ + for serie in self.series: + serie.points, serie.outliers = \ + self._box_points(serie.values, self.box_mode) + + self._x_pos = [ + (i + .5) / self._order for i in range(self._order)] + + if self._min: + self._box.ymin = min(self._min, self.zero) + if self._max: + self._box.ymax = max(self._max, self.zero) + + def _plot(self): + """Plot the series data""" + for serie in self.series: + self._boxf(serie) + + @property + def _len(self): + """Len is always 7 here""" + return 7 + + def _boxf(self, serie): + """For a specific series, draw the box plot.""" + serie_node = self.svg.serie(serie) + # 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, 0) + + x_center, y_center = self._draw_box( + box, serie.points[1:6], serie.outliers, serie.index, metadata) + self._tooltip_data(box, val, x_center, y_center, "centered", + self._get_x_label(serie.index)) + self._static_value(serie_node, val, x_center, y_center, metadata) + + def _draw_box(self, parent_node, quartiles, outliers, box_index, metadata): + """ + 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 + alter(self.svg.line( + parent_node, + coords=[(xs, self.view.y(whisker)), + (xe, self.view.y(whisker))], + class_='reactive tooltip-trigger', + attrib={'stroke-width': 3}), metadata) + + # draw lines connecting whiskers to box (Q1 and Q3) + alter(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}), metadata) + alter(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}), metadata) + + # box, bounded by Q1 and Q3 + alter(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'), metadata) + + # draw outliers + for o in outliers: + alter(self.svg.node( + parent_node, + tag='circle', + cx=left_edge + width / 2, + cy=self.view.y(o), + r=3, + class_='subtle-fill reactive tooltip-trigger'), metadata) + + return (left_edge + width / 2, self.view.y( + sum(quartiles) / len(quartiles))) + + @staticmethod + def _box_points(values, mode='extremes'): + """ + Default mode: (mode='extremes' or unset) + Return a 7-tuple of 2x minimum, Q1, Median, Q3, + and 2x maximum for a list of numeric values. + 1.5IQR mode: (mode='1.5IQR') + Return a 7-tuple of min, Q1 - 1.5 * IQR, Q1, Median, Q3, + Q3 + 1.5 * IQR and max for a list of numeric values. + Tukey mode: (mode='tukey') + Return a 7-tuple of min, q[0..4], max and a list of outliers + Outliers are considered values x: x < q1 - IQR or x > q3 + IQR + SD mode: (mode='stdev') + Return a 7-tuple of min, q[0..4], max and a list of outliers + Outliers are considered values x: x < q2 - SD or x > q2 + SD + SDp mode: (mode='pstdev') + Return a 7-tuple of min, q[0..4], max and a list of outliers + Outliers are considered values x: x < q2 - SDp or x > q2 + SDp + + 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] + + def mean(seq): + return sum(seq) / len(seq) + + def stdev(seq): + m = mean(seq) + l = len(seq) + v = sum((n - m)**2 for n in seq) / (l - 1) # variance + return v**0.5 # sqrt + + def pstdev(seq): + m = mean(seq) + l = len(seq) + v = sum((n - m)**2 for n in seq) / l # variance + return v**0.5 # sqrt + + outliers = [] + # 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, 0, 0), [] + elif n == 1: + return (s[0], s[0], s[0], s[0], s[0], s[0], s[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 + min_s = s[0] + max_s = s[-1] + if mode == 'extremes': + q0 = min_s + q4 = max_s + elif mode == 'tukey': + # the lowest datum still within 1.5 IQR of the lower quartile, + # and the highest datum still within 1.5 IQR of the upper + # quartile [Tukey box plot, Wikipedia ] + b0 = bisect_left(s, q1 - 1.5 * iqr) + b4 = bisect_right(s, q3 + 1.5 * iqr) + q0 = s[b0] + q4 = s[b4 - 1] + outliers = s[:b0] + s[b4:] + elif mode == 'stdev': + # one standard deviation above and below the mean of the data + sd = stdev(s) + b0 = bisect_left(s, q2 - sd) + b4 = bisect_right(s, q2 + sd) + q0 = s[b0] + q4 = s[b4 - 1] + outliers = s[:b0] + s[b4:] + elif mode == 'pstdev': + # one population standard deviation above and below + # the mean of the data + sdp = pstdev(s) + b0 = bisect_left(s, q2 - sdp) + b4 = bisect_right(s, q2 + sdp) + q0 = s[b0] + q4 = s[b4 - 1] + outliers = s[:b0] + s[b4:] + elif mode == '1.5IQR': + # 1.5IQR mode + q0 = q1 - 1.5 * iqr + q4 = q3 + 1.5 * iqr + return (min_s, q0, q1, q2, q3, q4, max_s), outliers diff --git a/pygal/graph/dot.py b/pygal/graph/dot.py new file mode 100644 index 0000000..89aadcb --- /dev/null +++ b/pygal/graph/dot.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +""" +Dot chart displaying values as a grid of dots, the bigger the value +the bigger the dot +""" + +from __future__ import division + +from math import log10 + +from pygal._compat import to_str +from pygal.graph.graph import Graph +from pygal.util import alter, cached_property, decorate, safe_enumerate +from pygal.view import ReverseView, View + + +class Dot(Graph): + + """Dot graph class""" + + def dot(self, serie, r_max): + """Draw a dot line""" + serie_node = self.svg.serie(serie) + view_values = list(map(self.view, serie.points)) + for i, value in safe_enumerate(serie.values): + x, y = view_values[i] + + if self.logarithmic: + log10min = log10(self._min) - 1 + log10max = log10(self._max or 1) + + if value != 0: + size = r_max * ( + (log10(abs(value)) - log10min) / + (log10max - log10min) + ) + else: + size = 0 + else: + size = r_max * (abs(value) / (self._max or 1)) + + metadata = serie.metadata.get(i) + dots = decorate( + self.svg, + self.svg.node(serie_node['plot'], class_="dots"), + metadata) + alter(self.svg.node( + dots, 'circle', + cx=x, cy=y, r=size, + class_='dot reactive tooltip-trigger' + ( + ' negative' if value < 0 else '')), metadata) + + val = self._format(serie, i) + self._tooltip_data( + dots, val, x, y, 'centered', + self._get_x_label(i)) + self._static_value(serie_node, val, x, y, metadata) + + def _compute(self): + """Compute y min and max and y scale and set labels""" + x_len = self._len + y_len = self._order + self._box.xmax = x_len + self._box.ymax = y_len + + self._x_pos = [n / 2 for n in range(1, 2 * x_len, 2)] + self._y_pos = [n / 2 for n in reversed(range(1, 2 * y_len, 2))] + + for j, serie in enumerate(self.series): + serie.points = [ + (self._x_pos[i], self._y_pos[j]) + for i in range(x_len)] + + def _compute_y_labels(self): + self._y_labels = list(zip( + self.y_labels and map(to_str, self.y_labels) or [ + serie.title['title'] + if isinstance(serie.title, dict) + else serie.title or '' for serie in self.series], + self._y_pos)) + + def _set_view(self): + """Assign a view to current graph""" + view_class = ReverseView if self.inverse_y_axis else View + + self.view = view_class( + self.width - self.margin_box.x, + self.height - self.margin_box.y, + self._box) + + @cached_property + def _values(self): + """Getter for series values (flattened)""" + return [abs(val) for val in super(Dot, self)._values if val != 0] + + @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(map(abs, self._values)) if self._values else None)) + + def _plot(self): + """Plot all dots for series""" + r_max = min( + self.view.x(1) - self.view.x(0), + (self.view.y(0) or 0) - self.view.y(1)) / ( + 2 * 1.05) + for serie in self.series: + self.dot(serie, r_max) diff --git a/pygal/graph/dual.py b/pygal/graph/dual.py new file mode 100644 index 0000000..ac8d4a8 --- /dev/null +++ b/pygal/graph/dual.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Dual chart base. Dual means a chart with 2 scaled axis like xy""" + +from pygal._compat import is_str +from pygal.graph.graph import Graph +from pygal.util import compute_scale, cut + + +class Dual(Graph): + _dual = True + + def _value_format(self, value): + """ + Format value for dual value display. + """ + return '%s: %s' % ( + self._x_format(value[0]), + self._y_format(value[1])) + + def _compute_x_labels(self): + x_pos = compute_scale( + self._box.xmin, self._box.xmax, self.logarithmic, + self.order_min, self.min_scale, self.max_scale + ) + if self.x_labels: + self._x_labels = [] + for i, x_label in enumerate(self.x_labels): + if isinstance(x_label, dict): + pos = self._x_adapt(x_label.get('value')) + title = x_label.get('label', self._x_format(pos)) + elif is_str(x_label): + pos = self._x_adapt(x_pos[i % len(x_pos)]) + title = x_label + else: + pos = self._x_adapt(x_label) + title = self._x_format(pos) + + self._x_labels.append((title, pos)) + self._box.xmin = min(self._box.xmin, min(cut(self._x_labels, 1))) + self._box.xmax = max(self._box.xmax, max(cut(self._x_labels, 1))) + + else: + self._x_labels = list(zip(map(self._x_format, x_pos), x_pos)) + + def _compute_x_labels_major(self): + # In case of dual, x labels must adapters and so majors too + self.x_labels_major = self.x_labels_major and list( + map(self._x_adapt, self.x_labels_major)) + super(Dual, self)._compute_x_labels_major() + + def _get_x_label(self, i): + """Convenience function to get the x_label of a value index""" + return diff --git a/pygal/graph/funnel.py b/pygal/graph/funnel.py new file mode 100644 index 0000000..00e3dc7 --- /dev/null +++ b/pygal/graph/funnel.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +"""Funnel chart: Represent values as a funnel""" + +from __future__ import division + +from pygal.adapters import none_to_zero, positive +from pygal.graph.graph import Graph +from pygal.util import alter, cut, decorate + + +class Funnel(Graph): + + """Funnel graph class""" + + _adapters = [positive, none_to_zero] + + def _value_format(self, value): + """Format value for dual value display.""" + return super(Funnel, self)._value_format(value and abs(value)) + + def funnel(self, serie): + """Draw a funnel slice""" + serie_node = self.svg.serie(serie) + fmt = lambda x: '%f %f' % x + for i, poly in enumerate(serie.points): + metadata = serie.metadata.get(i) + val = self._format(serie, i) + + funnels = decorate( + self.svg, + self.svg.node(serie_node['plot'], class_="funnels"), + metadata) + + alter(self.svg.node( + funnels, 'polygon', + points=' '.join(map(fmt, map(self.view, poly))), + class_='funnel reactive tooltip-trigger'), metadata) + + # Poly center from label + x, y = self.view(( + self._center(self._x_pos[serie.index]), + sum([point[1] for point in poly]) / len(poly))) + self._tooltip_data( + funnels, val, x, y, 'centered', + self._get_x_label(serie.index)) + self._static_value(serie_node, val, x, y, metadata) + + def _center(self, x): + return x - 1 / (2 * self._order) + + def _compute(self): + """Compute y min and max and y scale and set labels""" + self._x_pos = [ + (x + 1) / self._order for x in range(self._order) + ] if self._order != 1 else [.5] # Center if only one value + + previous = [[self.zero, self.zero] for i in range(self._len)] + for i, serie in enumerate(self.series): + y_height = - sum(serie.safe_values) / 2 + all_x_pos = [0] + self._x_pos + serie.points = [] + for j, value in enumerate(serie.values): + poly = [] + poly.append((all_x_pos[i], previous[j][0])) + poly.append((all_x_pos[i], previous[j][1])) + previous[j][0] = y_height + y_height = previous[j][1] = y_height + value + poly.append((all_x_pos[i + 1], previous[j][1])) + poly.append((all_x_pos[i + 1], previous[j][0])) + serie.points.append(poly) + + val_max = max(list(map(sum, cut(self.series, 'values'))) + [self.zero]) + self._box.ymin = -val_max + self._box.ymax = val_max + + if self.range and self.range[0] is not None: + self._box.ymin = self.range[0] + + if self.range and self.range[1] is not None: + self._box.ymax = self.range[1] + + def _compute_x_labels(self): + self._x_labels = list( + zip(self.x_labels and + map(self._x_format, self.x_labels) or [ + serie.title['title'] + if isinstance(serie.title, dict) + else serie.title or '' for serie in self.series], + map(self._center, self._x_pos))) + + def _plot(self): + """Plot the funnel""" + for serie in self.series: + self.funnel(serie) diff --git a/pygal/graph/gauge.py b/pygal/graph/gauge.py new file mode 100644 index 0000000..974fafd --- /dev/null +++ b/pygal/graph/gauge.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Gauge chart representing values as needles on a polar scale""" + +from __future__ import division + +from pygal._compat import is_str +from pygal.graph.graph import Graph +from pygal.util import alter, compute_scale, cut, decorate +from pygal.view import PolarThetaLogView, PolarThetaView + + +class Gauge(Graph): + + """Gauge graph class""" + + needle_width = 1 / 20 + + def _set_view(self): + """Assign a view to current graph""" + if self.logarithmic: + view_class = PolarThetaLogView + else: + view_class = PolarThetaView + + self.view = view_class( + self.width - self.margin_box.x, + self.height - self.margin_box.y, + self._box) + + def needle(self, serie): + """Draw a needle for each value""" + serie_node = self.svg.serie(serie) + for i, theta in enumerate(serie.values): + if theta is None: + continue + + def point(x, y): + return '%f %f' % self.view((x, y)) + + val = self._format(serie, i) + metadata = serie.metadata.get(i) + gauges = decorate( + self.svg, + self.svg.node(serie_node['plot'], class_="dots"), + metadata) + + tolerance = 1.15 + + if theta < self._min: + theta = self._min * tolerance + + if theta > self._max: + theta = self._max * tolerance + + w = (self._box._tmax - self._box._tmin + self.view.aperture) / 4 + + if self.logarithmic: + w = min(w, self._min - self._min * 10 ** -10) + + alter( + self.svg.node( + gauges, 'path', d='M %s L %s A %s 1 0 1 %s Z' % ( + point(.85, theta), + point(self.needle_width, theta - w), + '%f %f' % (self.needle_width, self.needle_width), + point(self.needle_width, theta + w), + ), + class_='line reactive tooltip-trigger'), + metadata) + + x, y = self.view((.75, theta)) + self._tooltip_data( + gauges, val, x, y, + xlabel=self._get_x_label(i)) + self._static_value(serie_node, val, x, y, metadata) + + def _y_axis(self, draw_axes=True): + """Override y axis to plot a polar axis""" + axis = self.svg.node(self.nodes['plot'], class_="axis x gauge") + + for i, (label, theta) in enumerate(self._y_labels): + guides = self.svg.node(axis, class_='guides') + + self.svg.line( + guides, [self.view((.95, theta)), self.view((1, theta))], + close=True, + class_='line') + + self.svg.line( + guides, [self.view((0, theta)), self.view((.95, theta))], + close=True, + class_='guide line %s' % ( + 'major' if i in (0, len(self._y_labels) - 1) + else '')) + + x, y = self.view((.9, theta)) + self.svg.node( + guides, 'text', + x=x, + y=y + ).text = label + + self.svg.node( + guides, 'title', + ).text = self._y_format(theta) + + def _x_axis(self, draw_axes=True): + """Override x axis to put a center circle in center""" + axis = self.svg.node(self.nodes['plot'], class_="axis y gauge") + x, y = self.view((0, 0)) + self.svg.node(axis, 'circle', cx=x, cy=y, r=4) + + def _compute(self): + """Compute y min and max and y scale and set labels""" + self.min_ = self._min or 0 + self.max_ = self._max or 0 + if self.max_ - self.min_ == 0: + self.min_ -= 1 + self.max_ += 1 + + self._box.set_polar_box( + 0, 1, + self.min_, + self.max_) + + def _compute_x_labels(self): + pass + + def _compute_y_labels(self): + y_pos = compute_scale( + self.min_, self.max_, self.logarithmic, + self.order_min, self.min_scale, self.max_scale + ) + if self.y_labels: + self._y_labels = [] + for i, y_label in enumerate(self.y_labels): + if isinstance(y_label, dict): + pos = self._adapt(y_label.get('value')) + title = y_label.get('label', self._y_format(pos)) + elif is_str(y_label): + pos = self._adapt(y_pos[i]) + title = y_label + else: + pos = self._adapt(y_label) + title = self._y_format(pos) + self._y_labels.append((title, pos)) + self.min_ = min(self.min_, min(cut(self._y_labels, 1))) + self.max_ = max(self.max_, max(cut(self._y_labels, 1))) + self._box.set_polar_box( + 0, 1, + self.min_, + self.max_) + else: + self._y_labels = list(zip(map(self._y_format, y_pos), y_pos)) + + def _plot(self): + """Plot all needles""" + for serie in self.series: + self.needle(serie) diff --git a/pygal/graph/graph.py b/pygal/graph/graph.py new file mode 100644 index 0000000..0eebf4f --- /dev/null +++ b/pygal/graph/graph.py @@ -0,0 +1,945 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +"""Chart properties and drawing""" + +from __future__ import division + +from math import ceil, cos, sin, sqrt + +from pygal import stats +from pygal._compat import is_list_like, is_str, to_str +from pygal.graph.public import PublicApi +from pygal.interpolate import INTERPOLATIONS +from pygal.util import ( + cached_property, compute_scale, cut, decorate, filter_kwargs, get_text_box, + get_texts_box, majorize, rad, reverse_text_len, split_title, truncate) +from pygal.view import LogView, ReverseView, View, XYLogView + + +class Graph(PublicApi): + + """Graph super class containing generic common functions""" + + _dual = False + + def _decorate(self): + """Draw all decorations""" + self._set_view() + self._make_graph() + self._axes() + self._legend() + self._make_title() + self._make_x_title() + self._make_y_title() + + def _axes(self): + """Draw axes""" + self._y_axis() + self._x_axis() + + def _set_view(self): + """Assign a view to current graph""" + if self.logarithmic: + if self._dual: + view_class = XYLogView + else: + view_class = LogView + else: + view_class = ReverseView if self.inverse_y_axis else View + + self.view = view_class( + self.width - self.margin_box.x, + self.height - self.margin_box.y, + self._box) + + def _make_graph(self): + """Init common graph svg structure""" + self.nodes['graph'] = self.svg.node( + class_='graph %s-graph %s' % ( + self.__class__.__name__.lower(), + 'horizontal' if self.horizontal else 'vertical')) + self.svg.node(self.nodes['graph'], 'rect', + class_='background', + x=0, y=0, + width=self.width, + height=self.height) + self.nodes['plot'] = self.svg.node( + self.nodes['graph'], class_="plot", + transform="translate(%d, %d)" % ( + self.margin_box.left, self.margin_box.top)) + self.svg.node(self.nodes['plot'], 'rect', + class_='background', + x=0, y=0, + width=self.view.width, + height=self.view.height) + self.nodes['title'] = self.svg.node( + self.nodes['graph'], + class_="titles") + self.nodes['overlay'] = self.svg.node( + self.nodes['graph'], class_="plot overlay", + transform="translate(%d, %d)" % ( + self.margin_box.left, self.margin_box.top)) + self.nodes['text_overlay'] = self.svg.node( + self.nodes['graph'], class_="plot text-overlay", + transform="translate(%d, %d)" % ( + self.margin_box.left, self.margin_box.top)) + self.nodes['tooltip_overlay'] = self.svg.node( + self.nodes['graph'], class_="plot tooltip-overlay", + transform="translate(%d, %d)" % ( + self.margin_box.left, self.margin_box.top)) + self.nodes['tooltip'] = self.svg.node( + self.nodes['tooltip_overlay'], + transform='translate(0 0)', + style="opacity: 0", + **{'class': 'tooltip'}) + + self.svg.node(self.nodes['tooltip'], 'rect', + rx=self.tooltip_border_radius, + ry=self.tooltip_border_radius, + width=0, height=0, + **{'class': 'tooltip-box'}) + self.svg.node(self.nodes['tooltip'], 'g', class_='text') + + def _x_axis(self): + """Make the x axis: labels and guides""" + if not self._x_labels or not self.show_x_labels: + return + axis = self.svg.node(self.nodes['plot'], class_="axis x%s" % ( + ' always_show' if self.show_x_guides else '' + )) + truncation = self.truncate_label + if not truncation: + if self.x_label_rotation or len(self._x_labels) <= 1: + truncation = 25 + else: + first_label_position = self.view.x(self._x_labels[0][1]) or 0 + last_label_position = self.view.x(self._x_labels[-1][1]) or 0 + available_space = ( + last_label_position - first_label_position) / ( + len(self._x_labels) - 1) + truncation = reverse_text_len( + available_space, self.style.label_font_size) + truncation = max(truncation, 1) + + lastlabel = self._x_labels[-1][0] + if 0 not in [label[1] for label in self._x_labels]: + self.svg.node(axis, 'path', + d='M%f %f v%f' % (0, 0, self.view.height), + class_='line') + lastlabel = None + + for label, position in self._x_labels: + if self.horizontal: + major = position in self._x_labels_major + else: + major = label in self._x_labels_major + if not (self.show_minor_x_labels or major): + continue + guides = self.svg.node(axis, class_='guides') + x = self.view.x(position) + if x is None: + continue + y = self.view.height + 5 + last_guide = (self._y_2nd_labels and label == lastlabel) + self.svg.node( + guides, 'path', + d='M%f %f v%f' % (x or 0, 0, self.view.height), + class_='%s%s%sline' % ( + 'axis ' if label == "0" else '', + 'major ' if major else '', + 'guide ' if position != 0 and not last_guide else '')) + y += .5 * self.style.label_font_size + 5 + text = self.svg.node( + guides, 'text', + x=x, + y=y, + class_='major' if major else '' + ) + + text.text = truncate(label, truncation) + if text.text != label: + self.svg.node(guides, 'title').text = label + elif self._dual: + self.svg.node( + guides, 'title', + ).text = self._x_format(position) + + if self.x_label_rotation: + text.attrib['transform'] = "rotate(%d %f %f)" % ( + self.x_label_rotation, x, y) + if self.x_label_rotation >= 180: + text.attrib['class'] = ' '.join( + (text.attrib['class'] and text.attrib['class'].split( + ' ') or []) + ['backwards']) + + if self._y_2nd_labels and 0 not in [ + label[1] for label in self._x_labels]: + self.svg.node(axis, 'path', + d='M%f %f v%f' % ( + self.view.width, 0, self.view.height), + class_='line') + + if self._x_2nd_labels: + secondary_ax = self.svg.node( + self.nodes['plot'], class_="axis x x2%s" % ( + ' always_show' if self.show_x_guides else '' + )) + for label, position in self._x_2nd_labels: + major = label in self._x_labels_major + if not (self.show_minor_x_labels or major): + continue + # it is needed, to have the same structure as primary axis + guides = self.svg.node(secondary_ax, class_='guides') + x = self.view.x(position) + y = -5 + text = self.svg.node( + guides, 'text', + x=x, + y=y, + class_='major' if major else '' + ) + text.text = label + if self.x_label_rotation: + text.attrib['transform'] = "rotate(%d %f %f)" % ( + -self.x_label_rotation, x, y) + if self.x_label_rotation >= 180: + text.attrib['class'] = ' '.join(( + text.attrib['class'] and + text.attrib['class'].split( + ' ') or []) + ['backwards']) + + def _y_axis(self): + """Make the y axis: labels and guides""" + if not self._y_labels or not self.show_y_labels: + return + + axis = self.svg.node(self.nodes['plot'], class_="axis y%s" % ( + ' always_show' if self.show_y_guides else '' + )) + + if (0 not in [label[1] for label in self._y_labels] and + self.show_y_guides): + self.svg.node( + axis, 'path', + d='M%f %f h%f' % ( + 0, 0 if self.inverse_y_axis else self.view.height, + self.view.width), + class_='line' + ) + + for label, position in self._y_labels: + if self.horizontal: + major = label in self._y_labels_major + else: + major = position in self._y_labels_major + + if not (self.show_minor_y_labels or major): + continue + guides = self.svg.node(axis, class_='%sguides' % ( + 'logarithmic ' if self.logarithmic else '' + )) + x = -5 + y = self.view.y(position) + if not y: + continue + if self.show_y_guides: + self.svg.node( + guides, 'path', + d='M%f %f h%f' % (0, y, self.view.width), + class_='%s%s%sline' % ( + 'axis ' if label == "0" else '', + 'major ' if major else '', + 'guide ' if position != 0 else '')) + text = self.svg.node( + guides, 'text', + x=x, + y=y + .35 * self.style.label_font_size, + class_='major' if major else '' + ) + + text.text = label + + if self.y_label_rotation: + text.attrib['transform'] = "rotate(%d %f %f)" % ( + self.y_label_rotation, x, y) + if 90 < self.y_label_rotation < 270: + text.attrib['class'] = ' '.join( + (text.attrib['class'] and text.attrib['class'].split( + ' ') or []) + ['backwards']) + self.svg.node( + guides, 'title', + ).text = self._y_format(position) + + if self._y_2nd_labels: + secondary_ax = self.svg.node( + self.nodes['plot'], class_="axis y2") + for label, position in self._y_2nd_labels: + major = position in self._y_labels_major + if not (self.show_minor_y_labels or major): + continue + # it is needed, to have the same structure as primary axis + guides = self.svg.node(secondary_ax, class_='guides') + x = self.view.width + 5 + y = self.view.y(position) + text = self.svg.node( + guides, 'text', + x=x, + y=y + .35 * self.style.label_font_size, + class_='major' if major else '' + ) + text.text = label + if self.y_label_rotation: + text.attrib['transform'] = "rotate(%d %f %f)" % ( + self.y_label_rotation, x, y) + if 90 < self.y_label_rotation < 270: + text.attrib['class'] = ' '.join( + (text.attrib['class'] and + text.attrib['class'].split( + ' ') or []) + ['backwards']) + + def _legend(self): + """Make the legend box""" + if not self.show_legend: + return + truncation = self.truncate_legend + if self.legend_at_bottom: + x = self.margin_box.left + self.spacing + y = (self.margin_box.top + self.view.height + + self._x_title_height + + self._x_labels_height + self.spacing) + cols = self.legend_at_bottom_columns or ceil( + sqrt(self._order)) or 1 + + if not truncation: + available_space = self.view.width / cols - ( + self.legend_box_size + 5) + truncation = reverse_text_len( + available_space, self.style.legend_font_size) + else: + x = self.spacing + y = self.margin_box.top + self.spacing + cols = 1 + if not truncation: + truncation = 15 + + legends = self.svg.node( + self.nodes['graph'], class_='legends', + transform='translate(%d, %d)' % (x, y)) + + h = max(self.legend_box_size, self.style.legend_font_size) + x_step = self.view.width / cols + if self.legend_at_bottom: + secondary_legends = legends # svg node is the same + else: + + # draw secondary axis on right + x = self.margin_box.left + self.view.width + self.spacing + if self._y_2nd_labels: + h, w = get_texts_box( + cut(self._y_2nd_labels), self.style.label_font_size) + x += self.spacing + max(w * abs(cos(rad( + self.y_label_rotation))), h) + + y = self.margin_box.top + self.spacing + + secondary_legends = self.svg.node( + self.nodes['graph'], class_='legends', + transform='translate(%d, %d)' % (x, y)) + + serie_number = -1 + i = 0 + + for titles, is_secondary in ( + (self._legends, False), + (self._secondary_legends, True)): + if not self.legend_at_bottom and is_secondary: + i = 0 + + for title in titles: + serie_number += 1 + if title is None: + continue + col = i % cols + row = i // cols + + legend = self.svg.node( + secondary_legends if is_secondary else legends, + class_='legend reactive activate-serie', + id="activate-serie-%d" % serie_number) + self.svg.node( + legend, 'rect', + x=col * x_step, + y=1.5 * row * h + ( + self.style.legend_font_size - self.legend_box_size + if self.style.legend_font_size > self.legend_box_size + else 0 + ) / 2, + width=self.legend_box_size, + height=self.legend_box_size, + class_="color-%d reactive" % serie_number + ) + + if isinstance(title, dict): + node = decorate(self.svg, legend, title) + title = title['title'] + else: + node = legend + + truncated = truncate(title, truncation) + self.svg.node( + node, 'text', + x=col * x_step + self.legend_box_size + 5, + y=1.5 * row * h + .5 * h + .3 * self.style.legend_font_size + ).text = truncated + + if truncated != title: + self.svg.node(legend, 'title').text = title + + i += 1 + + def _make_title(self): + """Make the title""" + if self._title: + for i, title_line in enumerate(self._title, 1): + self.svg.node( + self.nodes['title'], 'text', class_='title plot_title', + x=self.width / 2, + y=i * (self.style.title_font_size + self.spacing) + ).text = title_line + + def _make_x_title(self): + """Make the X-Axis title""" + y = (self.height - self.margin_box.bottom + + self._x_labels_height) + if self._x_title: + for i, title_line in enumerate(self._x_title, 1): + text = self.svg.node( + self.nodes['title'], 'text', class_='title', + x=self.margin_box.left + self.view.width / 2, + y=y + i * (self.style.title_font_size + self.spacing) + ) + text.text = title_line + + def _make_y_title(self): + """Make the Y-Axis title""" + if self._y_title: + yc = self.margin_box.top + self.view.height / 2 + for i, title_line in enumerate(self._y_title, 1): + text = self.svg.node( + self.nodes['title'], 'text', class_='title', + x=self._legend_at_left_width, + y=i * (self.style.title_font_size + self.spacing) + yc + ) + text.attrib['transform'] = "rotate(%d %f %f)" % ( + -90, self._legend_at_left_width, yc) + text.text = title_line + + def _interpolate(self, xs, ys): + """Make the interpolation""" + x = [] + y = [] + for i in range(len(ys)): + if ys[i] is not None: + x.append(xs[i]) + y.append(ys[i]) + + interpolate = INTERPOLATIONS[self.interpolate] + + return list(interpolate( + x, y, self.interpolation_precision, + **self.interpolation_parameters)) + + def _rescale(self, points): + """Scale for secondary""" + return [ + (x, self._scale_diff + (y - self._scale_min_2nd) * self._scale + if y is not None else None) + for x, y in points] + + def _tooltip_data(self, node, value, x, y, classes=None, xlabel=None): + """Insert in desc tags informations for the javascript tooltip""" + self.svg.node(node, 'desc', class_="value").text = value + if classes is None: + classes = [] + if x > self.view.width / 2: + classes.append('left') + if y > self.view.height / 2: + classes.append('top') + classes = ' '.join(classes) + + self.svg.node(node, 'desc', + class_="x " + classes).text = to_str(x) + self.svg.node(node, 'desc', + class_="y " + classes).text = to_str(y) + if xlabel: + self.svg.node(node, 'desc', + class_="x_label").text = to_str(xlabel) + + def _static_value(self, serie_node, value, x, y, metadata, + align_text='left', classes=None): + """Write the print value""" + label = metadata and metadata.get('label') + classes = classes and [classes] or [] + + if self.print_labels and label: + label_cls = classes + ['label'] + if self.print_values: + y -= self.style.value_font_size / 2 + self.svg.node( + serie_node['text_overlay'], 'text', + class_=' '.join(label_cls), + x=x, + y=y + self.style.value_font_size / 3 + ).text = label + y += self.style.value_font_size + + if self.print_values or self.dynamic_print_values: + val_cls = classes + ['value'] + if self.dynamic_print_values: + val_cls.append('showable') + + self.svg.node( + serie_node['text_overlay'], 'text', + class_=' '.join(val_cls), + x=x, + y=y + self.style.value_font_size / 3, + attrib={'text-anchor': align_text} + ).text = value if self.print_zeroes or value != '0' else '' + + def _points(self, x_pos): + """ + Convert given data values into drawable points (x, y) + and interpolated points if interpolate option is specified + """ + for serie in self.all_series: + serie.points = [ + (x_pos[i], v) + for i, v in enumerate(serie.values)] + if serie.points and self.interpolate: + serie.interpolated = self._interpolate(x_pos, serie.values) + else: + serie.interpolated = [] + + def _compute_secondary(self): + """Compute secondary axis min max and label positions""" + # secondary y axis support + if self.secondary_series and self._y_labels: + y_pos = list(zip(*self._y_labels))[1] + if self.include_x_axis: + ymin = min(self._secondary_min, 0) + ymax = max(self._secondary_max, 0) + else: + ymin = self._secondary_min + ymax = self._secondary_max + steps = len(y_pos) + left_range = abs(y_pos[-1] - y_pos[0]) + right_range = abs(ymax - ymin) or 1 + scale = right_range / ((steps - 1) or 1) + self._y_2nd_labels = [(self._y_format(ymin + i * scale), pos) + for i, pos in enumerate(y_pos)] + + self._scale = left_range / right_range + self._scale_diff = y_pos[0] + self._scale_min_2nd = ymin + + def _post_compute(self): + """Hook called after compute and before margin computations and plot""" + pass + + def _get_x_label(self, i): + """Convenience function to get the x_label of a value index""" + if not self.x_labels or not self._x_labels or len(self._x_labels) <= i: + return + return self._x_labels[i][0] + + @property + def all_series(self): + """Getter for all series (nomal and secondary)""" + return self.series + self.secondary_series + + @property + def _x_format(self): + """Return the abscissa value formatter (always unary)""" + return self.x_value_formatter + + @property + def _default_formatter(self): + return to_str + + @property + def _y_format(self): + """Return the ordinate value formatter (always unary)""" + return self.value_formatter + + def _value_format(self, value): + """ + Format value for value display. + (Varies in type between chart types) + """ + + return self._y_format(value) + + def _format(self, serie, i): + """Format the nth value for the serie""" + value = serie.values[i] + metadata = serie.metadata.get(i) + + kwargs = { + 'chart': self, + 'serie': serie, + 'index': i + } + formatter = ( + (metadata and metadata.get('formatter')) or + serie.formatter or + self.formatter or + self._value_format + ) + kwargs = filter_kwargs(formatter, kwargs) + return formatter(value, **kwargs) + + def _serie_format(self, serie, value): + """Format an independent value for the serie""" + + kwargs = { + 'chart': self, + 'serie': serie, + 'index': None + } + formatter = ( + serie.formatter or + self.formatter or + self._value_format + ) + kwargs = filter_kwargs(formatter, kwargs) + return formatter(value, **kwargs) + + def _compute(self): + """Initial computations to draw the graph""" + + def _compute_margin(self): + """Compute graph margins from set texts""" + self._legend_at_left_width = 0 + for series_group in (self.series, self.secondary_series): + if self.show_legend and series_group: + h, w = get_texts_box( + map(lambda x: truncate(x, self.truncate_legend or 15), + [serie.title['title'] + if isinstance(serie.title, dict) + else serie.title or '' for serie in series_group]), + self.style.legend_font_size) + if self.legend_at_bottom: + h_max = max(h, self.legend_box_size) + cols = (self._order // self.legend_at_bottom_columns + if self.legend_at_bottom_columns + else ceil(sqrt(self._order)) or 1) + self.margin_box.bottom += self.spacing + h_max * round( + cols - 1) * 1.5 + h_max + else: + if series_group is self.series: + legend_width = self.spacing + w + self.legend_box_size + self.margin_box.left += legend_width + self._legend_at_left_width += legend_width + else: + self.margin_box.right += ( + self.spacing + w + self.legend_box_size) + + self._x_labels_height = 0 + if (self._x_labels or self._x_2nd_labels) and self.show_x_labels: + for xlabels in (self._x_labels, self._x_2nd_labels): + if xlabels: + h, w = get_texts_box( + map(lambda x: truncate(x, self.truncate_label or 25), + cut(xlabels)), + self.style.label_font_size) + self._x_labels_height = self.spacing + max( + w * abs(sin(rad(self.x_label_rotation))), h) + if xlabels is self._x_labels: + self.margin_box.bottom += self._x_labels_height + else: + self.margin_box.top += self._x_labels_height + if self.x_label_rotation: + if self.x_label_rotation % 180 < 90: + self.margin_box.right = max( + w * abs(cos(rad(self.x_label_rotation))), + self.margin_box.right) + else: + self.margin_box.left = max( + w * abs(cos(rad(self.x_label_rotation))), + self.margin_box.left) + + if self.show_y_labels: + for ylabels in (self._y_labels, self._y_2nd_labels): + if ylabels: + h, w = get_texts_box( + cut(ylabels), self.style.label_font_size) + if ylabels is self._y_labels: + self.margin_box.left += self.spacing + max( + w * abs(cos(rad(self.y_label_rotation))), h) + else: + self.margin_box.right += self.spacing + max( + w * abs(cos(rad(self.y_label_rotation))), h) + + self._title = split_title( + self.title, self.width, self.style.title_font_size) + + if self.title: + h, _ = get_text_box(self._title[0], self.style.title_font_size) + self.margin_box.top += len(self._title) * (self.spacing + h) + + self._x_title = split_title( + self.x_title, self.width - self.margin_box.x, + self.style.title_font_size) + + self._x_title_height = 0 + if self._x_title: + h, _ = get_text_box(self._x_title[0], self.style.title_font_size) + height = len(self._x_title) * (self.spacing + h) + self.margin_box.bottom += height + self._x_title_height = height + self.spacing + + self._y_title = split_title( + self.y_title, self.height - self.margin_box.y, + self.style.title_font_size) + + self._y_title_height = 0 + if self._y_title: + h, _ = get_text_box(self._y_title[0], self.style.title_font_size) + height = len(self._y_title) * (self.spacing + h) + self.margin_box.left += height + self._y_title_height = height + self.spacing + + # Inner margin + if self.print_values_position == 'top': + gh = self.height - self.margin_box.y + alpha = 1.1 * (self.style.value_font_size / gh) * self._box.height + if self._max and self._max > 0: + self._box.ymax += alpha + if self._min and self._min < 0: + self._box.ymin -= alpha + + def _confidence_interval(self, node, x, y, value, metadata): + if not metadata or 'ci' not in metadata: + return + ci = metadata['ci'] + ci['point_estimate'] = value + + low, high = getattr( + stats, + 'confidence_interval_%s' % ci.get('type', 'manual') + )(**ci) + + self.svg.confidence_interval( + node, x, + # Respect some charts y modifications (pyramid, stackbar) + y + (self.view.y(low) - self.view.y(value)), + y + (self.view.y(high) - self.view.y(value))) + + @cached_property + def _legends(self): + """Getter for series title""" + return [serie.title for serie in self.series] + + @cached_property + def _secondary_legends(self): + """Getter for series title on secondary y axis""" + return [serie.title for serie in self.secondary_series] + + @cached_property + def _values(self): + """Getter for series values (flattened)""" + return [val + for serie in self.series + for val in serie.values + if val is not None] + + @cached_property + def _secondary_values(self): + """Getter for secondary series values (flattened)""" + return [val + for serie in self.secondary_series + for val in serie.values + if val is not None] + + @cached_property + def _len(self): + """Getter for the maximum series size""" + return max([ + len(serie.values) + for serie in self.all_series] or [0]) + + @cached_property + def _secondary_min(self): + """Getter for the minimum series value""" + return (self.secondary_range[0] if ( + self.secondary_range and self.secondary_range[0] is not None) + else (min(self._secondary_values) + 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)) + + @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)) + + @cached_property + def _secondary_max(self): + """Getter for the maximum series value""" + return (self.secondary_range[1] if ( + self.secondary_range and self.secondary_range[1] is not None) + else (max(self._secondary_values) + if self._secondary_values else None)) + + @cached_property + def _order(self): + """Getter for the number of series""" + return len(self.all_series) + + def _x_label_format_if_value(self, label): + if not is_str(label): + return self._x_format(label) + return label + + def _compute_x_labels(self): + self._x_labels = self.x_labels and list( + zip(map(self._x_label_format_if_value, self.x_labels), + self._x_pos)) + + def _compute_x_labels_major(self): + if self.x_labels_major_every: + self._x_labels_major = [self._x_labels[i][0] for i in range( + 0, len(self._x_labels), self.x_labels_major_every)] + + elif self.x_labels_major_count: + label_count = len(self._x_labels) + major_count = self.x_labels_major_count + if (major_count >= label_count): + self._x_labels_major = [label[0] for label in self._x_labels] + + else: + self._x_labels_major = [self._x_labels[ + int(i * (label_count - 1) / (major_count - 1))][0] + for i in range(major_count)] + else: + self._x_labels_major = self.x_labels_major and list( + map(self._x_label_format_if_value, self.x_labels_major)) or [] + + def _compute_y_labels(self): + y_pos = compute_scale( + self._box.ymin, self._box.ymax, self.logarithmic, + self.order_min, self.min_scale, self.max_scale + ) + if self.y_labels: + self._y_labels = [] + for i, y_label in enumerate(self.y_labels): + if isinstance(y_label, dict): + pos = self._adapt(y_label.get('value')) + title = y_label.get('label', self._y_format(pos)) + elif is_str(y_label): + pos = self._adapt(y_pos[i % len(y_pos)]) + title = y_label + else: + pos = self._adapt(y_label) + title = self._y_format(pos) + self._y_labels.append((title, pos)) + self._box.ymin = min(self._box.ymin, min(cut(self._y_labels, 1))) + self._box.ymax = max(self._box.ymax, max(cut(self._y_labels, 1))) + else: + self._y_labels = list(zip(map(self._y_format, y_pos), y_pos)) + + def _compute_y_labels_major(self): + if self.y_labels_major_every: + self._y_labels_major = [self._y_labels[i][1] for i in range( + 0, len(self._y_labels), self.y_labels_major_every)] + + elif self.y_labels_major_count: + label_count = len(self._y_labels) + major_count = self.y_labels_major_count + if (major_count >= label_count): + self._y_labels_major = [label[1] for label in self._y_labels] + else: + self._y_labels_major = [self._y_labels[ + int(i * (label_count - 1) / (major_count - 1))][1] + for i in range(major_count)] + + elif self.y_labels_major: + self._y_labels_major = list(map(self._adapt, self.y_labels_major)) + elif self._y_labels: + self._y_labels_major = majorize(cut(self._y_labels, 1)) + else: + self._y_labels_major = [] + + def add_squares(self, squares): + x_lines = squares[0] - 1 + y_lines = squares[1] - 1 + + _current_x = 0 + _current_y = 0 + + for line in range(x_lines): + _current_x += (self.width - self.margin_box.x) / squares[0] + self.svg.node( + self.nodes['plot'], 'path', + class_='bg-lines', + d='M%s %s L%s %s' % ( + _current_x, 0, _current_x, + self.height - self.margin_box.y)) + + for line in range(y_lines): + _current_y += (self.height - self.margin_box.y) / squares[1] + self.svg.node( + self.nodes['plot'], 'path', + class_='bg-lines', + d='M%s %s L%s %s' % ( + 0, _current_y, self.width - self.margin_box.x, _current_y)) + return ((self.width - self.margin_box.x) / squares[0], + (self.height - self.margin_box.y) / squares[1]) + + def _draw(self): + """Draw all the things""" + self._compute() + self._compute_x_labels() + self._compute_x_labels_major() + self._compute_y_labels() + self._compute_y_labels_major() + self._compute_secondary() + self._post_compute() + self._compute_margin() + self._decorate() + if self.series and self._has_data() and self._values: + self._plot() + else: + self.svg.draw_no_data() + + def _has_data(self): + """Check if there is any data""" + return any([ + len([ + v for a in (s[0] if is_list_like(s) else [s]) + for v in (a if is_list_like(a) else [a]) + if v is not None]) + for s in self.raw_series + ]) diff --git a/pygal/graph/histogram.py b/pygal/graph/histogram.py new file mode 100644 index 0000000..979eff4 --- /dev/null +++ b/pygal/graph/histogram.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +""" +Histogram chart: like a bar chart but with data plotted along a x axis +as bars of varying width. +""" + +from __future__ import division + +from pygal.graph.bar import Bar +from pygal.graph.dual import Dual +from pygal.util import alter, cached_property, decorate + + +class Histogram(Dual, Bar): + + """Histogram chart class""" + _series_margin = 0 + + @cached_property + def _values(self): + """Getter for secondary series values (flattened)""" + return self.yvals + + @cached_property + def _secondary_values(self): + """Getter for secondary series values (flattened)""" + return [val[0] + for serie in self.secondary_series + for val in serie.values + if val[0] is not None] + + @cached_property + def xvals(self): + """All x values""" + return [val + for serie in self.all_series + for dval in serie.values + for val in dval[1:3] + if val is not None] + + @cached_property + def yvals(self): + """All y values""" + return [val[0] + for serie in self.series + for val in serie.values + if val[0] is not None] + + def _bar(self, serie, parent, x0, x1, y, i, zero, secondary=False): + """Internal bar drawing function""" + x, y = self.view((x0, y)) + x1, _ = self.view((x1, y)) + width = x1 - x + height = self.view.y(zero) - y + series_margin = width * self._series_margin + x += series_margin + width -= 2 * series_margin + + r = serie.rounded_bars * 1 if serie.rounded_bars else 0 + alter(self.svg.transposable_node( + parent, 'rect', + x=x, y=y, rx=r, ry=r, width=width, height=height, + class_='rect reactive tooltip-trigger'), serie.metadata.get(i)) + return x, y, width, height + + def bar(self, serie, rescale=False): + """Draw a bar graph for a serie""" + serie_node = self.svg.serie(serie) + bars = self.svg.node(serie_node['plot'], class_="histbars") + points = serie.points + + for i, (y, x0, x1) in enumerate(points): + if None in (x0, x1, y) or (self.logarithmic and y <= 0): + continue + metadata = serie.metadata.get(i) + + bar = decorate( + self.svg, + self.svg.node(bars, class_='histbar'), + metadata) + val = self._format(serie, i) + + bounds = self._bar( + serie, bar, x0, x1, y, i, self.zero, secondary=rescale) + self._tooltip_and_print_values( + serie_node, serie, bar, i, val, metadata, *bounds) + + def _compute(self): + """Compute x/y min and max and x/y scale and set labels""" + if self.xvals: + xmin = min(self.xvals) + xmax = max(self.xvals) + xrng = (xmax - xmin) + else: + xrng = None + + if self.yvals: + ymin = min(min(self.yvals), self.zero) + ymax = max(max(self.yvals), self.zero) + yrng = (ymax - ymin) + else: + yrng = None + + for serie in self.all_series: + serie.points = serie.values + + if xrng: + self._box.xmin, self._box.xmax = xmin, xmax + if yrng: + self._box.ymin, self._box.ymax = ymin, ymax + + if self.range and self.range[0] is not None: + self._box.ymin = self.range[0] + + if self.range and self.range[1] is not None: + self._box.ymax = self.range[1] diff --git a/pygal/graph/horizontal.py b/pygal/graph/horizontal.py new file mode 100644 index 0000000..9939b21 --- /dev/null +++ b/pygal/graph/horizontal.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +"""Horizontal graph mixin""" + +from pygal.graph.graph import Graph +from pygal.view import HorizontalLogView, HorizontalView + + +class HorizontalGraph(Graph): + + """Horizontal graph mixin""" + + def __init__(self, *args, **kwargs): + """Set the horizontal flag to True""" + self.horizontal = True + super(HorizontalGraph, self).__init__(*args, **kwargs) + + def _post_compute(self): + """After computations transpose labels""" + self._x_labels, self._y_labels = self._y_labels, self._x_labels + self._x_labels_major, self._y_labels_major = ( + self._y_labels_major, self._x_labels_major) + self._x_2nd_labels, self._y_2nd_labels = ( + self._y_2nd_labels, self._x_2nd_labels) + self.show_y_guides, self.show_x_guides = ( + self.show_x_guides, self.show_y_guides) + + def _axes(self): + """Set the _force_vertical flag when rendering axes""" + self.view._force_vertical = True + super(HorizontalGraph, self)._axes() + self.view._force_vertical = False + + def _set_view(self): + """Assign a horizontal view to current graph""" + if self.logarithmic: + view_class = HorizontalLogView + else: + view_class = HorizontalView + + self.view = view_class( + self.width - self.margin_box.x, + self.height - self.margin_box.y, + self._box) + + def _get_x_label(self, i): + """Convenience function to get the x_label of a value index""" + if not self.x_labels or not self._y_labels or len(self._y_labels) <= i: + return + return self._y_labels[i][0] diff --git a/pygal/graph/horizontalbar.py b/pygal/graph/horizontalbar.py new file mode 100644 index 0000000..5e801ce --- /dev/null +++ b/pygal/graph/horizontalbar.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Horizontal bar graph""" + +from pygal.graph.bar import Bar +from pygal.graph.horizontal import HorizontalGraph + + +class HorizontalBar(HorizontalGraph, Bar): + + """Horizontal Bar graph""" + + def _plot(self): + """Draw the bars in reverse order""" + for serie in self.series[::-1]: + self.bar(serie) + for serie in self.secondary_series[::-1]: + self.bar(serie, True) diff --git a/pygal/graph/horizontalline.py b/pygal/graph/horizontalline.py new file mode 100644 index 0000000..e13039d --- /dev/null +++ b/pygal/graph/horizontalline.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Horizontal line graph""" + +from pygal.graph.horizontal import HorizontalGraph +from pygal.graph.line import Line + + +class HorizontalLine(HorizontalGraph, Line): + + """Horizontal Line graph""" + + def _plot(self): + """Draw the lines in reverse order""" + for serie in self.series[::-1]: + self.line(serie) + for serie in self.secondary_series[::-1]: + self.line(serie, True) diff --git a/pygal/graph/horizontalstackedbar.py b/pygal/graph/horizontalstackedbar.py new file mode 100644 index 0000000..990361f --- /dev/null +++ b/pygal/graph/horizontalstackedbar.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Horizontal stacked graph""" + +from pygal.graph.horizontal import HorizontalGraph +from pygal.graph.stackedbar import StackedBar + + +class HorizontalStackedBar(HorizontalGraph, StackedBar): + + """Horizontal Stacked Bar graph""" diff --git a/pygal/graph/horizontalstackedline.py b/pygal/graph/horizontalstackedline.py new file mode 100644 index 0000000..9748d81 --- /dev/null +++ b/pygal/graph/horizontalstackedline.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Horizontal Stacked Line graph""" + +from pygal.graph.horizontal import HorizontalGraph +from pygal.graph.stackedline import StackedLine + + +class HorizontalStackedLine(HorizontalGraph, StackedLine): + + """Horizontal Stacked Line graph""" + + def _plot(self): + """Draw the lines in reverse order""" + for serie in self.series[::-1]: + self.line(serie) + for serie in self.secondary_series[::-1]: + self.line(serie, True) diff --git a/pygal/graph/line.py b/pygal/graph/line.py new file mode 100644 index 0000000..b2dc337 --- /dev/null +++ b/pygal/graph/line.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +""" +Line chart: Display series of data as markers (dots) +connected by straight segments +""" + +from __future__ import division + +from pygal.graph.graph import Graph +from pygal.util import alter, cached_property, decorate + + +class Line(Graph): + + """Line graph class""" + + def __init__(self, *args, **kwargs): + """Set _self_close as False, it's True for Radar like Line""" + self._self_close = False + super(Line, self).__init__(*args, **kwargs) + + @cached_property + def _values(self): + """Getter for series values (flattened)""" + return [ + val[1] + for serie in self.series + for val in (serie.interpolated + if self.interpolate else serie.points) + if val[1] is not None and (not self.logarithmic or val[1] > 0)] + + @cached_property + def _secondary_values(self): + """Getter for secondary series values (flattened)""" + return [ + val[1] + for serie in self.secondary_series + for val in (serie.interpolated + if self.interpolate else serie.points) + if val[1] is not None and (not self.logarithmic or val[1] > 0)] + + def _fill(self, values): + """Add extra values to fill the line""" + zero = self.view.y(min(max(self.zero, self._box.ymin), self._box.ymax)) + + # Check to see if the data has been padded with "none's" + # Fill doesn't work correctly otherwise + end = len(values) - 1 + while end > 0: + x, y = values[end] + if self.missing_value_fill_truncation == "either": + if x is not None and y is not None: + break + elif self.missing_value_fill_truncation == "x": + if x is not None: + break + elif self.missing_value_fill_truncation == "y": + if y is not None: + break + else: + raise ValueError( + "Invalid value ({}) for config key " + "'missing_value_fill_truncation';" + " Use 'x', 'y' or 'either'".format( + self.missing_value_fill_truncation)) + end -= 1 + + return ([(values[0][0], zero)] + + values + + [(values[end][0], zero)]) + + def line(self, serie, rescale=False): + """Draw the line serie""" + serie_node = self.svg.serie(serie) + if rescale and self.secondary_series: + points = self._rescale(serie.points) + else: + points = serie.points + view_values = list(map(self.view, points)) + if serie.show_dots: + for i, (x, y) in enumerate(view_values): + if None in (x, y): + continue + if self.logarithmic: + if points[i][1] is None or points[i][1] <= 0: + continue + if (serie.show_only_major_dots and + self.x_labels and i < len(self.x_labels) and + self.x_labels[i] not in self._x_labels_major): + continue + + metadata = serie.metadata.get(i) + classes = [] + if x > self.view.width / 2: + classes.append('left') + if y > self.view.height / 2: + classes.append('top') + classes = ' '.join(classes) + + self._confidence_interval( + serie_node['overlay'], x, y, serie.values[i], metadata) + + dots = decorate( + self.svg, + self.svg.node(serie_node['overlay'], class_="dots"), + metadata) + + val = self._format(serie, i) + alter(self.svg.transposable_node( + dots, 'circle', cx=x, cy=y, r=serie.dots_size, + class_='dot reactive tooltip-trigger'), metadata) + self._tooltip_data( + dots, val, x, y, + xlabel=self._get_x_label(i)) + self._static_value( + serie_node, val, + x + self.style.value_font_size, + y + self.style.value_font_size, + metadata) + + if serie.stroke: + if self.interpolate: + points = serie.interpolated + if rescale and self.secondary_series: + points = self._rescale(points) + view_values = list(map(self.view, points)) + if serie.fill: + view_values = self._fill(view_values) + + if serie.allow_interruptions: + # view_values are in form [(x1, y1), (x2, y2)]. We + # need to split that into multiple sequences if a + # None is present here + + sequences = [] + cur_sequence = [] + for x, y in view_values: + if y is None and len(cur_sequence) > 0: + # emit current subsequence + sequences.append(cur_sequence) + cur_sequence = [] + elif y is None: # just discard + continue + else: + cur_sequence.append((x, y)) # append the element + + if len(cur_sequence) > 0: # emit last possible sequence + sequences.append(cur_sequence) + else: + # plain vanilla rendering + sequences = [view_values] + if self.logarithmic: + for seq in sequences: + for ele in seq[::-1]: + y = points[seq.index(ele)][1] + if y is None or y <= 0: + del seq[seq.index(ele)] + for seq in sequences: + self.svg.line( + serie_node['plot'], seq, close=self._self_close, + class_='line reactive' + + (' nofill' if not serie.fill else '')) + + def _compute(self): + """Compute y min and max and y scale and set labels""" + # X Labels + if self.horizontal: + self._x_pos = [ + x / (self._len - 1) for x in range(self._len) + ][::-1] if self._len != 1 else [.5] # Center if only one value + else: + self._x_pos = [ + x / (self._len - 1) for x in range(self._len) + ] if self._len != 1 else [.5] # Center if only one value + + self._points(self._x_pos) + + if self.include_x_axis: + # Y Label + self._box.ymin = min(self._min or 0, 0) + self._box.ymax = max(self._max or 0, 0) + else: + self._box.ymin = self._min + self._box.ymax = self._max + + def _plot(self): + """Plot the serie lines and secondary serie lines""" + for serie in self.series: + self.line(serie) + + for serie in self.secondary_series: + self.line(serie, True) diff --git a/pygal/graph/map.py b/pygal/graph/map.py new file mode 100644 index 0000000..1a1405a --- /dev/null +++ b/pygal/graph/map.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +""" +pygal contains no map but a base class to create extension +see the pygal_maps_world package to get an exemple. +https://github.com/Kozea/pygal_maps_world +""" + +from __future__ import division + +from pygal.etree import etree +from pygal.graph.graph import Graph +from pygal.util import alter, cached_property, cut, decorate + + +class BaseMap(Graph): + + """Base class for maps""" + + _dual = True + + @cached_property + def _values(self): + """Getter for series values (flattened)""" + return [val[1] + for serie in self.series + for val in serie.values + if val[1] is not None] + + def enumerate_values(self, serie): + """Hook to replace default enumeration on values""" + return enumerate(serie.values) + + def adapt_code(self, area_code): + """Hook to change the area code""" + return area_code + + def _value_format(self, value): + """ + Format value for map value display. + """ + return '%s: %s' % ( + self.area_names.get(self.adapt_code(value[0]), '?'), + self._y_format(value[1])) + + def _plot(self): + """Insert a map in the chart and apply data on it""" + map = etree.fromstring(self.svg_map) + map.set('width', str(self.view.width)) + map.set('height', str(self.view.height)) + + for i, serie in enumerate(self.series): + safe_vals = list(filter( + 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 self.enumerate_values(serie): + area_code = self.adapt_code(area_code) + if value is None: + continue + if max_ == min_: + ratio = 1 + else: + ratio = .3 + .7 * (value - min_) / (max_ - min_) + + areae = map.findall( + ".//*[@class='%s%s %s map-element']" % ( + self.area_prefix, area_code, + self.kind)) + + if not areae: + continue + + for area in areae: + cls = area.get('class', '').split(' ') + cls.append('color-%d' % i) + cls.append('serie-%d' % i) + cls.append('series') + area.set('class', ' '.join(cls)) + area.set('style', 'fill-opacity: %f' % ratio) + + metadata = serie.metadata.get(j) + + if metadata: + node = decorate(self.svg, area, metadata) + if node != area: + area.remove(node) + for g in map: + if area not in g: + continue + index = list(g).index(area) + g.remove(area) + node.append(area) + g.insert(index, node) + + for node in area: + cls = node.get('class', '').split(' ') + cls.append('reactive') + cls.append('tooltip-trigger') + cls.append('map-area') + node.set('class', ' '.join(cls)) + alter(node, metadata) + + val = self._format(serie, j) + self._tooltip_data(area, val, 0, 0, 'auto') + + self.nodes['plot'].append(map) + + def _compute_x_labels(self): + pass + + def _compute_y_labels(self): + pass diff --git a/pygal/graph/pie.py b/pygal/graph/pie.py new file mode 100644 index 0000000..be2d425 --- /dev/null +++ b/pygal/graph/pie.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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: A circular chart divided into slice to illustrate proportions +It can be made as a donut or a half pie. +""" + +from __future__ import division + +from math import pi + +from pygal.adapters import none_to_zero, positive +from pygal.graph.graph import Graph +from pygal.util import alter, decorate + + +class Pie(Graph): + + """Pie graph class""" + + _adapters = [positive, none_to_zero] + + def slice(self, serie, start_angle, total): + """Make a serie slice""" + serie_node = self.svg.serie(serie) + dual = self._len > 1 and not self._order == 1 + + slices = self.svg.node(serie_node['plot'], class_="slices") + serie_angle = 0 + original_start_angle = start_angle + if self.half_pie: + center = ((self.width - self.margin_box.x) / 2., + (self.height - self.margin_box.y) / 1.25) + else: + center = ((self.width - self.margin_box.x) / 2., + (self.height - self.margin_box.y) / 2.) + + radius = min(center) + for i, val in enumerate(serie.values): + perc = val / total + if self.half_pie: + angle = 2 * pi * perc / 2 + else: + angle = 2 * pi * perc + serie_angle += angle + val = self._format(serie, i) + 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 * serie.inner_radius + + alter(self.svg.slice( + serie_node, slice_, big_radius, small_radius, + angle, start_angle, center, val, i, metadata), metadata) + start_angle += angle + + if dual: + val = self._serie_format(serie, sum(serie.values)) + self.svg.slice(serie_node, + self.svg.node(slices, class_="big_slice"), + radius * .9, 0, serie_angle, + original_start_angle, center, val, i, metadata) + return serie_angle + + def _compute_x_labels(self): + pass + + def _compute_y_labels(self): + pass + + def _plot(self): + """Draw all the serie slices""" + total = sum(map(sum, map(lambda x: x.values, self.series))) + if total == 0: + return + if self.half_pie: + current_angle = 3 * pi / 2 + else: + current_angle = 0 + + for index, serie in enumerate(self.series): + angle = self.slice(serie, current_angle, total) + current_angle += angle diff --git a/pygal/graph/public.py b/pygal/graph/public.py new file mode 100644 index 0000000..efb7872 --- /dev/null +++ b/pygal/graph/public.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +"""pygal public api functions""" + +import base64 +import io + +from pygal._compat import _ellipsis, is_list_like, u +from pygal.graph.base import BaseGraph + + +class PublicApi(BaseGraph): + + """Chart public functions""" + + def add(self, title, values, **kwargs): + """Add a serie to this graph, compat api""" + if not is_list_like(values) and not isinstance(values, dict): + values = [values] + kwargs['title'] = title + self.raw_series.append((values, kwargs)) + return self + + def __call__(self, *args, **kwargs): + """Call api: chart(1, 2, 3, title='T')""" + self.raw_series.append((args, kwargs)) + return self + + def add_xml_filter(self, callback): + """Add an xml filter for in tree post processing""" + self.xml_filters.append(callback) + return self + + def render(self, is_unicode=False, **kwargs): + """Render the graph, and return the svg string""" + self.setup(**kwargs) + svg = self.svg.render( + is_unicode=is_unicode, pretty_print=self.pretty_print) + self.teardown() + return svg + + def render_tree(self, **kwargs): + """Render the graph, and return (l)xml etree""" + self.setup(**kwargs) + svg = self.svg.root + for f in self.xml_filters: + svg = f(svg) + self.teardown() + return svg + + def render_table(self, **kwargs): + """Render the data as a html table""" + # Import here to avoid lxml import + try: + from pygal.table import Table + except ImportError: + raise ImportError('You must install lxml to use render table') + return Table(self).render(**kwargs) + + def render_pyquery(self, **kwargs): + """Render the graph, and return a pyquery wrapped tree""" + from pyquery import PyQuery as pq + return pq(self.render(**kwargs), parser='html') + + def render_in_browser(self, **kwargs): + """Render the graph, open it in your browser with black magic""" + try: + from lxml.html import open_in_browser + except ImportError: + raise ImportError('You must install lxml to use render in browser') + kwargs.setdefault('force_uri_protocol', 'https') + open_in_browser(self.render_tree(**kwargs), encoding='utf-8') + + def render_response(self, **kwargs): + """Render the graph, and return a Flask response""" + from flask import Response + return Response(self.render(**kwargs), mimetype='image/svg+xml') + + def render_django_response(self, **kwargs): + """Render the graph, and return a Django response""" + from django.http import HttpResponse + return HttpResponse( + self.render(**kwargs), content_type='image/svg+xml') + + def render_data_uri(self, **kwargs): + """Output a base 64 encoded data uri""" + # Force protocol as data uri have none + kwargs.setdefault('force_uri_protocol', 'https') + return "data:image/svg+xml;charset=utf-8;base64,%s" % ( + base64.b64encode( + self.render(**kwargs) + ).decode('utf-8').replace('\n', '') + ) + + def render_to_file(self, filename, **kwargs): + """Render the graph, and write it to filename""" + with io.open(filename, 'w', encoding='utf-8') as f: + f.write(self.render(is_unicode=True, **kwargs)) + + def render_to_png(self, filename=None, dpi=72, **kwargs): + """Render the graph, convert it to png and write it to filename""" + import cairosvg + return cairosvg.svg2png( + bytestring=self.render(**kwargs), write_to=filename, dpi=dpi) + + def render_sparktext(self, relative_to=None): + """Make a mini text sparkline from chart""" + bars = u('▁▂▃▄▅▆▇█') + if len(self.raw_series) == 0: + return u('') + values = list(self.raw_series[0][0]) + if len(values) == 0: + return u('') + + chart = u('') + values = list(map(lambda x: max(x, 0), values)) + + vmax = max(values) + if relative_to is None: + relative_to = min(values) + + if (vmax - relative_to) == 0: + chart = bars[0] * len(values) + return chart + + divisions = len(bars) - 1 + for value in values: + chart += bars[int(divisions * + (value - relative_to) / (vmax - relative_to))] + return chart + + def render_sparkline(self, **kwargs): + """Render a sparkline""" + spark_options = dict( + width=200, + height=50, + show_dots=False, + show_legend=False, + show_x_labels=False, + show_y_labels=False, + spacing=0, + margin=5, + min_scale=1, + max_scale=2, + explicit_size=True, + no_data_text='', + js=(), + classes=(_ellipsis, 'pygal-sparkline') + ) + spark_options.update(kwargs) + return self.render(**spark_options) diff --git a/pygal/graph/pyramid.py b/pygal/graph/pyramid.py new file mode 100644 index 0000000..97555ac --- /dev/null +++ b/pygal/graph/pyramid.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +""" +Pyramid chart: Stacked bar chart containing only positive values divided by two +axes, generally gender for age pyramid. +""" + +from __future__ import division + +from pygal.adapters import positive +from pygal.graph.horizontal import HorizontalGraph +from pygal.graph.stackedbar import StackedBar + + +class VerticalPyramid(StackedBar): + + """Vertical Pyramid graph class""" + + _adapters = [positive] + + def _value_format(self, value): + """Format value for dual value display.""" + return super(VerticalPyramid, self)._value_format(value and abs(value)) + + def _get_separated_values(self, secondary=False): + """Separate values between odd and even series stacked""" + series = self.secondary_series if secondary else self.series + positive_vals = map(sum, zip( + *[serie.safe_values + for index, serie in enumerate(series) + if index % 2])) + negative_vals = map(sum, zip( + *[serie.safe_values + for index, serie in enumerate(series) + if not index % 2])) + return list(positive_vals), list(negative_vals) + + def _compute_box(self, positive_vals, negative_vals): + """Compute Y min and max""" + max_ = max( + max(positive_vals or [self.zero]), + max(negative_vals or [self.zero])) + + if self.range and self.range[0] is not None: + self._box.ymin = self.range[0] + else: + self._box.ymin = - max_ + + if self.range and self.range[1] is not None: + self._box.ymax = self.range[1] + else: + self._box.ymax = max_ + + def _pre_compute_secondary(self, positive_vals, negative_vals): + """Compute secondary y min and max""" + self._secondary_max = max(max(positive_vals), max(negative_vals)) + self._secondary_min = - self._secondary_max + + def _bar(self, serie, parent, x, y, i, zero, secondary=False): + """Internal stacking bar drawing function""" + if serie.index % 2: + y = -y + return super(VerticalPyramid, self)._bar( + serie, parent, x, y, i, zero, secondary) + + +class Pyramid(HorizontalGraph, VerticalPyramid): + + """Horizontal Pyramid graph class like the one used by age pyramid""" diff --git a/pygal/graph/radar.py b/pygal/graph/radar.py new file mode 100644 index 0000000..588cf10 --- /dev/null +++ b/pygal/graph/radar.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +""" +Radar chart: As known as kiviat chart or spider chart is a polar line chart +useful for multivariate observation. +""" + +from __future__ import division + +from math import cos, pi + +from pygal._compat import is_str +from pygal.adapters import none_to_zero, positive +from pygal.graph.line import Line +from pygal.util import cached_property, compute_scale, cut, deg, truncate +from pygal.view import PolarLogView, PolarView + + +class Radar(Line): + + """Rada graph class""" + + _adapters = [positive, none_to_zero] + + def __init__(self, *args, **kwargs): + """Init custom vars""" + self._rmax = None + super(Radar, self).__init__(*args, **kwargs) + + def _fill(self, values): + """Add extra values to fill the line""" + return values + + @cached_property + def _values(self): + """Getter for series values (flattened)""" + if self.interpolate: + return [val[0] for serie in self.series + for val in serie.interpolated] + else: + return super(Line, self)._values + + def _set_view(self): + """Assign a view to current graph""" + if self.logarithmic: + view_class = PolarLogView + else: + view_class = PolarView + + self.view = view_class( + self.width - self.margin_box.x, + self.height - self.margin_box.y, + self._box) + + def _x_axis(self, draw_axes=True): + """Override x axis to make it polar""" + if not self._x_labels or not self.show_x_labels: + return + + axis = self.svg.node(self.nodes['plot'], class_="axis x web%s" % ( + ' always_show' if self.show_x_guides else '' + )) + format_ = lambda x: '%f %f' % x + center = self.view((0, 0)) + r = self._rmax + + # Can't simply determine truncation + truncation = self.truncate_label or 25 + + for label, theta in self._x_labels: + major = label in self._x_labels_major + if not (self.show_minor_x_labels or major): + continue + guides = self.svg.node(axis, class_='guides') + end = self.view((r, theta)) + + self.svg.node( + guides, 'path', + d='M%s L%s' % (format_(center), format_(end)), + class_='%s%sline' % ( + 'axis ' if label == "0" else '', + 'major ' if major else '')) + + r_txt = (1 - self._box.__class__.margin) * self._box.ymax + pos_text = self.view((r_txt, theta)) + text = self.svg.node( + guides, 'text', + x=pos_text[0], + y=pos_text[1], + class_='major' if major else '') + text.text = truncate(label, truncation) + if text.text != label: + self.svg.node(guides, 'title').text = label + else: + self.svg.node( + guides, 'title', + ).text = self._x_format(theta) + + angle = - theta + pi / 2 + if cos(angle) < 0: + angle -= pi + text.attrib['transform'] = 'rotate(%f %s)' % ( + self.x_label_rotation or deg(angle), format_(pos_text)) + + def _y_axis(self, draw_axes=True): + """Override y axis to make it polar""" + if not self._y_labels or not self.show_y_labels: + return + + axis = self.svg.node(self.nodes['plot'], class_="axis y web") + + for label, r in reversed(self._y_labels): + major = r in self._y_labels_major + if not (self.show_minor_y_labels or major): + continue + guides = self.svg.node(axis, class_='%sguides' % ( + 'logarithmic ' if self.logarithmic else '' + )) + if self.show_y_guides: + self.svg.line( + guides, [self.view((r, theta)) for theta in self._x_pos], + close=True, + class_='%sguide line' % ( + 'major ' if major else '')) + x, y = self.view((r, self._x_pos[0])) + x -= 5 + text = self.svg.node( + guides, 'text', + x=x, + y=y, + class_='major' if major else '' + ) + text.text = label + + if self.y_label_rotation: + text.attrib['transform'] = "rotate(%d %f %f)" % ( + self.y_label_rotation, x, y) + + self.svg.node( + guides, 'title', + ).text = self._y_format(r) + + def _compute(self): + """Compute r min max and labels position""" + delta = 2 * pi / self._len if self._len else 0 + self._x_pos = [.5 * pi + i * delta for i in range(self._len + 1)] + for serie in self.all_series: + serie.points = [ + (v, self._x_pos[i]) + for i, v in enumerate(serie.values)] + if self.interpolate: + extended_x_pos = ( + [.5 * pi - delta] + self._x_pos) + extended_vals = (serie.values[-1:] + + serie.values) + serie.interpolated = list( + map(tuple, + map(reversed, + self._interpolate( + extended_x_pos, extended_vals)))) + + # x labels space + self._box.margin *= 2 + self._rmin = self.zero + self._rmax = self._max or 1 + self._box.set_polar_box(self._rmin, self._rmax) + self._self_close = True + + def _compute_y_labels(self): + y_pos = compute_scale( + self._rmin, self._rmax, self.logarithmic, self.order_min, + self.min_scale, self.max_scale / 2 + ) + if self.y_labels: + self._y_labels = [] + for i, y_label in enumerate(self.y_labels): + if isinstance(y_label, dict): + pos = self._adapt(y_label.get('value')) + title = y_label.get('label', self._y_format(pos)) + elif is_str(y_label): + pos = self._adapt(y_pos[i]) + title = y_label + else: + pos = self._adapt(y_label) + title = self._y_format(pos) + self._y_labels.append((title, pos)) + self._rmin = min(self._rmin, min(cut(self._y_labels, 1))) + self._rmax = max(self._rmax, max(cut(self._y_labels, 1))) + self._box.set_polar_box(self._rmin, self._rmax) + + else: + self._y_labels = list(zip(map(self._y_format, y_pos), y_pos)) diff --git a/pygal/graph/solidgauge.py b/pygal/graph/solidgauge.py new file mode 100644 index 0000000..fc32b87 --- /dev/null +++ b/pygal/graph/solidgauge.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + + +""" +Solid Guage +For each series a solid guage is shown on the plot area. +""" +from __future__ import division + +from math import pi, sqrt + +from pygal.graph.graph import Graph +from pygal.util import alter, decorate + + +class SolidGauge(Graph): + + def gaugify(self, serie, squares, sq_dimensions, current_square): + serie_node = self.svg.serie(serie) + if self.half_pie: + start_angle = 3 * pi / 2 + center = ( + (current_square[1] * sq_dimensions[0]) - ( + sq_dimensions[0] / 2.), + (current_square[0] * sq_dimensions[1]) - ( + sq_dimensions[1] / 4)) + end_angle = pi / 2 + else: + start_angle = 0 + center = ( + (current_square[1] * sq_dimensions[0]) - ( + sq_dimensions[0] / 2.), + (current_square[0] * sq_dimensions[1]) - ( + sq_dimensions[1] / 2.)) + end_angle = 2 * pi + + max_value = serie.metadata.get(0, {}).get('max_value', 100) + radius = min([sq_dimensions[0] / 2, sq_dimensions[1] / 2]) * .9 + small_radius = radius * serie.inner_radius + + self.svg.gauge_background( + serie_node, start_angle, center, radius, small_radius, end_angle, + self.half_pie, self._serie_format(serie, max_value)) + + sum_ = 0 + for i, value in enumerate(serie.values): + if value is None: + continue + ratio = min(value, max_value) / max_value + if self.half_pie: + angle = 2 * pi * ratio / 2 + else: + angle = 2 * pi * ratio + + val = self._format(serie, i) + metadata = serie.metadata.get(i) + + gauge_ = decorate( + self.svg, + self.svg.node(serie_node['plot'], class_="gauge"), + metadata) + + alter( + self.svg.solid_gauge( + serie_node, gauge_, radius, small_radius, + angle, start_angle, center, val, i, metadata, + self.half_pie, end_angle, + self._serie_format(serie, max_value)), + metadata) + start_angle += angle + sum_ += value + + x, y = center + self.svg.node( + serie_node['text_overlay'], 'text', + class_='value gauge-sum', + x=x, + y=y + self.style.value_font_size / 3, + attrib={'text-anchor': 'middle'} + ).text = self._serie_format(serie, sum_) + + def _compute_x_labels(self): + pass + + def _compute_y_labels(self): + pass + + def _plot(self): + """Draw all the serie slices""" + squares = self._squares() + sq_dimensions = self.add_squares(squares) + + for index, serie in enumerate(self.series): + current_square = self._current_square(squares, index) + self.gaugify( + serie, squares, sq_dimensions, current_square) + + def _squares(self): + + n_series_ = len(self.series) + i = 2 + + if sqrt(n_series_).is_integer(): + _x = int(sqrt(n_series_)) + _y = int(sqrt(n_series_)) + else: + while i * i < n_series_: + while n_series_ % i == 0: + n_series_ = n_series_ / i + i = i + 1 + _y = int(n_series_) + _x = int(len(self.series) / _y) + if len(self.series) == 5: + _x, _y = 2, 3 + if abs(_x - _y) > 2: + _sq = 3 + while (_x * _y) - 1 < len(self.series): + _x, _y = _sq, _sq + _sq += 1 + return (_x, _y) + + def _current_square(self, squares, index): + current_square = [1, 1] + steps = index + 1 + steps_taken = 0 + for i in range(squares[0] * squares[1]): + steps_taken += 1 + if steps_taken != steps and steps_taken % squares[0] != 0: + current_square[1] += 1 + elif steps_taken != steps and steps_taken % squares[0] == 0: + current_square[1] = 1 + current_square[0] += 1 + else: + return tuple(current_square) + raise Exception( + 'Something went wrong with the current square assignment.') diff --git a/pygal/graph/stackedbar.py b/pygal/graph/stackedbar.py new file mode 100644 index 0000000..a75a923 --- /dev/null +++ b/pygal/graph/stackedbar.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +""" +Stacked Bar chart: Like a bar chart but with all series stacking +on top of the others instead of being displayed side by side. +""" + +from __future__ import division + +from pygal.adapters import none_to_zero +from pygal.graph.bar import Bar + + +class StackedBar(Bar): + + """Stacked Bar graph class""" + + _adapters = [none_to_zero] + + def _get_separated_values(self, secondary=False): + """Separate values between positives and negatives stacked""" + series = self.secondary_series if secondary else self.series + transposed = list(zip(*[serie.values for serie in series])) + positive_vals = [sum([ + val for val in vals + if val is not None and val >= self.zero]) + for vals in transposed] + negative_vals = [sum([ + val + for val in vals + if val is not None and val < self.zero]) + for vals in transposed] + return positive_vals, negative_vals + + def _compute_box(self, positive_vals, negative_vals): + """Compute Y min and max""" + if self.range and self.range[0] is not None: + self._box.ymin = self.range[0] + else: + self._box.ymin = negative_vals and min( + min(negative_vals), self.zero) or self.zero + if self.range and self.range[1] is not None: + self._box.ymax = self.range[1] + else: + self._box.ymax = positive_vals and max( + max(positive_vals), self.zero) or self.zero + + def _compute(self): + """Compute y min and max and y scale and set labels""" + positive_vals, negative_vals = self._get_separated_values() + + if self.logarithmic: + positive_vals = list(filter( + lambda x: x > self.zero, positive_vals)) + negative_vals = list(filter( + lambda x: x > self.zero, negative_vals)) + + self._compute_box(positive_vals, negative_vals) + positive_vals = positive_vals or [self.zero] + negative_vals = negative_vals or [self.zero] + + self._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(self._x_pos) + + self.negative_cumulation = [0] * self._len + self.positive_cumulation = [0] * self._len + + if self.secondary_series: + positive_vals, negative_vals = self._get_separated_values(True) + positive_vals = positive_vals or [self.zero] + negative_vals = negative_vals or [self.zero] + self.secondary_negative_cumulation = [0] * self._len + self.secondary_positive_cumulation = [0] * self._len + self._pre_compute_secondary(positive_vals, negative_vals) + + self._x_pos = [(i + .5) / self._len for i in range(self._len)] + + def _pre_compute_secondary(self, positive_vals, negative_vals): + """Compute secondary y min and max""" + self._secondary_min = (negative_vals and min( + min(negative_vals), self.zero)) or self.zero + self._secondary_max = (positive_vals and max( + max(positive_vals), self.zero)) or self.zero + + def _bar(self, serie, parent, x, y, i, zero, secondary=False): + """Internal stacking bar drawing function""" + if secondary: + cumulation = (self.secondary_negative_cumulation + if y < self.zero else + self.secondary_positive_cumulation) + else: + cumulation = (self.negative_cumulation + if y < self.zero else + self.positive_cumulation) + zero = cumulation[i] + cumulation[i] = zero + y + if zero == 0: + zero = self.zero + y -= self.zero + y += zero + + width = (self.view.x(1) - self.view.x(0)) / self._len + x, y = self.view((x, y)) + y = y or 0 + series_margin = width * self._series_margin + x += series_margin + width -= 2 * series_margin + if self.secondary_series: + width /= 2 + x += int(secondary) * width + serie_margin = width * self._serie_margin + x += serie_margin + width -= 2 * serie_margin + height = self.view.y(zero) - y + r = serie.rounded_bars * 1 if serie.rounded_bars else 0 + self.svg.transposable_node( + parent, 'rect', + x=x, y=y, rx=r, ry=r, width=width, height=height, + class_='rect reactive tooltip-trigger') + return x, y, width, height + + def _plot(self): + """Draw bars for series and secondary series""" + for serie in self.series[::-1 if self.stack_from_top else 1]: + self.bar(serie) + for serie in self.secondary_series[::-1 if self.stack_from_top else 1]: + self.bar(serie, True) diff --git a/pygal/graph/stackedline.py b/pygal/graph/stackedline.py new file mode 100644 index 0000000..455e9cb --- /dev/null +++ b/pygal/graph/stackedline.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +""" +Stacked Line chart: Like a line chart but with all lines stacking +on top of the others. Used along fill=True option. +""" + +from __future__ import division + +from pygal.adapters import none_to_zero +from pygal.graph.line import Line + + +class StackedLine(Line): + + """Stacked Line graph class""" + + _adapters = [none_to_zero] + + def __init__(self, *args, **kwargs): + """Custom variable initialization""" + self._previous_line = None + super(StackedLine, self).__init__(*args, **kwargs) + + def _value_format(self, value, serie, index): + """ + Display value and cumulation + """ + sum_ = serie.points[index][1] + if serie in self.series and ( + self.stack_from_top and + self.series.index(serie) == self._order - 1 or + not self.stack_from_top and + self.series.index(serie) == 0): + return super(StackedLine, self)._value_format(value) + return '%s (+%s)' % ( + self._y_format(sum_), + self._y_format(value) + ) + + def _fill(self, values): + """Add extra values to fill the line""" + if not self._previous_line: + self._previous_line = values + return super(StackedLine, self)._fill(values) + new_values = values + list(reversed(self._previous_line)) + self._previous_line = values + return new_values + + def _points(self, x_pos): + """ + Convert given data values into drawable points (x, y) + and interpolated points if interpolate option is specified + """ + for series_group in (self.series, self.secondary_series): + accumulation = [0] * self._len + for serie in series_group[::-1 if self.stack_from_top else 1]: + accumulation = list(map(sum, zip(accumulation, serie.values))) + serie.points = [ + (x_pos[i], v) + for i, v in enumerate(accumulation)] + if serie.points and self.interpolate: + serie.interpolated = self._interpolate(x_pos, accumulation) + else: + serie.interpolated = [] + + def _plot(self): + """Plot stacked serie lines and stacked secondary lines""" + for serie in self.series[::-1 if self.stack_from_top else 1]: + self.line(serie) + for serie in self.secondary_series[::-1 if self.stack_from_top else 1]: + self.line(serie, True) diff --git a/pygal/graph/time.py b/pygal/graph/time.py new file mode 100644 index 0000000..abd8f25 --- /dev/null +++ b/pygal/graph/time.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +""" +XY time extensions: handle convertion of date, time, datetime, timedelta +into float for xy plot and back to their type for display +""" + +from datetime import date, datetime, time, timedelta + +from pygal._compat import is_str, timestamp +from pygal.adapters import positive +from pygal.graph.xy import XY + + +def datetime_to_timestamp(x): + """Convert a datetime into a utc float timestamp""" + if isinstance(x, datetime): + return timestamp(x) + return x + + +def datetime_to_time(x): + """Convert a datetime into a time""" + if isinstance(x, datetime): + return x.time() + return x + + +def date_to_datetime(x): + """Convert a date into a datetime""" + if not isinstance(x, datetime) and isinstance(x, date): + return datetime.combine(x, time()) + return x + + +def time_to_datetime(x): + """Convert a time into a datetime""" + if isinstance(x, time): + return datetime.combine(date(1970, 1, 1), x) + return x + + +def timedelta_to_seconds(x): + """Convert a timedelta into an amount of seconds""" + if isinstance(x, timedelta): + return x.total_seconds() + return x + + +def time_to_seconds(x): + """Convert a time in a seconds sum""" + if isinstance(x, time): + return (( + ((x.hour * 60) + x.minute) * 60 + x.second + ) * 10 ** 6 + x.microsecond) / 10 ** 6 + + if is_str(x): + return x + # Clamp to valid time + return x and max(0, min(x, 24 * 3600 - 10 ** -6)) + + +def seconds_to_time(x): + """Convert a number of second into a time""" + t = int(x * 10 ** 6) + ms = t % 10 ** 6 + t = t // 10 ** 6 + s = t % 60 + t = t // 60 + m = t % 60 + t = t // 60 + h = t + return time(h, m, s, ms) + + +class DateTimeLine(XY): + + """DateTime abscissa xy graph class""" + + _x_adapters = [datetime_to_timestamp, date_to_datetime] + + @property + def _x_format(self): + """Return the value formatter for this graph""" + def datetime_to_str(x): + dt = datetime.utcfromtimestamp(x) + return self.x_value_formatter(dt) + return datetime_to_str + + +class DateLine(DateTimeLine): + + """Date abscissa xy graph class""" + + @property + def _x_format(self): + """Return the value formatter for this graph""" + def date_to_str(x): + d = datetime.utcfromtimestamp(x).date() + return self.x_value_formatter(d) + return date_to_str + + +class TimeLine(DateTimeLine): + + """Time abscissa xy graph class""" + + _x_adapters = [positive, time_to_seconds, datetime_to_time] + + @property + def _x_format(self): + """Return the value formatter for this graph""" + def date_to_str(x): + t = seconds_to_time(x) + return self.x_value_formatter(t) + return date_to_str + + +class TimeDeltaLine(XY): + + """TimeDelta abscissa xy graph class""" + + _x_adapters = [timedelta_to_seconds] + + @property + def _x_format(self): + """Return the value formatter for this graph""" + def timedelta_to_str(x): + td = timedelta(seconds=x) + return self.x_value_formatter(td) + + return timedelta_to_str diff --git a/pygal/graph/treemap.py b/pygal/graph/treemap.py new file mode 100644 index 0000000..8b954bd --- /dev/null +++ b/pygal/graph/treemap.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Treemap chart: Visualize data using nested recangles""" + +from __future__ import division + +from pygal.adapters import none_to_zero, positive +from pygal.graph.graph import Graph +from pygal.util import alter, cut, decorate + + +class Treemap(Graph): + + """Treemap graph class""" + + _adapters = [positive, none_to_zero] + + def _rect(self, serie, serie_node, rects, val, x, y, w, h, i): + rx, ry = self.view((x, y)) + rw, rh = self.view((x + w, y + h)) + rw -= rx + rh -= ry + + metadata = serie.metadata.get(i) + + val = self._format(serie, i) + + rect = decorate( + self.svg, + self.svg.node(rects, class_="rect"), + metadata) + + alter( + self.svg.node( + rect, 'rect', + x=rx, + y=ry, + width=rw, + height=rh, + class_='rect reactive tooltip-trigger'), + metadata) + + self._tooltip_data( + rect, val, + rx + rw / 2, + ry + rh / 2, + 'centered', + self._get_x_label(i)) + self._static_value( + serie_node, val, + rx + rw / 2, + ry + rh / 2, + metadata) + + def _binary_tree(self, data, total, x, y, w, h, parent=None): + if total == 0: + return + if len(data) == 1: + if parent: + i, datum = data[0] + serie, serie_node, rects = parent + self._rect(serie, serie_node, rects, datum, x, y, w, h, i) + else: + datum = data[0] + serie_node = self.svg.serie(datum) + self._binary_tree( + list(enumerate(datum.values)), + total, x, y, w, h, + (datum, serie_node, + self.svg.node(serie_node['plot'], class_="rects"))) + return + + midpoint = total / 2 + pivot_index = 1 + running_sum = 0 + for i, elt in enumerate(data): + if running_sum >= midpoint: + pivot_index = i + break + + running_sum += elt[1] if parent else sum(elt.values) + + half1 = data[:pivot_index] + half2 = data[pivot_index:] + + if parent: + half1_sum = sum(cut(half1, 1)) + half2_sum = sum(cut(half2, 1)) + else: + half1_sum = sum(map(sum, map(lambda x: x.values, half1))) + half2_sum = sum(map(sum, map(lambda x: x.values, half2))) + pivot_pct = half1_sum / total + + if h > w: + y_pivot = pivot_pct * h + self._binary_tree( + half1, half1_sum, x, y, w, y_pivot, parent) + self._binary_tree( + half2, half2_sum, x, y + y_pivot, w, h - y_pivot, parent) + else: + x_pivot = pivot_pct * w + self._binary_tree( + half1, half1_sum, x, y, x_pivot, h, parent) + self._binary_tree( + half2, half2_sum, x + x_pivot, y, w - x_pivot, h, parent) + + def _compute_x_labels(self): + pass + + def _compute_y_labels(self): + pass + + def _plot(self): + total = sum(map(sum, map(lambda x: x.values, self.series))) + if total == 0: + return + + gw = self.width - self.margin_box.x + gh = self.height - self.margin_box.y + + self.view.box.xmin = self.view.box.ymin = x = y = 0 + self.view.box.xmax = w = (total * gw / gh) ** .5 + self.view.box.ymax = h = total / w + self.view.box.fix() + + self._binary_tree(self.series, total, x, y, w, h) diff --git a/pygal/graph/xy.py b/pygal/graph/xy.py new file mode 100644 index 0000000..e24caa1 --- /dev/null +++ b/pygal/graph/xy.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +""" +XY Line graph: Plot a set of couple data points (x, y) connected by +straight segments. +""" + +from __future__ import division + +from functools import reduce + +from pygal.graph.dual import Dual +from pygal.graph.line import Line +from pygal.util import cached_property, compose, ident + + +class XY(Line, Dual): + + """XY Line graph class""" + + _x_adapters = [] + + @cached_property + def xvals(self): + """All x values""" + return [val[0] + for serie in self.all_series + for val in serie.values + if val[0] is not None] + + @cached_property + def yvals(self): + """All y values""" + return [val[1] + for serie in self.series + for val in serie.values + if val[1] is not 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.yvals) if self.yvals 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.yvals) if self.yvals else None)) + + def _compute(self): + """Compute x/y min and max and x/y scale and set labels""" + if self.xvals: + if self.xrange: + x_adapter = reduce( + compose, self._x_adapters) if getattr( + self, '_x_adapters', None) else ident + + xmin = x_adapter(self.xrange[0]) + xmax = x_adapter(self.xrange[1]) + + else: + xmin = min(self.xvals) + xmax = max(self.xvals) + xrng = (xmax - xmin) + else: + xrng = None + + if self.yvals: + ymin = self._min + ymax = self._max + + if self.include_x_axis: + ymin = min(ymin or 0, 0) + ymax = max(ymax or 0, 0) + + yrng = (ymax - ymin) + else: + yrng = None + + for serie in self.all_series: + serie.points = serie.values + if self.interpolate: + vals = list(zip(*sorted( + filter(lambda t: None not in t, + serie.points), key=lambda x: x[0]))) + serie.interpolated = self._interpolate(vals[0], vals[1]) + + if self.interpolate: + self.xvals = [val[0] + for serie in self.all_series + for val in serie.interpolated] + self.yvals = [val[1] + for serie in self.series + for val in serie.interpolated] + if self.xvals: + xmin = min(self.xvals) + xmax = max(self.xvals) + xrng = (xmax - xmin) + else: + xrng = None + + # these values can also be 0 (zero), so testing explicitly for None + if xrng is not None: + self._box.xmin, self._box.xmax = xmin, xmax + + if yrng is not None: + self._box.ymin, self._box.ymax = ymin, ymax diff --git a/pygal/interpolate.py b/pygal/interpolate.py new file mode 100644 index 0000000..6a10dda --- /dev/null +++ b/pygal/interpolate.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +""" +Interpolation functions + +These functions takes two lists of points x and y and +returns an iterator over the interpolation between all these points +with `precision` interpolated points between each of them + +""" +from __future__ import division + +from math import sin + + +def quadratic_interpolate(x, y, precision=250, **kwargs): + """ + Interpolate x, y using a quadratic algorithm + https://en.wikipedia.org/wiki/Spline_(mathematics) + """ + n = len(x) - 1 + delta_x = [x2 - x1 for x1, x2 in zip(x, x[1:])] + delta_y = [y2 - y1 for y1, y2 in zip(y, y[1:])] + slope = [delta_y[i] / delta_x[i] if delta_x[i] else 1 for i in range(n)] + + # Quadratic spline: a + bx + cx² + a = y + b = [0] * (n + 1) + c = [0] * (n + 1) + + for i in range(1, n): + b[i] = 2 * slope[i - 1] - b[i - 1] + + c = [(slope[i] - b[i]) / delta_x[i] if delta_x[i] else 0 for i in range(n)] + + for i in range(n + 1): + yield x[i], a[i] + if i == n or delta_x[i] == 0: + continue + for s in range(1, precision): + X = s * delta_x[i] / precision + X2 = X * X + yield x[i] + X, a[i] + b[i] * X + c[i] * X2 + + +def cubic_interpolate(x, y, precision=250, **kwargs): + """ + Interpolate x, y using a cubic algorithm + https://en.wikipedia.org/wiki/Spline_interpolation + """ + n = len(x) - 1 + # Spline equation is a + bx + cx² + dx³ + # ie: Spline part i equation is a[i] + b[i]x + c[i]x² + d[i]x³ + a = y + b = [0] * (n + 1) + c = [0] * (n + 1) + d = [0] * (n + 1) + m = [0] * (n + 1) + z = [0] * (n + 1) + + h = [x2 - x1 for x1, x2 in zip(x, x[1:])] + k = [a2 - a1 for a1, a2 in zip(a, a[1:])] + g = [k[i] / h[i] if h[i] else 1 for i in range(n)] + + for i in range(1, n): + j = i - 1 + l = 1 / (2 * (x[i + 1] - x[j]) - h[j] * m[j]) if x[i + 1] - x[j] else 0 + m[i] = h[i] * l + z[i] = (3 * (g[i] - g[j]) - h[j] * z[j]) * l + + for j in reversed(range(n)): + if h[j] == 0: + continue + c[j] = z[j] - (m[j] * c[j + 1]) + b[j] = g[j] - (h[j] * (c[j + 1] + 2 * c[j])) / 3 + d[j] = (c[j + 1] - c[j]) / (3 * h[j]) + + for i in range(n + 1): + yield x[i], a[i] + if i == n or h[i] == 0: + continue + for s in range(1, precision): + X = s * h[i] / precision + X2 = X * X + X3 = X2 * X + yield x[i] + X, a[i] + b[i] * X + c[i] * X2 + d[i] * X3 + + +def hermite_interpolate(x, y, precision=250, + type='cardinal', c=None, b=None, t=None): + """ + Interpolate x, y using the hermite method. + See https://en.wikipedia.org/wiki/Cubic_Hermite_spline + + This interpolation is configurable and contain 4 subtypes: + * Catmull Rom + * Finite Difference + * Cardinal + * Kochanek Bartels + + The cardinal subtype is customizable with a parameter: + * c: tension (0, 1) + + This last type is also customizable using 3 parameters: + * c: continuity (-1, 1) + * b: bias (-1, 1) + * t: tension (-1, 1) + + """ + n = len(x) - 1 + m = [1] * (n + 1) + w = [1] * (n + 1) + delta_x = [x2 - x1 for x1, x2 in zip(x, x[1:])] + if type == 'catmull_rom': + type = 'cardinal' + c = 0 + if type == 'finite_difference': + for i in range(1, n): + m[i] = w[i] = .5 * ( + (y[i + 1] - y[i]) / (x[i + 1] - x[i]) + + (y[i] - y[i - 1]) / ( + x[i] - x[i - 1]) + ) if x[i + 1] - x[i] and x[i] - x[i - 1] else 0 + + elif type == 'kochanek_bartels': + c = c or 0 + b = b or 0 + t = t or 0 + for i in range(1, n): + m[i] = .5 * ((1 - t) * (1 + b) * (1 + c) * (y[i] - y[i - 1]) + + (1 - t) * (1 - b) * (1 - c) * (y[i + 1] - y[i])) + w[i] = .5 * ((1 - t) * (1 + b) * (1 - c) * (y[i] - y[i - 1]) + + (1 - t) * (1 - b) * (1 + c) * (y[i + 1] - y[i])) + + if type == 'cardinal': + c = c or 0 + for i in range(1, n): + m[i] = w[i] = (1 - c) * ( + y[i + 1] - y[i - 1]) / ( + x[i + 1] - x[i - 1]) if x[i + 1] - x[i - 1] else 0 + + def p(i, x_): + t = (x_ - x[i]) / delta_x[i] + t2 = t * t + t3 = t2 * t + + h00 = 2 * t3 - 3 * t2 + 1 + h10 = t3 - 2 * t2 + t + h01 = - 2 * t3 + 3 * t2 + h11 = t3 - t2 + + return (h00 * y[i] + + h10 * m[i] * delta_x[i] + + h01 * y[i + 1] + + h11 * w[i + 1] * delta_x[i]) + + for i in range(n + 1): + yield x[i], y[i] + if i == n or delta_x[i] == 0: + continue + for s in range(1, precision): + X = x[i] + s * delta_x[i] / precision + yield X, p(i, X) + + +def lagrange_interpolate(x, y, precision=250, **kwargs): + """ + Interpolate x, y using Lagrange polynomials + https://en.wikipedia.org/wiki/Lagrange_polynomial + """ + n = len(x) - 1 + delta_x = [x2 - x1 for x1, x2 in zip(x, x[1:])] + for i in range(n + 1): + yield x[i], y[i] + if i == n or delta_x[i] == 0: + continue + + for s in range(1, precision): + X = x[i] + s * delta_x[i] / precision + s = 0 + for k in range(n + 1): + p = 1 + for m in range(n + 1): + if m == k: + continue + if x[k] - x[m]: + p *= (X - x[m]) / (x[k] - x[m]) + s += y[k] * p + yield X, s + + +def trigonometric_interpolate(x, y, precision=250, **kwargs): + """ + Interpolate x, y using trigonometric + As per http://en.wikipedia.org/wiki/Trigonometric_interpolation + """ + n = len(x) - 1 + delta_x = [x2 - x1 for x1, x2 in zip(x, x[1:])] + for i in range(n + 1): + yield x[i], y[i] + if i == n or delta_x[i] == 0: + continue + + for s in range(1, precision): + X = x[i] + s * delta_x[i] / precision + s = 0 + for k in range(n + 1): + p = 1 + for m in range(n + 1): + if m == k: + continue + if sin(0.5 * (x[k] - x[m])): + p *= sin(0.5 * (X - x[m])) / sin(0.5 * (x[k] - x[m])) + s += y[k] * p + yield X, s + + +INTERPOLATIONS = { + 'quadratic': quadratic_interpolate, + 'cubic': cubic_interpolate, + 'hermite': hermite_interpolate, + 'lagrange': lagrange_interpolate, + 'trigonometric': trigonometric_interpolate +} + + +if __name__ == '__main__': + from pygal import XY + points = [(.1, 7), (.3, -4), (.6, 10), (.9, 8), (1.4, 3), (1.7, 1)] + xy = XY(show_dots=False) + xy.add('normal', points) + xy.add('quadratic', quadratic_interpolate(*zip(*points))) + xy.add('cubic', cubic_interpolate(*zip(*points))) + xy.add('lagrange', lagrange_interpolate(*zip(*points))) + xy.add('trigonometric', trigonometric_interpolate(*zip(*points))) + xy.add('hermite catmul_rom', hermite_interpolate( + *zip(*points), type='catmul_rom')) + xy.add('hermite finite_difference', hermite_interpolate( + *zip(*points), type='finite_difference')) + xy.add('hermite cardinal -.5', hermite_interpolate( + *zip(*points), type='cardinal', c=-.5)) + xy.add('hermite cardinal .5', hermite_interpolate( + *zip(*points), type='cardinal', c=.5)) + xy.add('hermite kochanek_bartels .5 .75 -.25', hermite_interpolate( + *zip(*points), type='kochanek_bartels', c=.5, b=.75, t=-.25)) + xy.add('hermite kochanek_bartels .25 -.75 .5', hermite_interpolate( + *zip(*points), type='kochanek_bartels', c=.25, b=-.75, t=.5)) + xy.render_in_browser() diff --git a/pygal/maps/__init__.py b/pygal/maps/__init__.py new file mode 100644 index 0000000..28ea40f --- /dev/null +++ b/pygal/maps/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Maps extensions namespace module""" diff --git a/pygal/serie.py b/pygal/serie.py new file mode 100644 index 0000000..a5b70c4 --- /dev/null +++ b/pygal/serie.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +"""Serie property holder""" + +from pygal.util import cached_property + + +class Serie(object): + + """Serie class containing title, values and the graph serie index""" + + def __init__(self, index, values, config, metadata=None): + """Create the serie with its options""" + self.index = index + self.values = values + self.config = config + self.__dict__.update(config.__dict__) + self.metadata = metadata or {} + + @cached_property + def safe_values(self): + """Property containing all values that are not None""" + return list(filter(lambda x: x is not None, self.values)) diff --git a/pygal/state.py b/pygal/state.py new file mode 100644 index 0000000..7570a84 --- /dev/null +++ b/pygal/state.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Class holding state during render""" + +from pygal.util import merge + + +class State(object): + + """ + Class containing config values + overriden by chart values + overriden by keyword args + """ + + def __init__(self, graph, **kwargs): + """Create the transient state""" + merge(self.__dict__, graph.config.__class__.__dict__) + merge(self.__dict__, graph.config.__dict__) + merge(self.__dict__, graph.__dict__) + merge(self.__dict__, kwargs) diff --git a/pygal/stats.py b/pygal/stats.py new file mode 100644 index 0000000..55d03b7 --- /dev/null +++ b/pygal/stats.py @@ -0,0 +1,73 @@ +from math import log, pi, sqrt + + +def erfinv(x, a=.147): + """Approximation of the inverse error function + https://en.wikipedia.org/wiki/Error_function + #Approximation_with_elementary_functions + """ + lnx = log(1 - x * x) + part1 = (2 / (a * pi) + lnx / 2) + part2 = lnx / a + sgn = 1 if x > 0 else -1 + return sgn * sqrt(sqrt(part1 * part1 - part2) - part1) + + +def norm_ppf(x): + if not 0 < x < 1: + raise ValueError("Can't compute the percentage point for value %d" % x) + return sqrt(2) * erfinv(2 * x - 1) + + +def ppf(x, n): + try: + from scipy import stats + except ImportError: + stats = None + + if stats: + if n < 30: + return stats.t.ppf(x, n) + return stats.norm.ppf(x) + else: + if n < 30: + # TODO: implement power series: + # http://eprints.maths.ox.ac.uk/184/1/tdist.pdf + raise ImportError( + 'You must have scipy installed to use t-student ' + 'when sample_size is below 30') + return norm_ppf(x) + +# According to http://sphweb.bumc.bu.edu/otlt/MPH-Modules/BS/ +# BS704_Confidence_Intervals/BS704_Confidence_Intervals_print.html + + +def confidence_interval_continuous( + point_estimate, stddev, sample_size, confidence=.95, **kwargs): + """Continuous confidence interval from sample size and standard error""" + alpha = ppf((confidence + 1) / 2, sample_size - 1) + + margin = stddev / sqrt(sample_size) + return (point_estimate - alpha * margin, point_estimate + alpha * margin) + + +def confidence_interval_dichotomous( + point_estimate, sample_size, confidence=.95, bias=False, + percentage=True, **kwargs): + """Dichotomous confidence interval from sample size and maybe a bias""" + alpha = ppf((confidence + 1) / 2, sample_size - 1) + p = point_estimate + if percentage: + p /= 100 + + margin = sqrt(p * (1 - p) / sample_size) + if bias: + margin += .5 / sample_size + if percentage: + margin *= 100 + + return (point_estimate - alpha * margin, point_estimate + alpha * margin) + + +def confidence_interval_manual(point_estimate, low, high): + return (low, high) diff --git a/pygal/style.py b/pygal/style.py new file mode 100644 index 0000000..1b01984 --- /dev/null +++ b/pygal/style.py @@ -0,0 +1,532 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +"""Charts styling classes""" + +from __future__ import division + +from itertools import chain + +from pygal import colors +from pygal.colors import darken, is_foreground_light, lighten + + +class Style(object): + + """Styling class containing colors for the css generation""" + + plot_background = 'rgba(255, 255, 255, 1)' + background = 'rgba(249, 249, 249, 1)' + value_background = 'rgba(229, 229, 229, 1)' + foreground = 'rgba(0, 0, 0, .87)' + foreground_strong = 'rgba(0, 0, 0, 1)' + foreground_subtle = 'rgba(0, 0, 0, .54)' + + # Monospaced font is highly encouraged + font_family = ( + 'Consolas, "Liberation Mono", Menlo, Courier, monospace') + + label_font_family = None + major_label_font_family = None + value_font_family = None + value_label_font_family = None + tooltip_font_family = None + title_font_family = None + legend_font_family = None + no_data_font_family = None + + label_font_size = 10 + major_label_font_size = 10 + value_font_size = 16 + value_label_font_size = 10 + tooltip_font_size = 14 + title_font_size = 16 + legend_font_size = 14 + no_data_font_size = 64 + + # Guide line dash array style + guide_stroke_dasharray = '4,4' + major_guide_stroke_dasharray = '6,6' + + opacity = '.7' + opacity_hover = '.8' + + stroke_opacity = '.8' + stroke_opacity_hover = '.9' + + transition = '150ms' + colors = ( + '#F44336', # 0 + '#3F51B5', # 4 + '#009688', # 8 + '#FFC107', # 13 + '#FF5722', # 15 + '#9C27B0', # 2 + '#03A9F4', # 6 + '#8BC34A', # 10 + '#FF9800', # 14 + '#E91E63', # 1 + '#2196F3', # 5 + '#4CAF50', # 9 + '#FFEB3B', # 12 + '#673AB7', # 3 + '#00BCD4', # 7 + '#CDDC39', # 11b + '#9E9E9E', # 17 + '#607D8B', # 18 + ) + + value_colors = () + ci_colors = () + + def __init__(self, **kwargs): + """Create the style""" + self.__dict__.update(kwargs) + self._google_fonts = set() + if self.font_family.startswith('googlefont:'): + self.font_family = self.font_family.replace('googlefont:', '') + self._google_fonts.add(self.font_family.split(',')[0].strip()) + + for name in dir(self): + if name.endswith('_font_family'): + fn = getattr(self, name) + if fn is None: + setattr(self, name, self.font_family) + elif fn.startswith('googlefont:'): + setattr(self, name, fn.replace('googlefont:', '')) + self._google_fonts.add( + getattr(self, name).split(',')[0].strip()) + + def get_colors(self, prefix, len_): + """Get the css color list""" + def color(tupl): + """Make a color css""" + return (( + '%s.color-{0}, %s.color-{0} a:visited {{\n' + ' stroke: {1};\n' + ' fill: {1};\n' + '}}\n') % (prefix, prefix)).format(*tupl) + + def value_color(tupl): + """Make a value color css""" + return (( + '%s .text-overlay .color-{0} text {{\n' + ' fill: {1};\n' + '}}\n') % (prefix,)).format(*tupl) + + def ci_color(tupl): + """Make a value color css""" + if not tupl[1]: + return '' + return (( + '%s .color-{0} .ci {{\n' + ' stroke: {1};\n' + '}}\n') % (prefix,)).format(*tupl) + + if len(self.colors) < len_: + missing = len_ - len(self.colors) + cycles = 1 + missing // len(self.colors) + colors = [] + for i in range(0, cycles + 1): + for color_ in self.colors: + colors.append(darken(color_, 33 * i / cycles)) + if len(colors) >= len_: + break + else: + continue + break + else: + colors = self.colors[:len_] + + # Auto compute foreground value color when color is missing + value_colors = [] + for i in range(len_): + if i < len(self.value_colors) and self.value_colors[i] is not None: + value_colors.append(self.value_colors[i]) + else: + value_colors.append('white' if is_foreground_light( + colors[i]) else 'black') + + return '\n'.join(chain( + map(color, enumerate(colors)), + map(value_color, enumerate(value_colors)), + map(ci_color, enumerate(self.ci_colors)))) + + def to_dict(self): + """Convert instance to a serializable mapping.""" + config = {} + for attr in dir(self): + if not attr.startswith('_'): + value = getattr(self, attr) + if not hasattr(value, '__call__'): + config[attr] = value + return config + + +DefaultStyle = Style + + +class DarkStyle(Style): + + """A dark style (old default)""" + + background = 'black' + plot_background = '#111' + foreground = '#999' + foreground_strong = '#eee' + foreground_subtle = '#555' + opacity = '.8' + opacity_hover = '.4' + transition = '250ms' + colors = ( + '#ff5995', '#b6e354', '#feed6c', '#8cedff', '#9e6ffe', + '#899ca1', '#f8f8f2', '#bf4646', '#516083', '#f92672', + '#82b414', '#fd971f', '#56c2d6', '#808384', '#8c54fe', + '#465457') + + +class LightStyle(Style): + + """A light style""" + + background = 'white' + plot_background = 'rgba(0, 0, 255, 0.1)' + foreground = 'rgba(0, 0, 0, 0.7)' + foreground_strong = 'rgba(0, 0, 0, 0.9)' + foreground_subtle = 'rgba(0, 0, 0, 0.5)' + colors = ('#242424', '#9f6767', '#92ac68', + '#d0d293', '#9aacc3', '#bb77a4', + '#77bbb5', '#777777') + + +class NeonStyle(DarkStyle): + + """Similar to DarkStyle but with more opacity and effects""" + + opacity = '.1' + opacity_hover = '.75' + transition = '1s ease-out' + + +class CleanStyle(Style): + + """A rather clean style""" + + background = 'transparent' + plot_background = 'rgba(240, 240, 240, 0.7)' + foreground = 'rgba(0, 0, 0, 0.9)' + foreground_strong = 'rgba(0, 0, 0, 0.9)' + foreground_subtle = 'rgba(0, 0, 0, 0.5)' + colors = ( + 'rgb(12,55,149)', 'rgb(117,38,65)', 'rgb(228,127,0)', 'rgb(159,170,0)', + 'rgb(149,12,12)') + + +class DarkSolarizedStyle(Style): + + """Dark solarized popular theme""" + + background = '#073642' + plot_background = '#002b36' + foreground = '#839496' + foreground_strong = '#fdf6e3' + foreground_subtle = '#657b83' + opacity = '.66' + opacity_hover = '.9' + transition = '500ms ease-in' + colors = ( + '#b58900', '#cb4b16', '#dc322f', '#d33682', + '#6c71c4', '#268bd2', '#2aa198', '#859900') + + +class LightSolarizedStyle(DarkSolarizedStyle): + + """Light solarized popular theme""" + + background = '#fdf6e3' + plot_background = '#eee8d5' + foreground = '#657b83' + foreground_strong = '#073642' + foreground_subtle = '#073642' + + +class RedBlueStyle(Style): + + """A red and blue theme""" + + background = lighten('#e6e7e9', 7) + plot_background = lighten('#e6e7e9', 10) + foreground = 'rgba(0, 0, 0, 0.9)' + foreground_strong = 'rgba(0, 0, 0, 0.9)' + foreground_subtle = 'rgba(0, 0, 0, 0.5)' + opacity = '.6' + opacity_hover = '.9' + colors = ( + '#d94e4c', '#e5884f', '#39929a', + lighten('#d94e4c', 10), darken('#39929a', 15), lighten('#e5884f', 17), + darken('#d94e4c', 10), '#234547') + + +class LightColorizedStyle(Style): + + """A light colorized style""" + + background = '#f8f8f8' + plot_background = lighten('#f8f8f8', 3) + foreground = '#333' + foreground_strong = '#666' + foreground_subtle = 'rgba(0, 0 , 0, 0.5)' + opacity = '.5' + opacity_hover = '.9' + transition = '250ms ease-in' + colors = ( + '#fe9592', '#534f4c', '#3ac2c0', '#a2a7a1', + darken('#fe9592', 15), lighten('#534f4c', 15), lighten('#3ac2c0', 15), + lighten('#a2a7a1', 15), lighten('#fe9592', 15), darken('#3ac2c0', 10)) + + +class DarkColorizedStyle(Style): + + """A dark colorized style""" + + background = darken('#3a2d3f', 5) + plot_background = lighten('#3a2d3f', 2) + foreground = 'rgba(255, 255, 255, 0.9)' + foreground_strong = 'rgba(255, 255, 255, 0.9)' + foreground_subtle = 'rgba(255, 255 , 255, 0.5)' + opacity = '.2' + opacity_hover = '.7' + transition = '250ms ease-in' + colors = ( + '#c900fe', '#01b8fe', '#59f500', '#ff00e4', '#f9fa00', + darken('#c900fe', 20), darken('#01b8fe', 15), darken('#59f500', 20), + darken('#ff00e4', 15), lighten('#f9fa00', 20)) + + +class TurquoiseStyle(Style): + + """A turquoise style""" + + background = darken('#1b8088', 15) + plot_background = darken('#1b8088', 17) + foreground = 'rgba(255, 255, 255, 0.9)' + foreground_strong = 'rgba(255, 255, 255, 0.9)' + foreground_subtle = 'rgba(255, 255 , 255, 0.5)' + opacity = '.5' + opacity_hover = '.9' + transition = '250ms ease-in' + colors = ( + '#93d2d9', '#ef940f', '#8C6243', '#fff', + darken('#93d2d9', 20), lighten('#ef940f', 15), + lighten('#8c6243', 15), '#1b8088') + + +class LightGreenStyle(Style): + + """A light green style""" + + background = lighten('#f3f3f3', 3) + plot_background = '#fff' + foreground = '#333333' + foreground_strong = '#666' + foreground_subtle = '#222222' + opacity = '.5' + opacity_hover = '.9' + transition = '250ms ease-in' + colors = ( + '#7dcf30', '#247fab', lighten('#7dcf30', 10), '#ccc', + darken('#7dcf30', 15), '#ddd', lighten('#247fab', 10), + darken('#247fab', 15)) + + +class DarkGreenStyle(Style): + + """A dark green style""" + + background = darken('#251e01', 3) + plot_background = darken('#251e01', 1) + foreground = 'rgba(255, 255, 255, 0.9)' + foreground_strong = 'rgba(255, 255, 255, 0.9)' + foreground_subtle = 'rgba(255, 255, 255, 0.6)' + opacity = '.6' + opacity_hover = '.9' + transition = '250ms ease-in' + colors = ( + '#adde09', '#6e8c06', '#4a5e04', '#fcd202', '#C1E34D', + lighten('#fcd202', 25)) + + +class DarkGreenBlueStyle(Style): + + """A dark green and blue style""" + + background = '#000' + plot_background = lighten('#000', 8) + foreground = 'rgba(255, 255, 255, 0.9)' + foreground_strong = 'rgba(255, 255, 255, 0.9)' + foreground_subtle = 'rgba(255, 255, 255, 0.6)' + opacity = '.55' + opacity_hover = '.9' + transition = '250ms ease-in' + colors = (lighten('#34B8F7', 15), '#7dcf30', '#247fab', + darken('#7dcf30', 10), lighten('#247fab', 10), + lighten('#7dcf30', 10), darken('#247fab', 10), '#fff') + + +class BlueStyle(Style): + + """A blue style""" + + background = darken('#f8f8f8', 3) + plot_background = '#f8f8f8' + foreground = 'rgba(0, 0, 0, 0.9)' + foreground_strong = 'rgba(0, 0, 0, 0.9)' + foreground_subtle = 'rgba(0, 0, 0, 0.6)' + opacity = '.5' + opacity_hover = '.9' + transition = '250ms ease-in' + colors = ( + '#00b2f0', '#43d9be', '#0662ab', darken('#00b2f0', 20), + lighten('#43d9be', 20), lighten('#7dcf30', 10), darken('#0662ab', 15), + '#ffd541', '#7dcf30', lighten('#00b2f0', 15), darken('#ffd541', 20)) + + +class SolidColorStyle(Style): + + """A light style with strong colors""" + + background = '#FFFFFF' + plot_background = '#FFFFFF' + foreground = '#000000' + foreground_strong = '#000000' + foreground_subtle = '#828282' + opacity = '.8' + opacity_hover = '.9' + transition = '400ms ease-in' + colors = ( + '#FF9900', '#DC3912', '#4674D1', '#109618', '#990099', + '#0099C6', '#DD4477', '#74B217', '#B82E2E', '#316395', '#994499') + + +styles = {'default': DefaultStyle, + 'dark': DarkStyle, + 'light': LightStyle, + 'neon': NeonStyle, + 'clean': CleanStyle, + 'light_red_blue': RedBlueStyle, + 'dark_solarized': DarkSolarizedStyle, + 'light_solarized': LightSolarizedStyle, + 'dark_colorized': DarkColorizedStyle, + 'light_colorized': LightColorizedStyle, + 'turquoise': TurquoiseStyle, + 'green': LightGreenStyle, + 'dark_green': DarkGreenStyle, + 'dark_green_blue': DarkGreenBlueStyle, + 'blue': BlueStyle, + 'solid_color': SolidColorStyle} + + +class ParametricStyleBase(Style): + + """Parametric Style base class for all the parametric operations""" + + _op = None + + def __init__(self, color, step=10, max_=None, base_style=None, **kwargs): + """ + Initialization of the parametric style. + + This takes several parameters: + * a `step` which correspond on how many colors will be needed + * a `max_` which defines the maximum amplitude of the color effect + * a `base_style` which will be taken as default for everything + except colors + * any keyword arguments setting other style parameters + """ + + if self._op is None: + raise RuntimeError('ParametricStyle is not instanciable') + + defaults = {} + if base_style is not None: + if isinstance(base_style, type): + base_style = base_style() + defaults.update(base_style.to_dict()) + defaults.update(kwargs) + + super(ParametricStyleBase, self).__init__(**defaults) + + if max_ is None: + violency = { + 'darken': 50, + 'lighten': 50, + 'saturate': 100, + 'desaturate': 100, + 'rotate': 360 + } + max_ = violency[self._op] + + def modifier(index): + percent = max_ * index / (step - 1) + return getattr(colors, self._op)(color, percent) + + self.colors = list(map(modifier, range(0, max(2, step)))) + + +class LightenStyle(ParametricStyleBase): + + """Create a style by lightening the given color""" + + _op = 'lighten' + + +class DarkenStyle(ParametricStyleBase): + + """Create a style by darkening the given color""" + + _op = 'darken' + + +class SaturateStyle(ParametricStyleBase): + + """Create a style by saturating the given color""" + + _op = 'saturate' + + +class DesaturateStyle(ParametricStyleBase): + + """Create a style by desaturating the given color""" + + _op = 'desaturate' + + +class RotateStyle(ParametricStyleBase): + + """Create a style by rotating the given color""" + + _op = 'rotate' + + +parametric_styles = { + 'lighten': LightenStyle, + 'darken': DarkenStyle, + 'saturate': SaturateStyle, + 'desaturate': DesaturateStyle, + 'rotate': RotateStyle +} diff --git a/pygal/svg.py b/pygal/svg.py new file mode 100644 index 0000000..d72c1e6 --- /dev/null +++ b/pygal/svg.py @@ -0,0 +1,498 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +"""Svg helper""" + +from __future__ import division + +import io +import json +import os +from datetime import date, datetime +from math import pi +from numbers import Number + +from pygal import __version__ +from pygal._compat import quote_plus, to_str, u +from pygal.etree import etree +from pygal.util import ( + coord_abs_project, coord_diff, coord_dual, coord_format, coord_project, + minify_css, template) + +nearly_2pi = 2 * pi - .00001 + + +class Svg(object): + + """Svg related methods""" + + ns = 'http://www.w3.org/2000/svg' + xlink_ns = 'http://www.w3.org/1999/xlink' + + def __init__(self, graph): + """Create the svg helper with the chart instance""" + self.graph = graph + if not graph.no_prefix: + self.id = '#chart-%s ' % graph.uuid + else: + self.id = '' + self.processing_instructions = [] + if etree.lxml: + attrs = { + 'nsmap': { + None: self.ns, + 'xlink': self.xlink_ns + } + } + else: + attrs = { + 'xmlns': self.ns + } + if hasattr(etree, 'register_namespace'): + etree.register_namespace('xlink', self.xlink_ns) + else: + etree._namespace_map[self.xlink_ns] = 'xlink' + + self.root = etree.Element('svg', **attrs) + self.root.attrib['id'] = self.id.lstrip('#').rstrip() + if graph.classes: + self.root.attrib['class'] = ' '.join(graph.classes) + self.root.append( + etree.Comment(u( + 'Generated with pygal %s (%s) ©Kozea 2012-2016 on %s' % ( + __version__, + 'lxml' if etree.lxml else 'etree', + date.today().isoformat())))) + self.root.append(etree.Comment(u('http://pygal.org'))) + self.root.append(etree.Comment(u('http://github.com/Kozea/pygal'))) + self.defs = self.node(tag='defs') + self.title = self.node(tag='title') + self.title.text = graph.title or 'Pygal' + + for def_ in self.graph.defs: + self.defs.append(etree.fromstring(def_)) + + def add_styles(self): + """Add the css to the svg""" + colors = self.graph.style.get_colors(self.id, self.graph._order) + strokes = self.get_strokes() + all_css = [] + auto_css = ['file://base.css'] + + if self.graph.style._google_fonts: + auto_css.append( + '//fonts.googleapis.com/css?family=%s' % quote_plus( + '|'.join(self.graph.style._google_fonts)) + ) + + for css in auto_css + list(self.graph.css): + css_text = None + if css.startswith('inline:'): + css_text = css[len('inline:'):] + elif css.startswith('file://'): + css = css[len('file://'):] + + if not os.path.exists(css): + css = os.path.join( + os.path.dirname(__file__), 'css', css) + + with io.open(css, encoding='utf-8') as f: + css_text = template( + f.read(), + style=self.graph.style, + colors=colors, + strokes=strokes, + id=self.id) + + if css_text is not None: + if not self.graph.pretty_print: + css_text = minify_css(css_text) + all_css.append(css_text) + else: + if css.startswith('//') and self.graph.force_uri_protocol: + css = '%s:%s' % (self.graph.force_uri_protocol, css) + self.processing_instructions.append( + etree.PI( + u('xml-stylesheet'), u('href="%s"' % css))) + self.node( + self.defs, 'style', type='text/css').text = '\n'.join(all_css) + + def add_scripts(self): + """Add the js to the svg""" + common_script = self.node(self.defs, 'script', type='text/javascript') + + def get_js_dict(): + return dict( + (k, getattr(self.graph.state, k)) + for k in dir(self.graph.config) + if not k.startswith('_') and hasattr(self.graph.state, k) and + not hasattr(getattr(self.graph.state, k), '__call__')) + + def json_default(o): + if isinstance(o, (datetime, date)): + return o.isoformat() + if hasattr(o, 'to_dict'): + return o.to_dict() + return json.JSONEncoder().default(o) + + dct = get_js_dict() + # Config adds + dct['legends'] = [ + l.get('title') if isinstance(l, dict) else l + for l in self.graph._legends + self.graph._secondary_legends] + + common_js = 'window.pygal = window.pygal || {};' + common_js += 'window.pygal.config = window.pygal.config || {};' + if self.graph.no_prefix: + common_js += 'window.pygal.config = ' + else: + common_js += 'window.pygal.config[%r] = ' % self.graph.uuid + + common_script.text = common_js + json.dumps(dct, default=json_default) + + for js in self.graph.js: + if js.startswith('file://'): + script = self.node(self.defs, 'script', type='text/javascript') + with io.open(js[len('file://'):], encoding='utf-8') as f: + script.text = f.read() + else: + if js.startswith('//') and self.graph.force_uri_protocol: + js = '%s:%s' % (self.graph.force_uri_protocol, js) + self.node(self.defs, 'script', type='text/javascript', href=js) + + def node(self, parent=None, tag='g', attrib=None, **extras): + """Make a new svg node""" + if parent is None: + parent = self.root + attrib = attrib or {} + attrib.update(extras) + + def in_attrib_and_number(key): + return key in attrib and isinstance(attrib[key], Number) + + for pos, dim in (('x', 'width'), ('y', 'height')): + if in_attrib_and_number(dim) and attrib[dim] < 0: + attrib[dim] = - attrib[dim] + if in_attrib_and_number(pos): + attrib[pos] = attrib[pos] - attrib[dim] + + for key, value in dict(attrib).items(): + if value is None: + del attrib[key] + + attrib[key] = to_str(value) + if key.endswith('_'): + attrib[key.rstrip('_')] = attrib[key] + del attrib[key] + elif key == 'href': + attrib[etree.QName( + 'http://www.w3.org/1999/xlink', key)] = attrib[key] + del attrib[key] + return etree.SubElement(parent, tag, attrib) + + def transposable_node(self, parent=None, tag='g', attrib=None, **extras): + """Make a new svg node which can be transposed if horizontal""" + if self.graph.horizontal: + for key1, key2 in (('x', 'y'), ('width', 'height'), ('cx', 'cy')): + attr1 = extras.get(key1, None) + attr2 = extras.get(key2, None) + if attr2: + extras[key1] = attr2 + elif attr1: + del extras[key1] + if attr1: + extras[key2] = attr1 + elif attr2: + del extras[key2] + return self.node(parent, tag, attrib, **extras) + + def serie(self, serie): + """Make serie node""" + return dict( + plot=self.node( + self.graph.nodes['plot'], + class_='series serie-%d color-%d' % ( + serie.index, serie.index)), + overlay=self.node( + self.graph.nodes['overlay'], + class_='series serie-%d color-%d' % ( + serie.index, serie.index)), + text_overlay=self.node( + self.graph.nodes['text_overlay'], + class_='series serie-%d color-%d' % ( + serie.index, serie.index))) + + def line(self, node, coords, close=False, **kwargs): + """Draw a svg line""" + line_len = len(coords) + if len([c for c in coords if c[1] is not None]) < 2: + return + root = 'M%s L%s Z' if close else 'M%s L%s' + origin_index = 0 + while origin_index < line_len and None in coords[origin_index]: + origin_index += 1 + if origin_index == line_len: + return + if self.graph.horizontal: + coord_format = lambda xy: '%f %f' % (xy[1], xy[0]) + else: + coord_format = lambda xy: '%f %f' % xy + + origin = coord_format(coords[origin_index]) + line = ' '.join([coord_format(c) + for c in coords[origin_index + 1:] + if None not in c]) + return self.node( + node, 'path', d=root % (origin, line), **kwargs) + + def slice( + self, serie_node, node, radius, small_radius, + angle, start_angle, center, val, i, metadata): + """Draw a pie slice""" + if angle == 2 * pi: + angle = nearly_2pi + + if angle > 0: + to = [coord_abs_project(center, radius, start_angle), + coord_abs_project(center, radius, start_angle + angle), + coord_abs_project(center, small_radius, start_angle + angle), + coord_abs_project(center, small_radius, start_angle)] + rv = self.node( + node, 'path', + d='M%s A%s 0 %d 1 %s L%s A%s 0 %d 0 %s z' % ( + to[0], + coord_dual(radius), int(angle > pi), to[1], + to[2], + coord_dual(small_radius), int(angle > pi), to[3]), + class_='slice reactive tooltip-trigger') + else: + rv = None + x, y = coord_diff(center, coord_project( + (radius + small_radius) / 2, start_angle + angle / 2)) + + self.graph._tooltip_data( + node, val, x, y, "centered", + self.graph._x_labels and self.graph._x_labels[i][0]) + if angle >= 0.3: # 0.3 radians is about 17 degrees + self.graph._static_value(serie_node, val, x, y, metadata) + return rv + + def gauge_background( + self, serie_node, start_angle, center, radius, small_radius, + end_angle, half_pie, max_value): + + if end_angle == 2 * pi: + end_angle = nearly_2pi + + to_shade = [ + coord_abs_project(center, radius, start_angle), + coord_abs_project(center, radius, end_angle), + coord_abs_project(center, small_radius, end_angle), + coord_abs_project(center, small_radius, start_angle)] + + self.node( + serie_node['plot'], 'path', + d='M%s A%s 0 1 1 %s L%s A%s 0 1 0 %s z' % ( + to_shade[0], + coord_dual(radius), + to_shade[1], + to_shade[2], + coord_dual(small_radius), + to_shade[3]), + class_='gauge-background reactive') + + if half_pie: + begin_end = [ + coord_diff( + center, + coord_project( + radius - (radius - small_radius) / 2, start_angle)), + coord_diff( + center, + coord_project( + radius - (radius - small_radius) / 2, end_angle))] + pos = 0 + for i in begin_end: + self.node( + serie_node['plot'], 'text', + class_='y-{} bound reactive'.format(pos), + x=i[0], + y=i[1] + 10, + attrib={'text-anchor': 'middle'} + ).text = '{}'.format(0 if pos == 0 else max_value) + pos += 1 + else: + middle_radius = .5 * (radius + small_radius) + # Correct text vertical alignment + middle_radius -= .1 * (radius - small_radius) + to_labels = [ + coord_abs_project( + center, middle_radius, 0), + coord_abs_project( + center, middle_radius, nearly_2pi) + ] + self.node( + self.defs, 'path', id='valuePath-%s%s' % center, + d='M%s A%s 0 1 1 %s' % ( + to_labels[0], + coord_dual(middle_radius), + to_labels[1] + )) + text_ = self.node( + serie_node['text_overlay'], 'text') + self.node( + text_, 'textPath', class_='max-value reactive', + attrib={ + 'href': '#valuePath-%s%s' % center, + 'startOffset': '99%', + 'text-anchor': 'end' + } + ).text = max_value + + def solid_gauge( + self, serie_node, node, radius, small_radius, + angle, start_angle, center, val, i, metadata, half_pie, end_angle, + max_value): + """Draw a solid gauge slice and background slice""" + if angle == 2 * pi: + angle = nearly_2pi + + if angle > 0: + to = [coord_abs_project(center, radius, start_angle), + coord_abs_project(center, radius, start_angle + angle), + coord_abs_project(center, small_radius, start_angle + angle), + coord_abs_project(center, small_radius, start_angle)] + + self.node( + node, 'path', + d='M%s A%s 0 %d 1 %s L%s A%s 0 %d 0 %s z' % ( + to[0], + coord_dual(radius), + int(angle > pi), + to[1], + to[2], + coord_dual(small_radius), + int(angle > pi), + to[3]), + class_='slice reactive tooltip-trigger') + else: + return + + x, y = coord_diff(center, coord_project( + (radius + small_radius) / 2, start_angle + angle / 2)) + self.graph._static_value(serie_node, val, x, y, metadata, 'middle') + self.graph._tooltip_data( + node, val, x, y, "centered", + self.graph._x_labels and self.graph._x_labels[i][0]) + + def confidence_interval(self, node, x, low, high, width=7): + if self.graph.horizontal: + fmt = lambda xy: '%f %f' % (xy[1], xy[0]) + else: + fmt = coord_format + + shr = lambda xy: (xy[0] + width, xy[1]) + shl = lambda xy: (xy[0] - width, xy[1]) + + top = (x, high) + bottom = (x, low) + + ci = self.node(node, class_="ci") + + self.node( + ci, 'path', d="M%s L%s M%s L%s M%s L%s L%s M%s L%s" % tuple( + map(fmt, ( + top, shr(top), top, shl(top), top, + bottom, shr(bottom), bottom, shl(bottom) + )) + ), class_='nofill reactive' + ) + + def pre_render(self): + """Last things to do before rendering""" + self.add_styles() + self.add_scripts() + self.root.set( + 'viewBox', '0 0 %d %d' % (self.graph.width, self.graph.height)) + if self.graph.explicit_size: + self.root.set('width', str(self.graph.width)) + self.root.set('height', str(self.graph.height)) + + def draw_no_data(self): + """Write the no data text to the svg""" + no_data = self.node(self.graph.nodes['text_overlay'], 'text', + x=self.graph.view.width / 2, + y=self.graph.view.height / 2, + class_='no_data') + no_data.text = self.graph.no_data_text + + def render(self, is_unicode=False, pretty_print=False): + """Last thing to do before rendering""" + for f in self.graph.xml_filters: + self.root = f(self.root) + args = { + 'encoding': 'utf-8' + } + + svg = b'' + if etree.lxml: + args['pretty_print'] = pretty_print + + if not self.graph.disable_xml_declaration: + svg = b"\n" + + if not self.graph.disable_xml_declaration: + svg += b'\n'.join( + [etree.tostring( + pi, **args) + for pi in self.processing_instructions] + ) + + svg += etree.tostring( + self.root, **args) + + if self.graph.disable_xml_declaration or is_unicode: + svg = svg.decode('utf-8') + return svg + + def get_strokes(self): + """Return a css snippet containing all stroke style options""" + def stroke_dict_to_css(stroke, i=None): + """Return a css style for the given option""" + css = ['%s.series%s {\n' % ( + self.id, '.serie-%d' % i if i is not None else '')] + for key in ( + 'width', 'linejoin', 'linecap', + 'dasharray', 'dashoffset'): + if stroke.get(key): + css.append(' stroke-%s: %s;\n' % ( + key, stroke[key])) + css.append('}') + return '\n'.join(css) + + css = [] + if self.graph.stroke_style is not None: + css.append(stroke_dict_to_css(self.graph.stroke_style)) + for serie in self.graph.series: + if serie.stroke_style is not None: + css.append(stroke_dict_to_css(serie.stroke_style, serie.index)) + + for secondary_serie in self.graph.secondary_series: + if secondary_serie.stroke_style is not None: + css.append(stroke_dict_to_css(secondary_serie.stroke_style, secondary_serie.index)) + return '\n'.join(css) diff --git a/pygal/table.py b/pygal/table.py new file mode 100644 index 0000000..5c51a13 --- /dev/null +++ b/pygal/table.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +""" +HTML Table maker. + +This class is used to render an html table from a chart data. +""" + +import uuid + +from lxml.html import builder, tostring + +from pygal.util import template + + +class HTML(object): + + """Lower case adapter of lxml builder""" + + def __getattribute__(self, attr): + """Get the uppercase builder attribute""" + return getattr(builder, attr.upper()) + + +class Table(object): + + """Table generator class""" + + _dual = None + + def __init__(self, chart): + """Init the table""" + self.chart = chart + + def render(self, total=False, transpose=False, style=False): + """Render the HTMTL table of the chart. + + `total` can be specified to include data sums + `transpose` make labels becomes columns + `style` include scoped style for the table + + """ + self.chart.setup() + ln = self.chart._len + html = HTML() + attrs = {} + + if style: + attrs['id'] = 'table-%s' % uuid.uuid4() + + table = [] + + _ = lambda x: x if x is not None else '' + + if self.chart.x_labels: + labels = [None] + list(self.chart.x_labels) + if len(labels) < ln: + labels += [None] * (ln + 1 - len(labels)) + if len(labels) > ln + 1: + labels = labels[:ln + 1] + table.append(labels) + + if total: + if len(table): + table[0].append('Total') + else: + table.append([None] * (ln + 1) + ['Total']) + acc = [0] * (ln + 1) + + for i, serie in enumerate(self.chart.all_series): + row = [serie.title] + if total: + sum_ = 0 + for j, value in enumerate(serie.values): + if total: + v = value or 0 + acc[j] += v + sum_ += v + row.append(self.chart._format(serie, j)) + if total: + acc[-1] += sum_ + row.append(self.chart._serie_format(serie, sum_)) + table.append(row) + + width = ln + 1 + if total: + width += 1 + table.append(['Total']) + for val in acc: + table[-1].append(self.chart._serie_format(serie, val)) + + # Align values + len_ = max([len(r) for r in table] or [0]) + + for i, row in enumerate(table[:]): + len_ = len(row) + if len_ < width: + table[i] = row + [None] * (width - len_) + + if not transpose: + table = list(zip(*table)) + + thead = [] + tbody = [] + tfoot = [] + + if not transpose or self.chart.x_labels: + # There's always series title but not always x_labels + thead = [table[0]] + tbody = table[1:] + else: + tbody = table + + if total: + tfoot = [tbody[-1]] + tbody = tbody[:-1] + + parts = [] + if thead: + parts.append( + html.thead( + *[html.tr( + *[html.th(_(col)) for col in r] + ) for r in thead] + ) + ) + if tbody: + parts.append( + html.tbody( + *[html.tr( + *[html.td(_(col)) for col in r] + ) for r in tbody] + ) + ) + if tfoot: + parts.append( + html.tfoot( + *[html.tr( + *[html.th(_(col)) for col in r] + ) for r in tfoot] + ) + ) + + table = tostring( + html.table( + *parts, **attrs + ) + ) + if style: + if style is True: + css = ''' + #{{ id }} { + border-collapse: collapse; + border-spacing: 0; + empty-cells: show; + border: 1px solid #cbcbcb; + } + #{{ id }} td, #{{ id }} th { + border-left: 1px solid #cbcbcb; + border-width: 0 0 0 1px; + margin: 0; + padding: 0.5em 1em; + } + #{{ id }} td:first-child, #{{ id }} th:first-child { + border-left-width: 0; + } + #{{ id }} thead, #{{ id }} tfoot { + color: #000; + text-align: left; + vertical-align: bottom; + } + #{{ id }} thead { + background: #e0e0e0; + } + #{{ id }} tfoot { + background: #ededed; + } + #{{ id }} tr:nth-child(2n-1) td { + background-color: #f2f2f2; + } + ''' + else: + css = style + table = tostring(html.style( + template(css, **attrs), + scoped='scoped')) + table + table = table.decode('utf-8') + self.chart.teardown() + return table diff --git a/pygal/test/__init__.py b/pygal/test/__init__.py new file mode 100644 index 0000000..1edf035 --- /dev/null +++ b/pygal/test/__init__.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Pygal test package""" + +import pygal +from pygal.util import cut +from pygal.graph.map import BaseMap +from decimal import Decimal + + +def get_data(i): + """Return sample test data for an index""" + return [ + [(-1, 1), (2, 0), (0, 4)], + [(0, 1), (None, 2), (3, 2)], + [(-3, 3), (1, 3), (1, 1)], + [(1, 1), (Decimal('1.'), 1), (1, 1)], + [(3, 2), (2, 1), (1., 1)]][i] + + +def adapt(chart, data): + """Adapt data to chart type""" + if isinstance(chart, pygal.XY): + return data + + data = cut(data) + if isinstance(chart, BaseMap): + return list( + map(lambda x: chart.__class__.x_labels[ + int(x) % len(chart.__class__.x_labels)] + if x is not None else None, data)) + return data + + +def make_data(chart, datas): + """Add sample data to the test chart""" + for i, data in enumerate(datas): + chart.add(data[0], + adapt(chart, data[1]), + secondary=bool(i % 2)) + return chart diff --git a/pygal/test/conftest.py b/pygal/test/conftest.py new file mode 100644 index 0000000..b8c8a59 --- /dev/null +++ b/pygal/test/conftest.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""pytest fixtures""" + +import sys + +import pytest + +import pygal +from pygal.etree import etree + +from . import get_data + + +@pytest.fixture +def etreefx(request): + """Fixture allowing to test with builtin etree and lxml""" + if request.param == 'etree': + etree.to_etree() + if request.param == 'lxml': + etree.to_lxml() + + +def pytest_generate_tests(metafunc): + """Generate the tests for etree and lxml""" + if etree._lxml_etree: + metafunc.fixturenames.append('etreefx') + metafunc.parametrize('etreefx', ['lxml', 'etree'], indirect=True) + + if not hasattr(sys, 'pypy_version_info'): + etree.to_lxml() + + if hasattr(sys, 'pypy_version_info'): + etree.to_etree() + + if "Chart" in metafunc.funcargnames: + metafunc.parametrize("Chart", pygal.CHARTS) + if "datas" in metafunc.funcargnames: + metafunc.parametrize( + "datas", + [ + [("Serie %d" % i, get_data(i)) for i in range(s)] + for s in (5, 1, 0) + ]) diff --git a/pygal/test/test_bar.py b/pygal/test/test_bar.py new file mode 100644 index 0000000..631990b --- /dev/null +++ b/pygal/test/test_bar.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Bar chart related tests""" + +from pygal import Bar + + +def test_simple_bar(): + """Simple bar test""" + bar = Bar() + rng = [-3, -32, -39] + bar.add('test1', rng) + bar.add('test2', map(abs, rng)) + bar.x_labels = map(str, rng) + bar.title = "Bar test" + q = bar.render_pyquery() + assert len(q(".axis.x")) == 1 + assert len(q(".axis.y")) == 1 + assert len(q(".legend")) == 2 + assert len(q(".plot .series rect")) == 2 * 3 diff --git a/pygal/test/test_box.py b/pygal/test/test_box.py new file mode 100644 index 0000000..48b42f5 --- /dev/null +++ b/pygal/test/test_box.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 chart related tests""" + +from pygal.graph.box import Box + + +def test_quartiles(): + """Test box points for the 1.5IQR computation method""" + a = [-2.0, 3.0, 4.0, 5.0, 8.0] # odd test data + (min_s, q0, q1, q2, q3, q4, max_s), outliers = Box._box_points( + a, mode='1.5IQR') + + assert q1 == 7.0 / 4.0 + assert q2 == 4.0 + assert q3 == 23 / 4.0 + assert q0 == 7.0 / 4.0 - 6.0 # q1 - 1.5 * iqr + assert q4 == 23 / 4.0 + 6.0 # q3 + 1.5 * iqr + + b = [1.0, 4.0, 6.0, 8.0] # even test data + (min_s, q0, q1, q2, q3, q4, max_s), outliers = Box._box_points( + b, mode='1.5IQR') + + assert q2 == 5.0 + + c = [2.0, None, 4.0, 6.0, None] # odd with None elements + (min_s, q0, q1, q2, q3, q4, max_s), outliers = Box._box_points( + c, mode='1.5IQR') + + assert q2 == 4.0 + + d = [4] + (min_s, q0, q1, q2, q3, q4, max_s), outliers = Box._box_points( + d, mode='1.5IQR') + + assert q0 == 4 + assert q1 == 4 + assert q2 == 4 + assert q3 == 4 + assert q4 == 4 + + +def test_quartiles_min_extremes(): + """Test box points for the extremes computation method""" + a = [-2.0, 3.0, 4.0, 5.0, 8.0] # odd test data + (min_s, q0, q1, q2, q3, q4, max_s), outliers = Box._box_points( + a, mode='extremes') + + assert q1 == 7.0 / 4.0 + assert q2 == 4.0 + assert q3 == 23 / 4.0 + assert q0 == -2.0 # min + assert q4 == 8.0 # max + + b = [1.0, 4.0, 6.0, 8.0] # even test data + (min_s, q0, q1, q2, q3, q4, max_s), outliers = Box._box_points( + b, mode='extremes') + + assert q2 == 5.0 + + c = [2.0, None, 4.0, 6.0, None] # odd with None elements + (min_s, q0, q1, q2, q3, q4, max_s), outliers = Box._box_points( + c, mode='extremes') + + assert q2 == 4.0 + + d = [4] + (min_s, q0, q1, q2, q3, q4, max_s), outliers = Box._box_points( + d, mode='extremes') + + assert q0 == 4 + assert q1 == 4 + assert q2 == 4 + assert q3 == 4 + assert q4 == 4 + + +def test_quartiles_tukey(): + """Test box points for the tukey computation method""" + a = [] # empty data + (min_s, q0, q1, q2, q3, q4, max_s), outliers = Box._box_points( + a, mode='tukey') + assert min_s == q0 == q1 == q2 == q3 == q4 == 0 + assert outliers == [] + + # https://en.wikipedia.org/wiki/Quartile example 1 + b = [6, 7, 15, 36, 39, 40, 41, 42, 43, 47, 49] + (min_s, q0, q1, q2, q3, q4, max_s), outliers = Box._box_points( + b, mode='tukey') + assert min_s == q0 == 6 + assert q1 == 20.25 + assert q2 == 40 + assert q3 == 42.75 + assert max_s == q4 == 49 + assert outliers == [] + + # previous test with added outlier 75 + c = [6, 7, 15, 36, 39, 40, 41, 42, 43, 47, 49, 75] + (min_s, q0, q1, q2, q3, q4, max_s), outliers = Box._box_points( + c, mode='tukey') + assert min_s == q0 == 6 + assert q1 == 25.5 + assert q2 == (40 + 41) / 2.0 + assert q3 == 45 + assert max_s == 75 + assert outliers == [75] + + # one more outlier, 77 + c = [6, 7, 15, 36, 39, 40, 41, 42, 43, 47, 49, 75, 77] + (min_s, q0, q1, q2, q3, q4, max_s), outliers = Box._box_points( + c, mode='tukey') + assert min_s == q0 == 6 + assert q1 == 30.75 + assert q2 == 41 + assert q3 == 47.5 + assert max_s == 77 + assert 75 in outliers + assert 77 in outliers + + +def test_quartiles_stdev(): + """Test box points for the stdev computation method""" + a = [35, 42, 35, 41, 36, 6, 12, 51, 33, 27, 46, 36, 44, 53, 75, 46, 16, + 51, 45, 29, 25, 26, 54, 61, 27, 40, 23, 34, 51, 37] + SD = 14.67 + (min_s, q0, q1, q2, q3, q4, max_s), outliers = Box._box_points( + a, mode='stdev') + assert min_s == min(a) + assert max_s == max(a) + assert q2 == 36.5 + assert q4 <= q2 + SD + assert q0 >= q2 - SD + assert all(n in outliers for n in [6, 12, 16, 53, 54, 61, 75]) + + b = [5] # test for posible zero division + (min_s, q0, q1, q2, q3, q4, max_s), outliers = Box._box_points( + b, mode='stdev') + assert min_s == q0 == q1 == q2 == q3 == q4 == max_s == b[0] + assert outliers == [] + + +def test_simple_box(): + """Simple box test""" + box = Box() + box.add('test1', [-1, 2, 3, 3.1, 3.2, 4, 5]) + box.add('test2', [2, 3, 5, 6, 6, 4]) + box.title = 'Box test' + q = box.render_pyquery() + + assert len(q(".axis.y")) == 1 + assert len(q(".legend")) == 2 + assert len(q(".plot .series rect")) == 2 diff --git a/pygal/test/test_colors.py b/pygal/test/test_colors.py new file mode 100644 index 0000000..46af59c --- /dev/null +++ b/pygal/test/test_colors.py @@ -0,0 +1,546 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Color utility functions tests""" + +from __future__ import division + +from pygal.colors import ( + darken, desaturate, hsl_to_rgb, lighten, parse_color, rgb_to_hsl, rotate, + saturate, unparse_color) + + +def test_parse_color(): + """Test color parse function""" + assert parse_color('#123') == (17, 34, 51, 1., '#rgb') + assert parse_color('#cdf') == (204, 221, 255, 1., '#rgb') + assert parse_color('#a3d7') == (170, 51, 221, 119 / 255, '#rgba') + assert parse_color('#584b4f') == (88, 75, 79, 1., '#rrggbb') + assert parse_color('#8cbe22') == (140, 190, 34, 1., '#rrggbb') + assert parse_color('#16cbf055') == (22, 203, 240, 1 / 3, '#rrggbbaa') + assert parse_color('rgb(134, 67, 216)') == (134, 67, 216, 1., 'rgb') + assert parse_color('rgb(0, 111, 222)') == (0, 111, 222, 1., 'rgb') + assert parse_color('rgba(237, 83, 48, .8)') == (237, 83, 48, .8, 'rgba') + assert parse_color('rgba(0, 1, 0, 0.1223)') == (0, 1, 0, .1223, 'rgba') + + +def test_unparse_color(): + """Test color unparse function""" + assert unparse_color(17, 34, 51, 1., '#rgb') == '#123' + assert unparse_color(204, 221, 255, 1., '#rgb') == '#cdf' + assert unparse_color(170, 51, 221, 119 / 255, '#rgba') == '#a3d7' + assert unparse_color(88, 75, 79, 1., '#rrggbb') == '#584b4f' + assert unparse_color(140, 190, 34, 1., '#rrggbb') == '#8cbe22' + assert unparse_color(22, 203, 240, 1 / 3, '#rrggbbaa') == '#16cbf055' + assert unparse_color(134, 67, 216, 1., 'rgb') == 'rgb(134, 67, 216)' + assert unparse_color(0, 111, 222, 1., 'rgb') == 'rgb(0, 111, 222)' + assert unparse_color(237, 83, 48, .8, 'rgba') == 'rgba(237, 83, 48, 0.8)' + assert unparse_color(0, 1, 0, .1223, 'rgba') == 'rgba(0, 1, 0, 0.1223)' + + +def test_darken(): + """Test darken color function""" + assert darken('#800', 20) == '#200' + assert darken('#800e', 20) == '#200e' + assert darken('#800', 0) == '#800' + assert darken('#ffffff', 10) == '#e6e6e6' + assert darken('#000000', 10) == '#000000' + assert darken('#f3148a', 25) == '#810747' + assert darken('#f3148aab', 25) == '#810747ab' + assert darken('#121212', 1) == '#0f0f0f' + assert darken('#999999', 100) == '#000000' + assert darken('#99999999', 100) == '#00000099' + assert darken('#1479ac', 8) == '#105f87' + assert darken('rgb(136, 0, 0)', 20) == 'rgb(34, 0, 0)' + assert darken('rgba(20, 121, 172, .13)', 8) == 'rgba(16, 95, 135, 0.13)' + + +def test_lighten(): + """Test lighten color function""" + assert lighten('#800', 20) == '#e00' + assert lighten('#800', 0) == '#800' + assert lighten('#ffffff', 10) == '#ffffff' + assert lighten('#000000', 10) == '#1a1a1a' + assert lighten('#f3148a', 25) == '#f98dc6' + assert lighten('#121212', 1) == '#151515' + assert lighten('#999999', 100) == '#ffffff' + assert lighten('#1479ac', 8) == '#1893d1' + + +def test_saturate(): + """Test color saturation function""" + assert saturate('#000', 20) == '#000' + assert saturate('#fff', 20) == '#fff' + assert saturate('#8a8', 100) == '#3f3' + assert saturate('#855', 20) == '#9e3f3f' + + +def test_desaturate(): + """Test color desaturation function""" + assert desaturate('#000', 20) == '#000' + assert desaturate('#fff', 20) == '#fff' + assert desaturate('#8a8', 100) == '#999' + assert desaturate('#855', 20) == '#726b6b' + + +def test_rotate(): + """Test color rotation function""" + assert rotate('#000', 45) == '#000' + assert rotate('#fff', 45) == '#fff' + assert rotate('#811', 45) == '#886a11' + assert rotate('#8a8', 360) == '#8a8' + assert rotate('#8a8', 0) == '#8a8' + assert rotate('#8a8', -360) == '#8a8' + + +def test_hsl_to_rgb_part_0(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(0, 100, 50) == (255, 0, 0) + assert hsl_to_rgb(60, 100, 50) == (255, 255, 0) + assert hsl_to_rgb(120, 100, 50) == (0, 255, 0) + assert hsl_to_rgb(180, 100, 50) == (0, 255, 255) + assert hsl_to_rgb(240, 100, 50) == (0, 0, 255) + assert hsl_to_rgb(300, 100, 50) == (255, 0, 255) + + +def test_rgb_to_hsl_part_0(): + """Test rgb to hsl color function""" + assert rgb_to_hsl(255, 0, 0) == (0, 100, 50) + assert rgb_to_hsl(255, 255, 0) == (60, 100, 50) + assert rgb_to_hsl(0, 255, 0) == (120, 100, 50) + assert rgb_to_hsl(0, 255, 255) == (180, 100, 50) + assert rgb_to_hsl(0, 0, 255) == (240, 100, 50) + assert rgb_to_hsl(255, 0, 255) == (300, 100, 50) + + +def test_hsl_to_rgb_part_1(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(-360, 100, 50) == (255, 0, 0) + assert hsl_to_rgb(-300, 100, 50) == (255, 255, 0) + assert hsl_to_rgb(-240, 100, 50) == (0, 255, 0) + assert hsl_to_rgb(-180, 100, 50) == (0, 255, 255) + assert hsl_to_rgb(-120, 100, 50) == (0, 0, 255) + assert hsl_to_rgb(-60, 100, 50) == (255, 0, 255) + + +def test_rgb_to_hsl_part_1(): + """Test rgb to hsl color function""" + # assert rgb_to_hsl(255, 0, 0) == (-360, 100, 50) + # assert rgb_to_hsl(255, 255, 0) == (-300, 100, 50) + # assert rgb_to_hsl(0, 255, 0) == (-240, 100, 50) + # assert rgb_to_hsl(0, 255, 255) == (-180, 100, 50) + # assert rgb_to_hsl(0, 0, 255) == (-120, 100, 50) + # assert rgb_to_hsl(255, 0, 255) == (-60, 100, 50) + pass + + +def test_hsl_to_rgb_part_2(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(360, 100, 50) == (255, 0, 0) + assert hsl_to_rgb(420, 100, 50) == (255, 255, 0) + assert hsl_to_rgb(480, 100, 50) == (0, 255, 0) + assert hsl_to_rgb(540, 100, 50) == (0, 255, 255) + assert hsl_to_rgb(600, 100, 50) == (0, 0, 255) + assert hsl_to_rgb(660, 100, 50) == (255, 0, 255) + + +def test_rgb_to_hsl_part_2(): + """Test rgb to hsl color function""" + # assert rgb_to_hsl(255, 0, 0) == (360, 100, 50) + # assert rgb_to_hsl(255, 255, 0) == (420, 100, 50) + # assert rgb_to_hsl(0, 255, 0) == (480, 100, 50) + # assert rgb_to_hsl(0, 255, 255) == (540, 100, 50) + # assert rgb_to_hsl(0, 0, 255) == (600, 100, 50) + # assert rgb_to_hsl(255, 0, 255) == (660, 100, 50) + pass + + +def test_hsl_to_rgb_part_3(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(6120, 100, 50) == (255, 0, 0) + assert hsl_to_rgb(-9660, 100, 50) == (255, 255, 0) + assert hsl_to_rgb(99840, 100, 50) == (0, 255, 0) + assert hsl_to_rgb(-900, 100, 50) == (0, 255, 255) + assert hsl_to_rgb(-104880, 100, 50) == (0, 0, 255) + assert hsl_to_rgb(2820, 100, 50) == (255, 0, 255) + + +def test_rgb_to_hsl_part_3(): + """Test rgb to hsl color function""" + # assert rgb_to_hsl(255, 0, 0) == (6120, 100, 50) + # assert rgb_to_hsl(255, 255, 0) == (-9660, 100, 50) + # assert rgb_to_hsl(0, 255, 0) == (99840, 100, 50) + # assert rgb_to_hsl(0, 255, 255) == (-900, 100, 50) + # assert rgb_to_hsl(0, 0, 255) == (-104880, 100, 50) + # assert rgb_to_hsl(255, 0, 255) == (2820, 100, 50) + pass + + +def test_hsl_to_rgb_part_4(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(0, 100, 50) == (255, 0, 0) + assert hsl_to_rgb(12, 100, 50) == (255, 51, 0) + assert hsl_to_rgb(24, 100, 50) == (255, 102, 0) + assert hsl_to_rgb(36, 100, 50) == (255, 153, 0) + assert hsl_to_rgb(48, 100, 50) == (255, 204, 0) + assert hsl_to_rgb(60, 100, 50) == (255, 255, 0) + assert hsl_to_rgb(72, 100, 50) == (204, 255, 0) + assert hsl_to_rgb(84, 100, 50) == (153, 255, 0) + assert hsl_to_rgb(96, 100, 50) == (102, 255, 0) + assert hsl_to_rgb(108, 100, 50) == (51, 255, 0) + assert hsl_to_rgb(120, 100, 50) == (0, 255, 0) + + +def test_rgb_to_hsl_part_4(): + """Test rgb to hsl color function""" + assert rgb_to_hsl(255, 0, 0) == (0, 100, 50) + assert rgb_to_hsl(255, 51, 0) == (12, 100, 50) + assert rgb_to_hsl(255, 102, 0) == (24, 100, 50) + assert rgb_to_hsl(255, 153, 0) == (36, 100, 50) + assert rgb_to_hsl(255, 204, 0) == (48, 100, 50) + assert rgb_to_hsl(255, 255, 0) == (60, 100, 50) + assert rgb_to_hsl(204, 255, 0) == (72, 100, 50) + assert rgb_to_hsl(153, 255, 0) == (84, 100, 50) + assert rgb_to_hsl(102, 255, 0) == (96, 100, 50) + assert rgb_to_hsl(51, 255, 0) == (108, 100, 50) + assert rgb_to_hsl(0, 255, 0) == (120, 100, 50) + + +def test_hsl_to_rgb_part_5(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(120, 100, 50) == (0, 255, 0) + assert hsl_to_rgb(132, 100, 50) == (0, 255, 51) + assert hsl_to_rgb(144, 100, 50) == (0, 255, 102) + assert hsl_to_rgb(156, 100, 50) == (0, 255, 153) + assert hsl_to_rgb(168, 100, 50) == (0, 255, 204) + assert hsl_to_rgb(180, 100, 50) == (0, 255, 255) + assert hsl_to_rgb(192, 100, 50) == (0, 204, 255) + assert hsl_to_rgb(204, 100, 50) == (0, 153, 255) + assert hsl_to_rgb(216, 100, 50) == (0, 102, 255) + assert hsl_to_rgb(228, 100, 50) == (0, 51, 255) + assert hsl_to_rgb(240, 100, 50) == (0, 0, 255) + + +def test_rgb_to_hsl_part_5(): + """Test rgb to hsl color function""" + assert rgb_to_hsl(0, 255, 0) == (120, 100, 50) + assert rgb_to_hsl(0, 255, 51) == (132, 100, 50) + assert rgb_to_hsl(0, 255, 102) == (144, 100, 50) + assert rgb_to_hsl(0, 255, 153) == (156, 100, 50) + assert rgb_to_hsl(0, 255, 204) == (168, 100, 50) + assert rgb_to_hsl(0, 255, 255) == (180, 100, 50) + assert rgb_to_hsl(0, 204, 255) == (192, 100, 50) + assert rgb_to_hsl(0, 153, 255) == (204, 100, 50) + assert rgb_to_hsl(0, 102, 255) == (216, 100, 50) + assert rgb_to_hsl(0, 51, 255) == (228, 100, 50) + assert rgb_to_hsl(0, 0, 255) == (240, 100, 50) + + +def test_hsl_to_rgb_part_6(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(240, 100, 50) == (0, 0, 255) + assert hsl_to_rgb(252, 100, 50) == (51, 0, 255) + assert hsl_to_rgb(264, 100, 50) == (102, 0, 255) + assert hsl_to_rgb(276, 100, 50) == (153, 0, 255) + assert hsl_to_rgb(288, 100, 50) == (204, 0, 255) + assert hsl_to_rgb(300, 100, 50) == (255, 0, 255) + assert hsl_to_rgb(312, 100, 50) == (255, 0, 204) + assert hsl_to_rgb(324, 100, 50) == (255, 0, 153) + assert hsl_to_rgb(336, 100, 50) == (255, 0, 102) + assert hsl_to_rgb(348, 100, 50) == (255, 0, 51) + assert hsl_to_rgb(360, 100, 50) == (255, 0, 0) + + +def test_rgb_to_hsl_part_6(): + """Test rgb to hsl color function""" + assert rgb_to_hsl(0, 0, 255) == (240, 100, 50) + assert rgb_to_hsl(51, 0, 255) == (252, 100, 50) + assert rgb_to_hsl(102, 0, 255) == (264, 100, 50) + assert rgb_to_hsl(153, 0, 255) == (276, 100, 50) + assert rgb_to_hsl(204, 0, 255) == (288, 100, 50) + assert rgb_to_hsl(255, 0, 255) == (300, 100, 50) + assert rgb_to_hsl(255, 0, 204) == (312, 100, 50) + assert rgb_to_hsl(255, 0, 153) == (324, 100, 50) + assert rgb_to_hsl(255, 0, 102) == (336, 100, 50) + assert rgb_to_hsl(255, 0, 51) == (348, 100, 50) + # assert rgb_to_hsl(255, 0, 0) == (360, 100, 50) + + +def test_hsl_to_rgb_part_7(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(0, 20, 50) == (153, 102, 102) + assert hsl_to_rgb(0, 60, 50) == (204, 51, 51) + assert hsl_to_rgb(0, 100, 50) == (255, 0, 0) + + +def test_rgb_to_hsl_part_7(): + """Test rgb to hsl color function""" + assert rgb_to_hsl(153, 102, 102) == (0, 20, 50) + assert rgb_to_hsl(204, 51, 51) == (0, 60, 50) + assert rgb_to_hsl(255, 0, 0) == (0, 100, 50) + + +def test_hsl_to_rgb_part_8(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(60, 20, 50) == (153, 153, 102) + assert hsl_to_rgb(60, 60, 50) == (204, 204, 51) + assert hsl_to_rgb(60, 100, 50) == (255, 255, 0) + + +def test_rgb_to_hsl_part_8(): + """Test rgb to hsl color function""" + assert rgb_to_hsl(153, 153, 102) == (60, 20, 50) + assert rgb_to_hsl(204, 204, 51) == (60, 60, 50) + assert rgb_to_hsl(255, 255, 0) == (60, 100, 50) + + +def test_hsl_to_rgb_part_9(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(120, 20, 50) == (102, 153, 102) + assert hsl_to_rgb(120, 60, 50) == (51, 204, 51) + assert hsl_to_rgb(120, 100, 50) == (0, 255, 0) + + +def test_rgb_to_hsl_part_9(): + """Test rgb to hsl color function""" + assert rgb_to_hsl(102, 153, 102) == (120, 20, 50) + assert rgb_to_hsl(51, 204, 51) == (120, 60, 50) + assert rgb_to_hsl(0, 255, 0) == (120, 100, 50) + + +def test_hsl_to_rgb_part_10(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(180, 20, 50) == (102, 153, 153) + assert hsl_to_rgb(180, 60, 50) == (51, 204, 204) + assert hsl_to_rgb(180, 100, 50) == (0, 255, 255) + + +def test_rgb_to_hsl_part_10(): + """Test rgb to hsl color function""" + assert rgb_to_hsl(102, 153, 153) == (180, 20, 50) + assert rgb_to_hsl(51, 204, 204) == (180, 60, 50) + assert rgb_to_hsl(0, 255, 255) == (180, 100, 50) + + +def test_hsl_to_rgb_part_11(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(240, 20, 50) == (102, 102, 153) + assert hsl_to_rgb(240, 60, 50) == (51, 51, 204) + assert hsl_to_rgb(240, 100, 50) == (0, 0, 255) + + +def test_rgb_to_hsl_part_11(): + """Test rgb to hsl color function""" + assert rgb_to_hsl(102, 102, 153) == (240, 20, 50) + assert rgb_to_hsl(51, 51, 204) == (240, 60, 50) + assert rgb_to_hsl(0, 0, 255) == (240, 100, 50) + + +def test_hsl_to_rgb_part_12(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(300, 20, 50) == (153, 102, 153) + assert hsl_to_rgb(300, 60, 50) == (204, 51, 204) + assert hsl_to_rgb(300, 100, 50) == (255, 0, 255) + + +def test_rgb_to_hsl_part_12(): + """Test rgb to hsl color function""" + assert rgb_to_hsl(153, 102, 153) == (300, 20, 50) + assert rgb_to_hsl(204, 51, 204) == (300, 60, 50) + assert rgb_to_hsl(255, 0, 255) == (300, 100, 50) + + +def test_hsl_to_rgb_part_13(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(0, 100, 0) == (0, 0, 0) + assert hsl_to_rgb(0, 100, 10) == (51, 0, 0) + assert hsl_to_rgb(0, 100, 20) == (102, 0, 0) + assert hsl_to_rgb(0, 100, 30) == (153, 0, 0) + assert hsl_to_rgb(0, 100, 40) == (204, 0, 0) + assert hsl_to_rgb(0, 100, 50) == (255, 0, 0) + assert hsl_to_rgb(0, 100, 60) == (255, 51, 51) + assert hsl_to_rgb(0, 100, 70) == (255, 102, 102) + assert hsl_to_rgb(0, 100, 80) == (255, 153, 153) + assert hsl_to_rgb(0, 100, 90) == (255, 204, 204) + assert hsl_to_rgb(0, 100, 100) == (255, 255, 255) + + +def test_rgb_to_hsl_part_13(): + """Test rgb to hsl color function""" + assert rgb_to_hsl(0, 0, 0) == (0, 0, 0) + assert rgb_to_hsl(51, 0, 0) == (0, 100, 10) + assert rgb_to_hsl(102, 0, 0) == (0, 100, 20) + assert rgb_to_hsl(153, 0, 0) == (0, 100, 30) + assert rgb_to_hsl(204, 0, 0) == (0, 100, 40) + assert rgb_to_hsl(255, 0, 0) == (0, 100, 50) + assert rgb_to_hsl(255, 51, 51) == (0, 100, 60) + assert rgb_to_hsl(255, 102, 102) == (0, 100, 70) + assert rgb_to_hsl(255, 153, 153) == (0, 100, 80) + assert rgb_to_hsl(255, 204, 204) == (0, 100, 90) + assert rgb_to_hsl(255, 255, 255) == (0, 0, 100) + + +def test_hsl_to_rgb_part_14(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(60, 100, 0) == (0, 0, 0) + assert hsl_to_rgb(60, 100, 10) == (51, 51, 0) + assert hsl_to_rgb(60, 100, 20) == (102, 102, 0) + assert hsl_to_rgb(60, 100, 30) == (153, 153, 0) + assert hsl_to_rgb(60, 100, 40) == (204, 204, 0) + assert hsl_to_rgb(60, 100, 50) == (255, 255, 0) + assert hsl_to_rgb(60, 100, 60) == (255, 255, 51) + assert hsl_to_rgb(60, 100, 70) == (255, 255, 102) + assert hsl_to_rgb(60, 100, 80) == (255, 255, 153) + assert hsl_to_rgb(60, 100, 90) == (255, 255, 204) + assert hsl_to_rgb(60, 100, 100) == (255, 255, 255) + + +def test_rgb_to_hsl_part_14(): + """Test rgb to hsl color function""" + # assert rgb_to_hsl(0, 0, 0) == (60, 100, 0) + assert rgb_to_hsl(51, 51, 0) == (60, 100, 10) + assert rgb_to_hsl(102, 102, 0) == (60, 100, 20) + assert rgb_to_hsl(153, 153, 0) == (60, 100, 30) + assert rgb_to_hsl(204, 204, 0) == (60, 100, 40) + assert rgb_to_hsl(255, 255, 0) == (60, 100, 50) + assert rgb_to_hsl(255, 255, 51) == (60, 100, 60) + assert rgb_to_hsl(255, 255, 102) == (60, 100, 70) + assert rgb_to_hsl(255, 255, 153) == (60, 100, 80) + assert rgb_to_hsl(255, 255, 204) == (60, 100, 90) + # assert rgb_to_hsl(255, 255, 255) == (60, 100, 100) + + +def test_hsl_to_rgb_part_15(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(120, 100, 0) == (0, 0, 0) + assert hsl_to_rgb(120, 100, 10) == (0, 51, 0) + assert hsl_to_rgb(120, 100, 20) == (0, 102, 0) + assert hsl_to_rgb(120, 100, 30) == (0, 153, 0) + assert hsl_to_rgb(120, 100, 40) == (0, 204, 0) + assert hsl_to_rgb(120, 100, 50) == (0, 255, 0) + assert hsl_to_rgb(120, 100, 60) == (51, 255, 51) + assert hsl_to_rgb(120, 100, 70) == (102, 255, 102) + assert hsl_to_rgb(120, 100, 80) == (153, 255, 153) + assert hsl_to_rgb(120, 100, 90) == (204, 255, 204) + assert hsl_to_rgb(120, 100, 100) == (255, 255, 255) + + +def test_rgb_to_hsl_part_15(): + """Test rgb to hsl color function""" + # assert rgb_to_hsl(0, 0, 0) == (120, 100, 0) + assert rgb_to_hsl(0, 51, 0) == (120, 100, 10) + assert rgb_to_hsl(0, 102, 0) == (120, 100, 20) + assert rgb_to_hsl(0, 153, 0) == (120, 100, 30) + assert rgb_to_hsl(0, 204, 0) == (120, 100, 40) + assert rgb_to_hsl(0, 255, 0) == (120, 100, 50) + assert rgb_to_hsl(51, 255, 51) == (120, 100, 60) + assert rgb_to_hsl(102, 255, 102) == (120, 100, 70) + assert rgb_to_hsl(153, 255, 153) == (120, 100, 80) + assert rgb_to_hsl(204, 255, 204) == (120, 100, 90) + # assert rgb_to_hsl(255, 255, 255) == (120, 100, 100) + + +def test_hsl_to_rgb_part_16(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(180, 100, 0) == (0, 0, 0) + assert hsl_to_rgb(180, 100, 10) == (0, 51, 51) + assert hsl_to_rgb(180, 100, 20) == (0, 102, 102) + assert hsl_to_rgb(180, 100, 30) == (0, 153, 153) + assert hsl_to_rgb(180, 100, 40) == (0, 204, 204) + assert hsl_to_rgb(180, 100, 50) == (0, 255, 255) + assert hsl_to_rgb(180, 100, 60) == (51, 255, 255) + assert hsl_to_rgb(180, 100, 70) == (102, 255, 255) + assert hsl_to_rgb(180, 100, 80) == (153, 255, 255) + assert hsl_to_rgb(180, 100, 90) == (204, 255, 255) + assert hsl_to_rgb(180, 100, 100) == (255, 255, 255) + + +def test_rgb_to_hsl_part_16(): + """Test rgb to hsl color function""" + # assert rgb_to_hsl(0, 0, 0) == (180, 100, 0) + assert rgb_to_hsl(0, 51, 51) == (180, 100, 10) + assert rgb_to_hsl(0, 102, 102) == (180, 100, 20) + assert rgb_to_hsl(0, 153, 153) == (180, 100, 30) + assert rgb_to_hsl(0, 204, 204) == (180, 100, 40) + assert rgb_to_hsl(0, 255, 255) == (180, 100, 50) + assert rgb_to_hsl(51, 255, 255) == (180, 100, 60) + assert rgb_to_hsl(102, 255, 255) == (180, 100, 70) + assert rgb_to_hsl(153, 255, 255) == (180, 100, 80) + assert rgb_to_hsl(204, 255, 255) == (180, 100, 90) + # assert rgb_to_hsl(255, 255, 255) == (180, 100, 100) + + +def test_hsl_to_rgb_part_17(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(240, 100, 0) == (0, 0, 0) + assert hsl_to_rgb(240, 100, 10) == (0, 0, 51) + assert hsl_to_rgb(240, 100, 20) == (0, 0, 102) + assert hsl_to_rgb(240, 100, 30) == (0, 0, 153) + assert hsl_to_rgb(240, 100, 40) == (0, 0, 204) + assert hsl_to_rgb(240, 100, 50) == (0, 0, 255) + assert hsl_to_rgb(240, 100, 60) == (51, 51, 255) + assert hsl_to_rgb(240, 100, 70) == (102, 102, 255) + assert hsl_to_rgb(240, 100, 80) == (153, 153, 255) + assert hsl_to_rgb(240, 100, 90) == (204, 204, 255) + assert hsl_to_rgb(240, 100, 100) == (255, 255, 255) + + +def test_rgb_to_hsl_part_17(): + """Test rgb to hsl color function""" + # assert rgb_to_hsl(0, 0, 0) == (240, 100, 0) + assert rgb_to_hsl(0, 0, 51) == (240, 100, 10) + assert rgb_to_hsl(0, 0, 102) == (240, 100, 20) + assert rgb_to_hsl(0, 0, 153) == (240, 100, 30) + assert rgb_to_hsl(0, 0, 204) == (240, 100, 40) + assert rgb_to_hsl(0, 0, 255) == (240, 100, 50) + assert rgb_to_hsl(51, 51, 255) == (240, 100, 60) + assert rgb_to_hsl(102, 102, 255) == (240, 100, 70) + assert rgb_to_hsl(153, 153, 255) == (240, 100, 80) + assert rgb_to_hsl(204, 204, 255) == (240, 100, 90) + # assert rgb_to_hsl(255, 255, 255) == (240, 100, 100) + + +def test_hsl_to_rgb_part_18(): + """Test hsl to rgb color function""" + assert hsl_to_rgb(300, 100, 0) == (0, 0, 0) + assert hsl_to_rgb(300, 100, 10) == (51, 0, 51) + assert hsl_to_rgb(300, 100, 20) == (102, 0, 102) + assert hsl_to_rgb(300, 100, 30) == (153, 0, 153) + assert hsl_to_rgb(300, 100, 40) == (204, 0, 204) + assert hsl_to_rgb(300, 100, 50) == (255, 0, 255) + assert hsl_to_rgb(300, 100, 60) == (255, 51, 255) + assert hsl_to_rgb(300, 100, 70) == (255, 102, 255) + assert hsl_to_rgb(300, 100, 80) == (255, 153, 255) + assert hsl_to_rgb(300, 100, 90) == (255, 204, 255) + assert hsl_to_rgb(300, 100, 100) == (255, 255, 255) + + +def test_rgb_to_hsl_part_18(): + """Test rgb to hsl color function""" + # assert rgb_to_hsl(0, 0, 0) == (300, 100, 0) + assert rgb_to_hsl(51, 0, 51) == (300, 100, 10) + assert rgb_to_hsl(102, 0, 102) == (300, 100, 20) + assert rgb_to_hsl(153, 0, 153) == (300, 100, 30) + assert rgb_to_hsl(204, 0, 204) == (300, 100, 40) + assert rgb_to_hsl(255, 0, 255) == (300, 100, 50) + assert rgb_to_hsl(255, 51, 255) == (300, 100, 60) + assert rgb_to_hsl(255, 102, 255) == (300, 100, 70) + assert rgb_to_hsl(255, 153, 255) == (300, 100, 80) + assert rgb_to_hsl(255, 204, 255) == (300, 100, 90) + # assert rgb_to_hsl(255, 255, 255) == (300, 100, 100) diff --git a/pygal/test/test_config.py b/pygal/test/test_config.py new file mode 100644 index 0000000..ec0e587 --- /dev/null +++ b/pygal/test/test_config.py @@ -0,0 +1,573 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Various config options tested on one chart type or more""" + +from tempfile import NamedTemporaryFile + +from pygal import ( + XY, Bar, Box, Config, DateLine, DateTimeLine, Dot, Funnel, Gauge, + Histogram, HorizontalBar, HorizontalLine, HorizontalStackedBar, + HorizontalStackedLine, Line, Pie, Pyramid, Radar, SolidGauge, + TimeDeltaLine, TimeLine, Treemap, formatters) +from pygal._compat import _ellipsis, u +from pygal.graph.dual import Dual +from pygal.graph.horizontal import HorizontalGraph +from pygal.graph.map import BaseMap +from pygal.test.utils import texts + + +def test_config_behaviours(): + """Test that all different way to set config produce same results""" + line1 = Line() + line1.show_legend = False + line1.fill = True + line1.pretty_print = True + line1.no_prefix = True + line1.x_labels = ['a', 'b', 'c'] + line1.add('_', [1, 2, 3]) + l1 = line1.render() + + q = line1.render_pyquery() + assert len(q(".axis.x")) == 1 + assert len(q(".axis.y")) == 1 + assert len(q(".plot .series path")) == 1 + assert len(q(".legend")) == 0 + assert len(q(".x.axis .guides")) == 3 + assert len(q(".y.axis .guides")) == 11 + assert len(q(".dots")) == 3 + assert q(".axis.x text").map(texts) == ['a', 'b', 'c'] + + line2 = Line( + show_legend=False, + fill=True, + pretty_print=True, + no_prefix=True, + x_labels=['a', 'b', 'c']) + line2.add('_', [1, 2, 3]) + l2 = line2.render() + assert l1 == l2 + + class LineConfig(Config): + show_legend = False + fill = True + pretty_print = True + no_prefix = True + x_labels = ['a', 'b', 'c'] + + line3 = Line(LineConfig) + line3.add('_', [1, 2, 3]) + l3 = line3.render() + assert l1 == l3 + + line4 = Line(LineConfig()) + line4.add('_', [1, 2, 3]) + l4 = line4.render() + assert l1 == l4 + + line_config = Config() + line_config.show_legend = False + line_config.fill = True + line_config.pretty_print = True + line_config.no_prefix = True + line_config.x_labels = ['a', 'b', 'c'] + + line5 = Line(line_config) + line5.add('_', [1, 2, 3]) + l5 = line5.render() + assert l1 == l5 + + l6 = Line(line_config)(1, 2, 3, title='_').render() + assert l1 == l6 + + +def test_config_alterations_class(): + """Assert a config can be changed on config class""" + class LineConfig(Config): + no_prefix = True + show_legend = False + fill = True + pretty_print = True + x_labels = ['a', 'b', 'c'] + + line1 = Line(LineConfig) + line1.add('_', [1, 2, 3]) + l1 = line1.render() + + LineConfig.stroke = False + line2 = Line(LineConfig) + line2.add('_', [1, 2, 3]) + l2 = line2.render() + assert l1 != l2 + + l1bis = line1.render() + assert l1 == l1bis + + +def test_config_alterations_instance(): + """Assert a config can be changed on instance""" + class LineConfig(Config): + no_prefix = True + show_legend = False + fill = True + pretty_print = True + x_labels = ['a', 'b', 'c'] + + config = LineConfig() + line1 = Line(config) + line1.add('_', [1, 2, 3]) + l1 = line1.render() + + config.stroke = False + line2 = Line(config) + line2.add('_', [1, 2, 3]) + l2 = line2.render() + assert l1 != l2 + + l1bis = line1.render() + assert l1 == l1bis + + +def test_config_alterations_kwargs(): + """Assert a config can be changed with keyword args""" + class LineConfig(Config): + no_prefix = True + show_legend = False + fill = True + pretty_print = True + x_labels = ['a', 'b', 'c'] + + config = LineConfig() + + line1 = Line(config) + line1.add('_', [1, 2, 3]) + l1 = line1.render() + + line1.stroke = False + l1bis = line1.render() + assert l1 != l1bis + + line2 = Line(config) + line2.add('_', [1, 2, 3]) + l2 = line2.render() + assert l1 == l2 + assert l1bis != l2 + + line3 = Line(config, title='Title') + line3.add('_', [1, 2, 3]) + l3 = line3.render() + assert l3 != l2 + + l2bis = line2.render() + assert l2 == l2bis + + +def test_logarithmic(): + """Test logarithmic option""" + line = Line(logarithmic=True) + line.add('_', [1, 10 ** 10, 1]) + q = line.render_pyquery() + assert len(q(".axis.x")) == 0 + assert len(q(".axis.y")) == 1 + assert len(q(".plot .series path")) == 1 + assert len(q(".legend")) == 1 + assert len(q(".x.axis .guides")) == 0 + assert len(q(".y.axis .guides")) == 21 + assert len(q(".dots")) == 3 + + +def test_interpolation(Chart): + """Test interpolation option""" + chart = Chart(interpolate='cubic') + chart.add('1', [1, 3, 12, 3, 4]) + chart.add('2', [7, -4, 10, None, 8, 3, 1]) + q = chart.render_pyquery() + assert len(q(".legend")) == 2 + + +def test_no_data_interpolation(Chart): + """Test interpolation option with no data""" + chart = Chart(interpolate='cubic') + q = chart.render_pyquery() + assert q(".text-overlay text").text() == "No data" + + +def test_no_data_with_empty_serie_interpolation(Chart): + """Test interpolation option with an empty serie""" + chart = Chart(interpolate='cubic') + chart.add('Serie', []) + q = chart.render_pyquery() + assert q(".text-overlay text").text() == "No data" + + +def test_logarithmic_bad_interpolation(): + """Test interpolation option with a logarithmic chart""" + line = Line(logarithmic=True, interpolate='cubic') + line.add('_', [.001, .00000001, 1]) + q = line.render_pyquery() + assert len(q(".y.axis .guides")) == 41 + + +def test_logarithmic_big_scale(): + """Test logarithmic option with a large range of value""" + line = Line(logarithmic=True) + line.add('_', [10 ** -10, 10 ** 10, 1]) + q = line.render_pyquery() + assert len(q(".y.axis .guides")) == 21 + + +def test_value_formatter(): + """Test value formatter option""" + line = Line(value_formatter=lambda x: str(x) + u('‰')) + line.add('_', [10 ** 4, 10 ** 5, 23 * 10 ** 4]) + q = line.render_pyquery() + assert len(q(".y.axis .guides")) == 11 + assert q(".axis.y text").map(texts) == list(map( + lambda x: str(x) + u('‰'), map(float, range(20000, 240000, 20000)))) + + +def test_logarithmic_small_scale(): + """Test logarithmic with a small range of values""" + line = Line(logarithmic=True) + line.add('_', [1 + 10 ** 10, 3 + 10 ** 10, 2 + 10 ** 10]) + q = line.render_pyquery() + assert len(q(".y.axis .guides")) == 11 + + +def test_human_readable(): + """Test human readable option""" + line = Line() + line.add('_', [10 ** 4, 10 ** 5, 23 * 10 ** 4]) + q = line.render_pyquery() + assert q(".axis.y text").map(texts) == list(map( + str, range(20000, 240000, 20000))) + + line.value_formatter = formatters.human_readable + + q = line.render_pyquery() + assert q(".axis.y text").map(texts) == list(map( + lambda x: '%dk' % x, range(20, 240, 20))) + + +def test_show_legend(): + """Test show legend option""" + line = Line() + line.add('_', [1, 2, 3]) + q = line.render_pyquery() + assert len(q(".legend")) == 1 + line.show_legend = False + q = line.render_pyquery() + assert len(q(".legend")) == 0 + + +def test_show_dots(): + """Test show dots option""" + line = Line() + line.add('_', [1, 2, 3]) + q = line.render_pyquery() + assert len(q(".dots")) == 3 + line.show_dots = False + q = line.render_pyquery() + assert len(q(".dots")) == 0 + + +def test_no_data(): + """Test no data and no data text option""" + line = Line() + q = line.render_pyquery() + assert q(".text-overlay text").text() == "No data" + line.no_data_text = u("þæ®þ怀&ij¿’€") + q = line.render_pyquery() + assert q(".text-overlay text").text() == u("þæ®þ怀&ij¿’€") + + +def test_include_x_axis(Chart): + """Test x axis inclusion option""" + chart = Chart() + if Chart in ( + Pie, Treemap, Radar, Funnel, Dot, Gauge, Histogram, Box, SolidGauge + ) or issubclass(Chart, BaseMap): + return + if not chart._dual: + data = 100, 200, 150 + else: + data = (1, 100), (3, 200), (2, 150) + chart.add('_', data) + q = chart.render_pyquery() + # Ghost thing + yaxis = ".axis.%s .guides text" % ( + 'y' if not getattr(chart, 'horizontal', False) else 'x') + if not isinstance(chart, Bar): + assert '0' not in q(yaxis).map(texts) + else: + assert '0' in q(yaxis).map(texts) + chart.include_x_axis = True + q = chart.render_pyquery() + assert '0' in q(yaxis).map(texts) + + +def test_css(Chart): + """Test css file option""" + css = "{{ id }}text { fill: #bedead; }\n" + with NamedTemporaryFile('w') as f: + f.write(css) + f.flush() + + config = Config() + config.css.append('file://' + f.name) + + chart = Chart(config) + chart.add('/', [10, 1, 5]) + svg = chart.render().decode('utf-8') + assert '#bedead' in svg + + chart = Chart(css=(_ellipsis, 'file://' + f.name)) + chart.add('/', [10, 1, 5]) + svg = chart.render().decode('utf-8') + assert '#bedead' in svg + + +def test_inline_css(Chart): + """Test inline css option""" + css = "{{ id }}text { fill: #bedead; }\n" + + config = Config() + config.css.append('inline:' + css) + chart = Chart(config) + chart.add('/', [10, 1, 5]) + svg = chart.render().decode('utf-8') + assert '#bedead' in svg + + +def test_meta_config(): + """Test config metaclass""" + from pygal.config import CONFIG_ITEMS + assert all(c.name != 'Unbound' for c in CONFIG_ITEMS) + + +def test_label_rotation(Chart): + """Test label rotation option""" + chart = Chart(x_label_rotation=28, y_label_rotation=76) + chart.add('1', [4, -5, 123, 59, 38]) + chart.add('2', [89, 0, 8, .12, 8]) + if not chart._dual: + chart.x_labels = ['one', 'twoooooooooooooooooooooo', 'three', '4'] + q = chart.render_pyquery() + if Chart in (Line, Bar): + assert len(q('.axis.x text[transform^="rotate(28"]')) == 4 + assert len(q('.axis.y text[transform^="rotate(76"]')) == 13 + + +def test_legend_at_bottom(Chart): + """Test legend at bottom option""" + chart = Chart(legend_at_bottom=True) + chart.add('1', [4, -5, 123, 59, 38]) + chart.add('2', [89, 0, 8, .12, 8]) + lab = chart.render() + chart.legend_at_bottom = False + assert lab != chart.render() + + +def test_x_y_title(Chart): + """Test x title and y title options""" + chart = Chart(title='I Am A Title', + x_title="I am a x title", + y_title="I am a y title") + chart.add('1', [4, -5, 123, 59, 38]) + chart.add('2', [89, 0, 8, .12, 8]) + q = chart.render_pyquery() + assert len(q('.titles .title')) == 3 + + +def test_range(Chart): + """Test y label major option""" + if Chart in ( + Pie, Treemap, Dot, SolidGauge + ) or issubclass(Chart, BaseMap): + return + chart = Chart() + chart.range = (0, 100) + chart.add('', [1, 2, 10]) + q = chart.render_pyquery() + axis = map(str, range(0, 101, 10)) + if Chart == Radar: + axis = map(str, range(100, -1, -20)) + z = 'x' if getattr(chart, 'horizontal', False) or Chart == Gauge else 'y' + assert [t.text for t in q('.axis.%s .guides text' % z)] == list(axis) + + +def test_x_label_major(Chart): + """Test x label major option""" + if Chart in ( + Pie, Treemap, Funnel, Dot, Gauge, Histogram, Box, SolidGauge, + Pyramid, DateTimeLine, TimeLine, DateLine, + TimeDeltaLine + ) or issubclass(Chart, (BaseMap, Dual, HorizontalGraph)): + return + chart = Chart() + chart.add('test', range(12)) + chart.x_labels = map(str, range(12)) + + q = chart.render_pyquery() + assert len(q(".axis.x text.major")) == 0 + + chart.x_labels_major = ['1', '5', '11', '1.0', '5.0', '11.0'] + q = chart.render_pyquery() + assert len(q(".axis.x text.major")) == 3 + assert len(q(".axis.x text")) == 12 + + chart.show_minor_x_labels = False + q = chart.render_pyquery() + assert len(q(".axis.x text.major")) == 3 + assert len(q(".axis.x text")) == 3 + + chart.show_minor_x_labels = True + chart.x_labels_major = None + chart.x_labels_major_every = 2 + q = chart.render_pyquery() + assert len(q(".axis.x text.major")) == 6 + assert len(q(".axis.x text")) == 12 + + chart.x_labels_major_every = None + chart.x_labels_major_count = 4 + q = chart.render_pyquery() + assert len(q(".axis.x text.major")) == 4 + assert len(q(".axis.x text")) == 12 + + chart.x_labels_major_every = None + chart.x_labels_major_count = 78 + q = chart.render_pyquery() + assert len(q(".axis.x text.major")) == 12 + assert len(q(".axis.x text")) == 12 + + +def test_y_label_major(Chart): + """Test y label major option""" + if Chart in ( + Pie, Treemap, Funnel, Dot, Gauge, Histogram, Box, SolidGauge, + HorizontalBar, HorizontalStackedBar, + HorizontalStackedLine, HorizontalLine, + Pyramid, DateTimeLine, TimeLine, DateLine, + TimeDeltaLine + ) or issubclass(Chart, BaseMap): + return + chart = Chart() + data = range(12) + if Chart == XY: + data = list(zip(*[range(12), range(12)])) + chart.add('test', data) + chart.y_labels = range(12) + + q = chart.render_pyquery() + assert len(q(".axis.y text.major")) == 3 + + chart.y_labels_major = [1.0, 5.0, 11.0] + q = chart.render_pyquery() + assert len(q(".axis.y text.major")) == 3 + assert len(q(".axis.y text")) == 12 + + chart.show_minor_y_labels = False + q = chart.render_pyquery() + assert len(q(".axis.y text.major")) == 3 + assert len(q(".axis.y text")) == 3 + + chart.show_minor_y_labels = True + chart.y_labels_major = None + chart.y_labels_major_every = 2 + q = chart.render_pyquery() + assert len(q(".axis.y text.major")) == 6 + assert len(q(".axis.y text")) == 12 + + chart.y_labels_major_every = None + chart.y_labels_major_count = 4 + q = chart.render_pyquery() + assert len(q(".axis.y text.major")) == 4 + assert len(q(".axis.y text")) == 12 + + chart.y_labels_major_every = None + chart.y_labels_major_count = 78 + q = chart.render_pyquery() + assert len(q(".axis.y text.major")) == 12 + assert len(q(".axis.y text")) == 12 + + +def test_no_y_labels(Chart): + """Test no y labels chart""" + chart = Chart() + chart.y_labels = [] + chart.add('_', [1, 2, 3]) + chart.add('?', [10, 21, 5]) + assert chart.render_pyquery() + + +def test_fill(Chart): + """Test fill option""" + chart = Chart(fill=True) + chart.add('_', [1, 2, 3]) + chart.add('?', [10, 21, 5]) + assert chart.render_pyquery() + + +def test_render_data_uri(Chart): + """Test the render data uri""" + chart = Chart(fill=True) + chart.add(u('ééé'), [1, 2, 3]) + chart.add(u('èèè'), [10, 21, 5]) + assert chart.render_data_uri().startswith( + 'data:image/svg+xml;charset=utf-8;base64,') + + +def test_formatters(Chart): + """Test custom formatters""" + if Chart._dual or Chart == Box: + return + chart = Chart( + formatter=lambda x, chart, serie: '%s%s$' % (x, serie.title)) + chart.add('_a', [1, 2, {'value': 3, 'formatter': lambda x: u('%s¥') % x}]) + chart.add('_b', [4, 5, 6], formatter=lambda x: u('%s€') % x) + chart.x_labels = [2, 4, 6] + chart.x_labels_major = [4] + q = chart.render_pyquery() + assert set([v.text for v in q(".value")]) == set(( + u('4€'), u('5€'), u('6€'), '1_a$', '2_a$', u('3¥')) + ( + ('6_a$', u('15€')) if Chart in (Pie, SolidGauge) else ())) + + +def test_classes(Chart): + """Test classes option""" + chart = Chart() + assert chart.render_pyquery().attr('class') == 'pygal-chart' + + chart = Chart(classes=()) + assert not chart.render_pyquery().attr('class') + + chart = Chart(classes=(_ellipsis,)) + assert chart.render_pyquery().attr('class') == 'pygal-chart' + + chart = Chart(classes=('graph',)) + assert chart.render_pyquery().attr('class') == 'graph' + + chart = Chart(classes=('pygal-chart', 'graph')) + assert chart.render_pyquery().attr('class') == 'pygal-chart graph' + + chart = Chart(classes=(_ellipsis, 'graph')) + assert chart.render_pyquery().attr('class') == 'pygal-chart graph' + + chart = Chart(classes=('graph', _ellipsis)) + assert chart.render_pyquery().attr('class') == 'graph pygal-chart' diff --git a/pygal/test/test_date.py b/pygal/test/test_date.py new file mode 100644 index 0000000..36882b9 --- /dev/null +++ b/pygal/test/test_date.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Date related charts tests""" + +from datetime import date, datetime, time, timedelta + +from pygal import DateLine, DateTimeLine, TimeDeltaLine, TimeLine +from pygal._compat import timestamp, utc +from pygal.test.utils import texts + + +def test_date(): + """Test a simple dateline""" + date_chart = DateLine(truncate_label=1000) + date_chart.add('dates', [ + (date(2013, 1, 2), 300), + (date(2013, 1, 12), 412), + (date(2013, 2, 2), 823), + (date(2013, 2, 22), 672) + ]) + + q = date_chart.render_pyquery() + + assert list( + map(lambda t: t.split(' ')[0], + q(".axis.x text").map(texts))) == [ + '2013-01-12', + '2013-01-24', + '2013-02-04', + '2013-02-16'] + + +def test_time(): + """Test a simple timeline""" + time_chart = TimeLine(truncate_label=1000) + time_chart.add('times', [ + (time(1, 12, 29), 2), + (time(21, 2, 29), 10), + (time(12, 30, 59), 7) + ]) + + q = time_chart.render_pyquery() + + assert list( + map(lambda t: t.split(' ')[0], + q(".axis.x text").map(texts))) == [ + '02:46:40', + '05:33:20', + '08:20:00', + '11:06:40', + '13:53:20', + '16:40:00', + '19:26:40'] + + +def test_datetime(): + """Test a simple datetimeline""" + datetime_chart = DateTimeLine(truncate_label=1000) + datetime_chart.add('datetimes', [ + (datetime(2013, 1, 2, 1, 12, 29), 300), + (datetime(2013, 1, 12, 21, 2, 29), 412), + (datetime(2013, 2, 2, 12, 30, 59), 823), + (datetime(2013, 2, 22), 672) + ]) + + q = datetime_chart.render_pyquery() + + assert list( + map(lambda t: t.split(' ')[0], + q(".axis.x text").map(texts))) == [ + '2013-01-12T14:13:20', + '2013-01-24T04:00:00', + '2013-02-04T17:46:40', + '2013-02-16T07:33:20'] + + +def test_timedelta(): + """Test a simple timedeltaline""" + timedelta_chart = TimeDeltaLine(truncate_label=1000) + timedelta_chart.add('timedeltas', [ + (timedelta(seconds=1), 10), + (timedelta(weeks=1), 50), + (timedelta(hours=3, seconds=30), 3), + (timedelta(microseconds=12112), .3), + ]) + + q = timedelta_chart.render_pyquery() + assert list( + t for t in q(".axis.x text").map(texts) if t != '0:00:00' + ) == [ + '1 day, 3:46:40', + '2 days, 7:33:20', + '3 days, 11:20:00', + '4 days, 15:06:40', + '5 days, 18:53:20', + '6 days, 22:40:00'] + + +def test_date_xrange(): + """Test dateline with xrange""" + datey = DateLine(truncate_label=1000) + datey.add('dates', [ + (date(2013, 1, 2), 300), + (date(2013, 1, 12), 412), + (date(2013, 2, 2), 823), + (date(2013, 2, 22), 672) + ]) + + datey.xrange = (date(2013, 1, 1), date(2013, 3, 1)) + + q = datey.render_pyquery() + assert list( + map(lambda t: t.split(' ')[0], + q(".axis.x text").map(texts))) == [ + '2013-01-01', + '2013-01-12', + '2013-01-24', + '2013-02-04', + '2013-02-16', + '2013-02-27'] + + +def test_date_labels(): + """Test dateline with xrange""" + datey = DateLine(truncate_label=1000) + datey.add('dates', [ + (date(2013, 1, 2), 300), + (date(2013, 1, 12), 412), + (date(2013, 2, 2), 823), + (date(2013, 2, 22), 672) + ]) + + datey.x_labels = [ + date(2013, 1, 1), + date(2013, 2, 1), + date(2013, 3, 1) + ] + + q = datey.render_pyquery() + assert list( + map(lambda t: t.split(' ')[0], + q(".axis.x text").map(texts))) == [ + '2013-01-01', + '2013-02-01', + '2013-03-01'] + + +def test_utc_timestamping(): + assert timestamp( + datetime(2017, 7, 14, 2, 40).replace(tzinfo=utc) + ) == 1500000000 + + for d in ( + datetime.now(), + datetime.utcnow(), + datetime(1999, 12, 31, 23, 59, 59), + datetime(2000, 1, 1, 0, 0, 0) + ): + assert datetime.utcfromtimestamp( + timestamp(d)) - d < timedelta(microseconds=10) diff --git a/pygal/test/test_formatters.py b/pygal/test/test_formatters.py new file mode 100644 index 0000000..1e6eb73 --- /dev/null +++ b/pygal/test/test_formatters.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Test formatters""" + +from pygal import formatters +from pygal._compat import u + + +def test_human_readable(): + """Test human_readable formatter""" + f = formatters.human_readable + + assert f(1) == '1' + assert f(1.) == '1' + assert f(10) == '10' + assert f(12.5) == '12.5' + assert f(1000) == '1k' + assert f(5000) == '5k' + assert f(100000) == '100k' + assert f(1253) == '1.253k' + assert f(1250) == '1.25k' + + assert f(0.1) == '100m' + assert f(0.01) == '10m' + assert f(0.001) == '1m' + assert f(0.002) == '2m' + assert f(0.0025) == '2.5m' + assert f(0.0001) == u('100µ') + assert f(0.000123) == u('123µ') + assert f(0.00001) == u('10µ') + assert f(0.000001) == u('1µ') + assert f(0.0000001) == u('100n') + assert f(0.0000000001) == u('100p') + + assert f(0) == '0' + assert f(0.) == '0' + assert f(-1337) == '-1.337k' + assert f(-.000000042) == '-42n' + + +def test_human_readable_custom(): + """Test human_readable formatter option""" + f = formatters.HumanReadable() + assert f(None) == u('∅') + f = formatters.HumanReadable(none_char='/') + assert f(None) == '/' + + +def test_significant(): + """Test significant formatter""" + f = formatters.significant + assert f(1) == '1' + assert f(1.) == '1' + assert f(-1.) == '-1' + assert f(10) == '10' + assert f(10000000000) == '1e+10' + assert f(100000000000) == '1e+11' + assert f(120000000000) == '1.2e+11' + + assert f(.1) == '0.1' + assert f(.01) == '0.01' + assert f(.0000000001) == '1e-10' + assert f(-.0000000001) == '-1e-10' + assert f(.0000000001002) == '1.002e-10' + + assert f(.0000000001002) == '1.002e-10' + + assert f(.12345678912345) == '0.1234567891' + assert f(.012345678912345) == '0.01234567891' + + assert f(12345678912345) == '1.234567891e+13' diff --git a/pygal/test/test_graph.py b/pygal/test/test_graph.py new file mode 100644 index 0000000..3ee99f1 --- /dev/null +++ b/pygal/test/test_graph.py @@ -0,0 +1,442 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Generate tests for different chart types with different data""" + +import io +import os +import sys +import uuid + +import pytest + +import pygal +from pygal._compat import u +from pygal.graph.map import BaseMap +from pygal.test import make_data +from pygal.util import cut + +try: + import cairosvg +except ImportError: + cairosvg = None + + +def test_multi_render(Chart, datas): + """Check that a chart always render the same""" + chart = Chart() + chart = make_data(chart, datas) + svg = chart.render() + for i in range(2): + assert svg == chart.render() + + +def test_render_to_file(Chart, datas): + """Test in file rendering""" + file_name = '/tmp/test_graph-%s.svg' % uuid.uuid4() + if os.path.exists(file_name): + os.remove(file_name) + + chart = Chart() + chart = make_data(chart, datas) + chart.render_to_file(file_name) + with io.open(file_name, encoding="utf-8") as f: + assert 'pygal' in f.read() + os.remove(file_name) + + +@pytest.mark.skipif(not cairosvg, reason="CairoSVG not installed") +def test_render_to_png(Chart, datas): + """Test in file png rendering""" + file_name = '/tmp/test_graph-%s.png' % uuid.uuid4() + if os.path.exists(file_name): + os.remove(file_name) + + chart = Chart() + chart = make_data(chart, datas) + chart.render_to_png(file_name) + png = chart._repr_png_() + + with open(file_name, 'rb') as f: + assert png == f.read() + os.remove(file_name) + + +def test_metadata(Chart): + """Test metadata values""" + chart = Chart() + v = range(7) + if Chart in (pygal.Box,): + return # summary charts cannot display per-value metadata + elif Chart == pygal.XY: + v = list(map(lambda x: (x, x + 1), v)) + elif issubclass(Chart, BaseMap): + v = [(k, i) for i, k in enumerate(Chart.x_labels) if k not in [ + 'oecd', 'nafta', 'eur']] + + chart.add('Serie with metadata', [ + v[0], + {'value': v[1]}, + {'value': v[2], 'label': 'Three'}, + {'value': v[3], 'xlink': 'http://4.example.com/'}, + {'value': v[4], 'xlink': 'http://5.example.com/', 'label': 'Five'}, + {'value': v[5], 'xlink': { + 'href': 'http://6.example.com/'}, 'label': 'Six'}, + {'value': v[6], 'xlink': { + 'href': 'http://7.example.com/', + 'target': '_blank'}, 'label': 'Seven'} + ]) + q = chart.render_pyquery() + for md in ('Three', 'Five', 'Seven'): + assert md in cut(q('desc'), 'text') + + for md in ('http://7.example.com/', 'http://4.example.com/'): + assert md in [e.attrib.get('xlink:href') for e in q('a')] + + if Chart in (pygal.Pie, pygal.Treemap, pygal.SolidGauge): + # Slices with value 0 are not rendered + assert len(v) - 1 == len(q('.tooltip-trigger').siblings('.value')) + elif not issubclass(Chart, BaseMap): + + # Tooltip are not working on maps + assert len(v) == len(q('.tooltip-trigger').siblings('.value')) + + +def test_empty_lists(Chart): + """Test chart rendering with an empty serie""" + chart = Chart() + chart.add('A', [1, 2]) + chart.add('B', []) + if not chart._dual: + chart.x_labels = ('red', 'green', 'blue') + q = chart.render_pyquery() + assert len(q(".legend")) == 2 + + +def test_empty_lists_with_nones(Chart): + """Test chart rendering with a None filled serie""" + chart = Chart() + chart.add('A', [None, None]) + chart.add('B', [None, 4, 4]) + q = chart.render_pyquery() + assert len(q(".legend")) == 2 + + +def test_only_one_value(Chart): + """Test chart rendering with only one value""" + chart = Chart() + chart.add('S', [1]) + q = chart.render_pyquery() + assert len(q(".legend")) == 1 + + +def test_only_one_value_log(Chart): + """Test logarithmic chart rendering with only one value""" + chart = Chart(logarithmic=True) + chart.add('S', [1]) + if not chart._dual: + chart.x_labels = ('single') + q = chart.render_pyquery() + assert len(q(".legend")) == 1 + + +def test_only_one_value_intrp(Chart): + """Test interpolated chart rendering with only one value""" + chart = Chart(interpolate='cubic') + chart.add('S', [1]) + q = chart.render_pyquery() + assert len(q(".legend")) == 1 + + +def test_non_iterable_value(Chart): + """Test serie as non iterable""" + chart = Chart(no_prefix=True) + chart.add('A', 1) + chart.add('B', 2) + if not chart._dual: + chart.x_labels = ('red', 'green', 'blue') + chart1 = chart.render() + chart = Chart(no_prefix=True) + chart.add('A', [1]) + chart.add('B', [2]) + if not chart._dual: + chart.x_labels = ('red', 'green', 'blue') + chart2 = chart.render() + assert chart1 == chart2 + + +def test_iterable_types(Chart): + """Test serie as various iterable""" + chart = Chart(no_prefix=True) + chart.add('A', [1, 2]) + chart.add('B', []) + if not chart._dual: + chart.x_labels = ('red', 'green', 'blue') + chart1 = chart.render() + + chart = Chart(no_prefix=True) + chart.add('A', (1, 2)) + chart.add('B', tuple()) + if not chart._dual: + chart.x_labels = ('red', 'green', 'blue') + chart2 = chart.render() + assert chart1 == chart2 + + +def test_values_by_dict(Chart): + """Test serie as dict""" + chart1 = Chart(no_prefix=True) + chart2 = Chart(no_prefix=True) + + if not issubclass(Chart, BaseMap) and not Chart._dual: + chart1.add('A', {'red': 10, 'green': 12, 'blue': 14}) + chart1.add('B', {'green': 11, 'red': 7}) + chart1.add('C', {'blue': 7}) + chart1.add('D', {}) + chart1.add('E', {'blue': 2, 'red': 13}) + chart1.x_labels = ('red', 'green', 'blue') + + chart2.add('A', [10, 12, 14]) + chart2.add('B', [7, 11]) + chart2.add('C', [None, None, 7]) + chart2.add('D', []) + chart2.add('E', [13, None, 2]) + chart2.x_labels = ('red', 'green', 'blue') + elif not Chart._dual: + chart1.add('A', {'fr': 10, 'us': 12, 'jp': 14}) + chart1.add('B', {'cn': 99}) + chart1.add('C', {}) + + chart2.add('A', [('fr', 10), ('us', 12), ('jp', 14)]) + chart2.add('B', [('cn', 99)]) + chart2.add('C', [None, (None, None)]) + + assert chart1.render() == chart2.render() + + +def test_no_data_with_no_values(Chart): + """Test no data""" + chart = Chart() + q = chart.render_pyquery() + assert q(".text-overlay text").text() == "No data" + + +def test_no_data_with_no_values_with_include_x_axis(Chart): + """Test no data and include_x_axis""" + chart = Chart(include_x_axis=True) + q = chart.render_pyquery() + assert q(".text-overlay text").text() == "No data" + + +def test_no_data_with_empty_serie(Chart): + """Test no data for empty serie""" + chart = Chart() + chart.add('Serie', []) + q = chart.render_pyquery() + assert q(".text-overlay text").text() == "No data" + + +def test_no_data_with_empty_series(Chart): + """Test no data for 2 empty series""" + chart = Chart() + chart.add('Serie1', []) + chart.add('Serie2', []) + q = chart.render_pyquery() + assert q(".text-overlay text").text() == "No data" + + +def test_no_data_with_none(Chart): + """Test no data for a None containing serie""" + chart = Chart() + chart.add('Serie', None) + q = chart.render_pyquery() + assert q(".text-overlay text").text() == "No data" + + +def test_no_data_with_list_of_none(Chart): + """Test no data for a None containing serie""" + chart = Chart() + chart.add('Serie', [None]) + q = chart.render_pyquery() + assert q(".text-overlay text").text() == "No data" + + +def test_no_data_with_lists_of_nones(Chart): + """Test no data for several None containing series""" + chart = Chart() + chart.add('Serie1', [None, None, None, None]) + chart.add('Serie2', [None, None, None]) + q = chart.render_pyquery() + assert q(".text-overlay text").text() == "No data" + + +def test_unicode_labels_decode(Chart): + """Test unicode labels""" + chart = Chart() + chart.add(u('Série1'), [{ + 'value': 1, + 'xlink': 'http://1/', + 'label': u('{\}°ijæð©&×&<—×€¿_…\{_…') + }, { + 'value': 2, + 'xlink': { + 'href': 'http://6.example.com/' + }, + 'label': u('æ°€≠|€æ°€əæ') + }, { + 'value': 3, + 'label': 'unicode <3' + }]) + if not chart._dual: + chart.x_labels = [u('&œ'), u('¿?'), u('††††††††'), 'unicode <3'] + chart.render_pyquery() + + +def test_unicode_labels_python2(Chart): + """Test unicode labels in python 2""" + if sys.version_info[0] == 3: + return + chart = Chart() + chart.add(u('Série1'), [{ + 'value': 1, + 'xlink': 'http://1/', + 'label': eval("u'{\}°ijæð©&×&<—×€¿_…\{_…'") + }, { + 'value': 2, + 'xlink': { + 'href': 'http://6.example.com/' + }, + 'label': eval("u'æ°€≠|€æ°€əæ'") + }, { + 'value': 3, + 'label': eval("'unicode <3'") + }]) + if not chart._dual: + chart.x_labels = eval("[u'&œ', u'¿?', u'††††††††', 'unicode <3']") + chart.render_pyquery() + + +def test_unicode_labels_python3(Chart): + """Test unicode labels in python 3""" + if sys.version_info[0] == 2: + return + chart = Chart() + chart.add(u('Série1'), [{ + 'value': 1, + 'xlink': 'http://1/', + 'label': eval("'{\}°ijæð©&×&<—×€¿_…\{_…'") + }, { + 'value': 2, + 'xlink': { + 'href': 'http://6.example.com/' + }, + 'label': eval("'æ°€≠|€æ°€əæ'") + }, { + 'value': 3, + 'label': eval("b'unicode <3'") + }]) + if not chart._dual: + chart.x_labels = eval("['&œ', '¿?', '††††††††', 'unicode <3']") + chart.render_pyquery() + + +def test_labels_with_links(Chart): + """Test values with links""" + chart = Chart() + # link on chart and label + chart.add({ + 'title': 'Red', 'xlink': {'href': 'http://en.wikipedia.org/wiki/Red'} + }, [{ + 'value': 2, + 'label': 'This is red', + 'xlink': {'href': 'http://en.wikipedia.org/wiki/Red'}}]) + + # link on chart only + chart.add('Green', [{ + 'value': 4, + 'label': 'This is green', + 'xlink': { + 'href': 'http://en.wikipedia.org/wiki/Green', + 'target': '_top'}}]) + + # link on label only opens in new tab + chart.add({'title': 'Yellow', 'xlink': { + 'href': 'http://en.wikipedia.org/wiki/Yellow', + 'target': '_blank'}}, 7) + + # link on chart only + chart.add('Blue', [{ + 'value': 5, + 'xlink': { + 'href': 'http://en.wikipedia.org/wiki/Blue', + 'target': '_blank'}}]) + + # link on label and chart with diffrent behaviours + chart.add({ + 'title': 'Violet', + 'xlink': 'http://en.wikipedia.org/wiki/Violet_(color)' + }, [{ + 'value': 3, + 'label': 'This is violet', + 'xlink': { + 'href': 'http://en.wikipedia.org/wiki/Violet_(color)', + 'target': '_self'}}]) + + q = chart.render_pyquery() + links = q('a') + + assert len(links) == 7 or isinstance(chart, BaseMap) and len(links) == 3 + + +def test_sparkline(Chart, datas): + """Test sparkline""" + chart = Chart() + chart = make_data(chart, datas) + assert chart.render_sparkline() + + +def test_secondary(Chart): + """Test secondary chart""" + chart = Chart() + rng = [83, .12, -34, 59] + chart.add('First serie', rng) + chart.add('Secondary serie', + map(lambda x: x * 2, rng), + secondary=True) + assert chart.render_pyquery() + + +def test_ipython_notebook(Chart, datas): + """Test ipython notebook""" + chart = Chart() + chart = make_data(chart, datas) + assert chart._repr_svg_() + + +def test_long_title(Chart, datas): + """Test chart rendering with a long title""" + chart = Chart( + title="A chart is a graphical representation of data, in which " + "'the data is represented by symbols, such as bars in a bar chart, " + "lines in a line chart, or slices in a pie chart'. A chart can " + "represent tabular numeric data, functions or some kinds of " + "qualitative structure and provides different info.") + chart = make_data(chart, datas) + q = chart.render_pyquery() + assert len(q('.titles text')) == 5 diff --git a/pygal/test/test_histogram.py b/pygal/test/test_histogram.py new file mode 100644 index 0000000..04793a0 --- /dev/null +++ b/pygal/test/test_histogram.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Histogram chart related tests""" + + +from pygal import Histogram + + +def test_histogram(): + """Simple histogram test""" + hist = Histogram() + hist.add('1', [ + (2, 0, 1), + (4, 1, 3), + (3, 3.5, 5), + (1.5, 5, 10) + ]) + hist.add('2', [(2, 2, 8)], secondary=True) + q = hist.render_pyquery() + assert len(q('.rect')) == 5 diff --git a/pygal/test/test_interpolate.py b/pygal/test/test_interpolate.py new file mode 100644 index 0000000..a2eda9c --- /dev/null +++ b/pygal/test/test_interpolate.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Interpolations tests""" + +from pygal.test import make_data + + +def test_cubic(Chart, datas): + """Test cubic interpolation""" + chart = Chart(interpolate='cubic') + chart = make_data(chart, datas) + assert chart.render() + + +def test_cubic_prec(Chart, datas): + """Test cubic interpolation precision""" + chart = Chart(interpolate='cubic', interpolation_precision=200) + chart = make_data(chart, datas) + + chart_low = Chart(interpolate='cubic', interpolation_precision=5) + chart_low = make_data(chart, datas) + + assert len(chart.render()) >= len(chart_low.render()) + + +def test_quadratic(Chart, datas): + """Test quadratic interpolation""" + chart = Chart(interpolate='quadratic') + chart = make_data(chart, datas) + assert chart.render() + + +def test_lagrange(Chart, datas): + """Test lagrange interpolation""" + chart = Chart(interpolate='lagrange') + chart = make_data(chart, datas) + assert chart.render() + + +def test_trigonometric(Chart, datas): + """Test trigonometric interpolation""" + chart = Chart(interpolate='trigonometric') + chart = make_data(chart, datas) + assert chart.render() + + +def test_hermite(Chart, datas): + """Test hermite interpolation""" + chart = Chart(interpolate='hermite') + chart = make_data(chart, datas) + assert chart.render() + + +def test_hermite_finite(Chart, datas): + """Test hermite finite difference interpolation""" + chart = Chart(interpolate='hermite', + interpolation_parameters={'type': 'finite_difference'}) + chart = make_data(chart, datas) + assert chart.render() + + +def test_hermite_cardinal(Chart, datas): + """Test hermite cardinal interpolation""" + chart = Chart(interpolate='hermite', + interpolation_parameters={'type': 'cardinal', 'c': .75}) + chart = make_data(chart, datas) + assert chart.render() + + +def test_hermite_catmull_rom(Chart, datas): + """Test hermite catmull rom interpolation""" + chart = Chart(interpolate='hermite', + interpolation_parameters={'type': 'catmull_rom'}) + chart = make_data(chart, datas) + assert chart.render() + + +def test_hermite_kochanek_bartels(Chart, datas): + """Test hermite kochanek bartels interpolation""" + chart = Chart(interpolate='hermite', + interpolation_parameters={ + 'type': 'kochanek_bartels', 'b': -1, 'c': 1, 't': 1}) + chart = make_data(chart, datas) + assert chart.render() + + chart = Chart(interpolate='hermite', + interpolation_parameters={ + 'type': 'kochanek_bartels', 'b': -1, 'c': -8, 't': 0}) + chart = make_data(chart, datas) + assert chart.render() + + chart = Chart(interpolate='hermite', + interpolation_parameters={ + 'type': 'kochanek_bartels', 'b': 0, 'c': 10, 't': -1}) + chart = make_data(chart, datas) + assert chart.render() diff --git a/pygal/test/test_line.py b/pygal/test/test_line.py new file mode 100644 index 0000000..c102232 --- /dev/null +++ b/pygal/test/test_line.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Line chart related tests""" + +from __future__ import division + +from math import cos, sin + +from pygal import Line +from pygal.test.utils import texts + + +def test_simple_line(): + """Simple line test""" + line = Line() + rng = range(-30, 31, 5) + line.add('test1', [cos(x / 10) for x in rng]) + line.add('test2', [sin(x / 10) for x in rng]) + line.add('test3', [cos(x / 10) - sin(x / 10) for x in rng]) + line.x_labels = map(str, rng) + line.title = "cos sin and cos - sin" + q = line.render_pyquery() + assert len(q(".axis.x")) == 1 + assert len(q(".axis.y")) == 1 + assert len(q(".plot .series path")) == 3 + assert len(q(".legend")) == 3 + assert len(q(".x.axis .guides")) == 13 + assert len(q(".y.axis .guides")) == 13 + assert len(q(".dots")) == 3 * 13 + assert q(".axis.x text").map(texts) == [ + '-30', '-25', '-20', '-15', '-10', '-5', + '0', '5', '10', '15', '20', '25', '30'] + assert q(".axis.y text").map(texts) == [ + '-1.2', '-1', '-0.8', '-0.6', '-0.4', '-0.2', + '0', '0.2', '0.4', '0.6', '0.8', '1', '1.2'] + assert q(".title").text() == 'cos sin and cos - sin' + assert q(".legend text").map(texts) == ['test1', 'test2', 'test3'] + + +def test_line(): + """Another simple line test""" + line = Line() + rng = [8, 12, 23, 73, 39, 57] + line.add('Single serie', rng) + line.title = "One serie" + q = line.render_pyquery() + assert len(q(".axis.x")) == 0 + assert len(q(".axis.y")) == 1 + assert len(q(".plot .series path")) == 1 + assert len(q(".x.axis .guides")) == 0 + assert len(q(".y.axis .guides")) == 7 + + +def test_one_dot(): + """Line test with an unique value""" + line = Line() + line.add('one dot', [12]) + line.x_labels = ['one'] + q = line.render_pyquery() + assert len(q(".axis.x")) == 1 + assert len(q(".axis.y")) == 1 + assert len(q(".y.axis .guides")) == 1 + + +def test_no_dot(): + """Line test with an empty serie""" + line = Line() + line.add('no dot', []) + q = line.render_pyquery() + assert q(".text-overlay text").text() == 'No data' + + +def test_no_dot_at_all(): + """Line test with no value""" + q = Line().render_pyquery() + assert q(".text-overlay text").text() == 'No data' + + +def test_not_equal_x_labels(): + """Test x_labels""" + line = Line() + line.add('test1', range(100)) + line.truncate_label = -1 + line.x_labels = map(str, range(11)) + q = line.render_pyquery() + assert len(q(".dots")) == 100 + assert len(q(".axis.x")) == 1 + assert q(".axis.x text").map(texts) == [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10'] + + +def test_int_x_labels(): + """Test x_labels""" + line = Line() + line.add('test1', range(100)) + line.truncate_label = -1 + line.x_labels = list(range(11)) + q = line.render_pyquery() + assert len(q(".dots")) == 100 + assert len(q(".axis.x")) == 1 + assert q(".axis.x text").map(texts) == [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10'] + + +def test_only_major_dots_every(): + """Test major dots""" + line = Line(show_only_major_dots=True, x_labels_major_every=3) + line.add('test', range(12)) + line.x_labels = map(str, range(12)) + q = line.render_pyquery() + assert len(q(".dots")) == 4 + + +def test_only_major_dots_no_labels(): + """Test major dots with no labels""" + line = Line(show_only_major_dots=True) + line.add('test', range(12)) + q = line.render_pyquery() + assert len(q(".dots")) == 12 + + +def test_only_major_dots_count(): + """Test major dots with a major label count""" + line = Line(show_only_major_dots=True) + line.add('test', range(12)) + line.x_labels = map(str, range(12)) + line.x_labels_major_count = 2 + q = line.render_pyquery() + assert len(q(".dots")) == 2 + + +def test_only_major_dots(): + """Test major dots with specified major labels""" + line = Line(show_only_major_dots=True,) + line.add('test', range(12)) + line.x_labels = map(str, range(12)) + line.x_labels_major = ['1', '5', '11'] + q = line.render_pyquery() + assert len(q(".dots")) == 3 + + +def test_line_secondary(): + """Test line with a secondary serie""" + line = Line() + rng = [8, 12, 23, 73, 39, 57] + line.add('First serie', rng) + line.add('Secondary serie', + map(lambda x: x * 2, rng), + secondary=True) + line.title = "One serie" + q = line.render_pyquery() + assert len(q(".axis.x")) == 0 + assert len(q(".axis.y")) == 1 + assert len(q(".plot .series path")) == 2 + assert len(q(".x.axis .guides")) == 0 + assert len(q(".y.axis .guides")) == 7 diff --git a/pygal/test/test_line_log_none_max_solved.py b/pygal/test/test_line_log_none_max_solved.py new file mode 100644 index 0000000..41e72f8 --- /dev/null +++ b/pygal/test/test_line_log_none_max_solved.py @@ -0,0 +1,13 @@ +# This file is test file for NoneMaxSolved +# I have modified the line.py and passed other test +# This test is for us to test whether the none value +# in the Log graph will be max or not (issue #309) + +from __future__ import division +from pygal import Line + +chart = Line(title='test', logarithmic=True) +chart.add('test 1', [None, -38, 48, 4422, 35586, 1003452, 225533]) +chart.add('test 2', [1, 40, 20, 38, 2937, 20399, 3947]) +q = chart.render_pyquery() +assert len(q(".dots")) == 12 diff --git a/pygal/test/test_maps.py b/pygal/test/test_maps.py new file mode 100644 index 0000000..e2a3c5f --- /dev/null +++ b/pygal/test/test_maps.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Map plugins tests are imported here""" + +import pkg_resources + +# Load plugins tests +for entry in pkg_resources.iter_entry_points('pygal.test.test_maps'): + module = entry.load() + for k, v in module.__dict__.items(): + if k.startswith('test_'): + globals()['test_maps_' + entry.name + '_' + k[5:]] = v diff --git a/pygal/test/test_pie.py b/pygal/test/test_pie.py new file mode 100644 index 0000000..2bca019 --- /dev/null +++ b/pygal/test/test_pie.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Donut chart related tests""" + +from pygal import Pie + + +def test_donut(): + """Test a donut pie chart""" + chart = Pie(inner_radius=.3, pretty_print=True) + chart.title = 'Browser usage in February 2012 (in %)' + chart.add('IE', 19.5) + chart.add('Firefox', 36.6) + chart.add('Chrome', 36.3) + chart.add('Safari', 4.5) + chart.add('Opera', 2.3) + assert chart.render() + + +def test_multiseries_donut(): + """Test a donut pie chart with multiserie""" + # this just demos that the multiseries pie does not respect + # the inner_radius + chart = Pie(inner_radius=.3, pretty_print=True) + chart.title = 'Browser usage by version in February 2012 (in %)' + chart.add('IE', [5.7, 10.2, 2.6, 1]) + chart.add('Firefox', [.6, 16.8, 7.4, 2.2, 1.2, 1, 1, 1.1, 4.3, 1]) + chart.add('Chrome', [.3, .9, 17.1, 15.3, .6, .5, 1.6]) + chart.add('Safari', [4.4, .1]) + chart.add('Opera', [.1, 1.6, .1, .5]) + assert chart.render() + + +def test_half_pie(): + """Test a half pie chart""" + pie = Pie() + pie.add('IE', 19.5) + pie.add('Firefox', 36.6) + pie.add('Chrome', 36.3) + pie.add('Safari', 4.5) + pie.add('Opera', 2.3) + + half = Pie(half_pie=True) + half.add('IE', 19.5) + half.add('Firefox', 36.6) + half.add('Chrome', 36.3) + half.add('Safari', 4.5) + half.add('Opera', 2.3) + assert pie.render() != half.render() diff --git a/pygal/test/test_serie_config.py b/pygal/test/test_serie_config.py new file mode 100644 index 0000000..f8072a2 --- /dev/null +++ b/pygal/test/test_serie_config.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Test per serie configuration""" + +from pygal import Line + +s1 = [1, 3, 12, 3, 4] +s2 = [7, -4, 10, None, 8, 3, 1] + + +def test_no_serie_config(): + """Test per serie no configuration""" + 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 + + +def test_global_config(): + """Test global configuration""" + 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 + + +def test_serie_config(): + """Test per serie configuration""" + 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 + + +def test_serie_precedence_over_global_config(): + """Test that per serie configuration overide global configuration""" + 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/test/test_sparktext.py b/pygal/test/test_sparktext.py new file mode 100644 index 0000000..8066169 --- /dev/null +++ b/pygal/test/test_sparktext.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Test sparktext rendering""" + +from pygal import Bar, Line +from pygal._compat import u + + +def test_basic_sparktext(): + """Test basic sparktext""" + chart = Line() + chart.add('_', [1, 5, 22, 13, 53]) + assert chart.render_sparktext() == u('▁▁▃▂█') + + +def test_all_sparktext(): + """Test all character sparktext""" + chart = Line() + chart.add('_', range(8)) + assert chart.render_sparktext() == u('▁▂▃▄▅▆▇█') + + +def test_shifted_sparktext(): + """Test relative_to option in sparktext""" + chart = Line() + chart.add('_', list(map(lambda x: x + 10000, range(8)))) + assert chart.render_sparktext() == u('▁▂▃▄▅▆▇█') + assert chart.render_sparktext(relative_to=0) == u('▇▇▇▇▇▇▇█') + + +def test_another_sparktext(): + """Test that same data produces same sparktext""" + chart = Line() + chart.add('_', [0, 30, 55, 80, 33, 150]) + assert chart.render_sparktext() == u('▁▂▃▄▂█') + assert chart.render_sparktext() == chart.render_sparktext() + chart2 = Bar() + chart2.add('_', [0, 30, 55, 80, 33, 150]) + assert chart2.render_sparktext() == chart.render_sparktext() + + +def test_negative_and_float__sparktext(): + """Test negative values""" + """Test negative values""" + chart = Line() + chart.add('_', [0.1, 0.2, 0.9, -0.5]) + assert chart.render_sparktext() == u('▁▂█▁') + + +def test_no_data_sparktext(): + """Test no data sparktext""" + chart2 = Line() + chart2.add('_', []) + assert chart2.render_sparktext() == u('') + + chart3 = Line() + assert chart3.render_sparktext() == u('') + + +def test_same_max_and_relative_values_sparktext(): + """Test flat sparktexts""" + chart = Line() + chart.add('_', [0, 0, 0, 0, 0]) + assert chart.render_sparktext() == u('▁▁▁▁▁') + + chart2 = Line() + chart2.add('_', [1, 1, 1, 1, 1]) + assert chart2.render_sparktext(relative_to=1) == u('▁▁▁▁▁') diff --git a/pygal/test/test_stacked.py b/pygal/test/test_stacked.py new file mode 100644 index 0000000..bb2f706 --- /dev/null +++ b/pygal/test/test_stacked.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Stacked chart related tests""" + +from pygal import StackedLine + + +def test_stacked_line(): + """Test stacked line""" + stacked = StackedLine() + stacked.add('one_two', [1, 2]) + stacked.add('ten_twelve', [10, 12]) + q = stacked.render_pyquery() + assert set([v.text for v in q("desc.value")]) == set( + ('1', '2', '11 (+10)', '14 (+12)')) + + +def test_stacked_line_reverse(): + """Test stack from top stacked line""" + stacked = StackedLine(stack_from_top=True) + stacked.add('one_two', [1, 2]) + stacked.add('ten_twelve', [10, 12]) + q = stacked.render_pyquery() + assert set([v.text for v in q("desc.value")]) == set( + ('11 (+1)', '14 (+2)', '10', '12')) + + +def test_stacked_line_log(): + """Test logarithmic stacked line""" + stacked = StackedLine(logarithmic=True) + stacked.add('one_two', [1, 2]) + stacked.add('ten_twelve', [10, 12]) + q = stacked.render_pyquery() + assert set([v.text for v in q("desc.value")]) == set( + ('1', '2', '11 (+10)', '14 (+12)')) + + +def test_stacked_line_interpolate(): + """Test interpolated stacked line""" + stacked = StackedLine(interpolate='cubic') + stacked.add('one_two', [1, 2]) + stacked.add('ten_twelve', [10, 12]) + q = stacked.render_pyquery() + assert set([v.text for v in q("desc.value")]) == set( + ('1', '2', '11 (+10)', '14 (+12)')) diff --git a/pygal/test/test_style.py b/pygal/test/test_style.py new file mode 100644 index 0000000..9fe0139 --- /dev/null +++ b/pygal/test/test_style.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Style related tests""" + +from pygal import Line +from pygal.style import ( + DarkenStyle, DesaturateStyle, LightenStyle, LightStyle, RotateStyle, + SaturateStyle) + +STYLES = LightenStyle, DarkenStyle, SaturateStyle, DesaturateStyle, RotateStyle + + +def test_parametric_styles(): + """Test that no parametric produce the same result""" + chart = None + for style in STYLES: + line = Line(style=style('#f4e83a')) + line.add('_', [1, 2, 3]) + line.x_labels = 'abc' + new_chart = line.render() + assert chart != new_chart + chart = new_chart + + +def test_parametric_styles_with_parameters(): + """Test a parametric style with parameters""" + line = Line(style=RotateStyle( + '#de3804', step=12, max_=180, base_style=LightStyle)) + line.add('_', [1, 2, 3]) + line.x_labels = 'abc' + assert line.render() diff --git a/pygal/test/test_table.py b/pygal/test/test_table.py new file mode 100644 index 0000000..4d7eb40 --- /dev/null +++ b/pygal/test/test_table.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 chart related tests""" + +from pyquery import PyQuery as pq + +from pygal import Pie + + +def test_pie_table(): + """Test rendering a table for a pie""" + chart = Pie(inner_radius=.3, pretty_print=True) + chart.title = 'Browser usage in February 2012 (in %)' + chart.add('IE', 19.5) + chart.add('Firefox', 36.6) + chart.add('Chrome', 36.3) + chart.add('Safari', 4.5) + chart.add('Opera', 2.3) + q = pq(chart.render_table()) + assert len(q('table')) == 1 diff --git a/pygal/test/test_util.py b/pygal/test/test_util.py new file mode 100644 index 0000000..0925b1e --- /dev/null +++ b/pygal/test/test_util.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Utility functions tests""" + +import sys + +from pytest import raises + +from pygal._compat import _ellipsis, u +from pygal.util import ( + _swap_curly, majorize, mergextend, minify_css, round_to_float, + round_to_int, template, truncate) + + +def test_round_to_int(): + """Test round to int function""" + assert round_to_int(154231, 1000) == 154000 + assert round_to_int(154231, 10) == 154230 + assert round_to_int(154231, 100000) == 200000 + assert round_to_int(154231, 50000) == 150000 + assert round_to_int(154231, 500) == 154000 + assert round_to_int(154231, 200) == 154200 + assert round_to_int(154361, 200) == 154400 + + +def test_round_to_float(): + """Test round to float function""" + assert round_to_float(12.01934, .01) == 12.02 + assert round_to_float(12.01134, .01) == 12.01 + assert round_to_float(12.1934, .1) == 12.2 + assert round_to_float(12.1134, .1) == 12.1 + assert round_to_float(12.1134, .001) == 12.113 + assert round_to_float(12.1134, .00001) == 12.1134 + assert round_to_float(12.1934, .5) == 12.0 + assert round_to_float(12.2934, .5) == 12.5 + + +def test_swap_curly(): + """Test swap curly function""" + for str in ( + 'foo', + u('foo foo foo bar'), + 'foo béè b¡ð/ijə˘©þß®~¯æ', + u('foo béè b¡ð/ijə˘©þß®~¯æ')): + assert _swap_curly(str) == str + assert _swap_curly('foo{bar}baz') == 'foo{{bar}}baz' + assert _swap_curly('foo{{bar}}baz') == 'foo{bar}baz' + assert _swap_curly('{foo}{{bar}}{baz}') == '{{foo}}{bar}{{baz}}' + assert _swap_curly('{foo}{{{bar}}}{baz}') == '{{foo}}{{{bar}}}{{baz}}' + assert _swap_curly('foo{ bar }baz') == 'foo{{ bar }}baz' + assert _swap_curly('foo{ bar}baz') == 'foo{{ bar}}baz' + assert _swap_curly('foo{bar }baz') == 'foo{{bar }}baz' + assert _swap_curly('foo{{ bar }}baz') == 'foo{bar}baz' + assert _swap_curly('foo{{bar }}baz') == 'foo{bar}baz' + assert _swap_curly('foo{{ bar}}baz') == 'foo{bar}baz' + + +def test_format(): + """Test format function""" + assert template('foo {{ baz }}', baz='bar') == 'foo bar' + with raises(KeyError): + assert template('foo {{ baz }}') == 'foo baz' + + class Object(object): + pass + obj = Object() + obj.a = 1 + obj.b = True + obj.c = '3' + assert template( + 'foo {{ o.a }} {{o.b}}-{{o.c}}', + o=obj) == 'foo 1 True-3' + + +def test_truncate(): + """Test truncate function""" + assert truncate('1234567890', 50) == '1234567890' + assert truncate('1234567890', 5) == u('1234…') + assert truncate('1234567890', 1) == u('…') + assert truncate('1234567890', 9) == u('12345678…') + assert truncate('1234567890', 10) == '1234567890' + assert truncate('1234567890', 0) == '1234567890' + assert truncate('1234567890', -1) == '1234567890' + + +def test_minify_css(): + """Test css minifier function""" + css = ''' +/* + * Font-sizes from config, override with care + */ + +.title { + font-family: sans; + + font-size: 12 ; +} + +.legends .legend text { + font-family: monospace; + font-size: 14 ;} +''' + assert minify_css(css) == ( + '.title{font-family:sans;font-size:12}' + '.legends .legend text{font-family:monospace;font-size:14}') + + +def test_majorize(): + """Test majorize function""" + assert majorize(()) == [] + assert majorize((0,)) == [] + assert majorize((0, 1)) == [] + assert majorize((0, 1, 2)) == [] + assert majorize((-1, 0, 1, 2)) == [0] + assert majorize((0, .1, .2, .3, .4, .5, .6, .7, .8, .9, 1)) == [0, .5, 1] + assert majorize((0, .2, .4, .6, .8, 1)) == [0, 1] + assert majorize((-.4, -.2, 0, .2, .4, .6, .8, 1)) == [0, 1] + assert majorize( + (-1, -.8, -.6, -.4, -.2, 0, .2, .4, .6, .8, 1)) == [-1, 0, 1] + assert majorize((0, .2, .4, .6, .8, 1, 1.2, 1.4, 1.6)) == [0, 1] + assert majorize((0, .2, .4, .6, .8, 1, 1.2, 1.4, 1.6, 1.8, 2)) == [0, 1, 2] + assert majorize( + (0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120)) == [0, 50, 100] + assert majorize( + (0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, + 22, 24, 26, 28, 30, 32, 34, 36)) == [0, 10, 20, 30] + assert majorize((0, 1, 2, 3, 4, 5)) == [0, 5] + assert majorize((-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5)) == [-5, 0, 5] + assert majorize((-5, 5, -4, 4, 0, 1, -1, 3, -2, 2, -3)) == [-5, 0, 5] + assert majorize((0, 1, 2, 3, 4)) == [0] + assert majorize((3, 4, 5, 6)) == [5] + assert majorize((0, 1, 2, 3, 4, 5, 6, 7, 8)) == [0, 5] + assert majorize((-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5)) == [-5, 0, 5] + assert majorize((-6, -5, -4, -3, -2, -1, 0, 1, 2, 3)) == [-5, 0] + assert majorize((-6, -5, -4, -3)) == [-5] + assert majorize((1, 10, 100, 1000, 10000, 100000)) == [] + assert majorize(range(30, 70, 5)) == [30, 40, 50, 60] + assert majorize(range(20, 55, 2)) == [20, 30, 40, 50] + assert majorize(range(21, 83, 3)) == [30, 45, 60, 75] + # TODO: handle crazy cases + # assert majorize(range(20, 83, 3)) == [20, 35, 50, 65, 80] + + +def test_mergextend(): + """Test mergextend function""" + assert mergextend(['a', 'b'], ['c', 'd']) == ['a', 'b'] + assert mergextend([], ['c', 'd']) == [] + assert mergextend(['a', 'b'], []) == ['a', 'b'] + + assert mergextend([_ellipsis], ['c', 'd']) == ['c', 'd'] + assert mergextend([_ellipsis, 'b'], ['c', 'd']) == ['c', 'd', 'b'] + assert mergextend(['a', _ellipsis], ['c', 'd']) == ['a', 'c', 'd'] + assert mergextend(['a', _ellipsis, 'b'], ['c', 'd']) == [ + 'a', 'c', 'd', 'b'] + + if sys.version_info[0] >= 3: + # For @#! sake it's 2016 now + assert eval("mergextend(['a', ..., 'b'], ['c', 'd'])") == [ + 'a', 'c', 'd', 'b'] diff --git a/pygal/test/test_view.py b/pygal/test/test_view.py new file mode 100644 index 0000000..db325ad --- /dev/null +++ b/pygal/test/test_view.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + + +"""View related tests""" + +# TODO + + +def test_all_logarithmic(Chart): + """Test logarithmic view rendering""" + chart = Chart(logarithmic=True) + chart.add('1', [1, 30, 8, 199, -23]) + chart.add('2', [87, 42, .9, 189, 81]) + assert chart.render() diff --git a/pygal/test/test_xml_filters.py b/pygal/test/test_xml_filters.py new file mode 100644 index 0000000..86aff3c --- /dev/null +++ b/pygal/test/test_xml_filters.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Xml filter tests""" + +from pygal import Bar + + +class ChangeBarsXMLFilter(object): + + """xml filter that insert a subplot""" + + def __init__(self, a, b): + """Generate data""" + self.data = [b[i] - a[i] for i in range(len(a))] + + def __call__(self, T): + """Apply the filter on the tree""" + subplot = Bar(legend_at_bottom=True, explicit_size=True, + width=800, height=150) + subplot.add("Difference", self.data) + subplot = subplot.render_tree() + subplot = subplot.findall("g")[0] + T.insert(2, subplot) + T.findall("g")[1].set('transform', 'translate(0,150), scale(1,0.75)') + return T + + +def test_xml_filters_round_trip(): + """Ensure doing nothing does nothing""" + plot = Bar() + plot.add("A", [60, 75, 80, 78, 83, 90]) + plot.add("B", [92, 87, 81, 73, 68, 55]) + before = plot.render() + plot.add_xml_filter(lambda T: T) + after = plot.render() + assert before == after + + +def test_xml_filters_change_bars(): + """Test the use a xml filter""" + plot = Bar(legend_at_bottom=True, explicit_size=True, + width=800, height=600) + A = [60, 75, 80, 78, 83, 90] + B = [92, 87, 81, 73, 68, 55] + plot.add("A", A) + plot.add("B", B) + plot.add_xml_filter(ChangeBarsXMLFilter(A, B)) + q = plot.render_tree() + assert len(q.findall("g")) == 2 + assert q.findall("g")[1].attrib[ + "transform"] == "translate(0,150), scale(1,0.75)" diff --git a/pygal/test/utils.py b/pygal/test/utils.py new file mode 100644 index 0000000..da6a3bf --- /dev/null +++ b/pygal/test/utils.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Tests helpers""" + +from pyquery import PyQuery as pq + + +def texts(i, e): + """Helper for getting the text of an element""" + return pq(e).text() diff --git a/pygal/util.py b/pygal/util.py new file mode 100644 index 0000000..4d284a8 --- /dev/null +++ b/pygal/util.py @@ -0,0 +1,386 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Various utility functions""" + +from __future__ import division + +import re +from decimal import Decimal +from math import ceil, cos, floor, log10, pi, sin + +from pygal._compat import _ellipsis, to_unicode, u + + +def float_format(number): + """Format a float to a precision of 3, without zeroes or dots""" + return ("%.3f" % number).rstrip('0').rstrip('.') + + +def majorize(values): + """Filter sequence to return only major considered numbers""" + sorted_values = sorted(values) + if len(values) <= 3 or ( + abs(2 * sorted_values[1] - sorted_values[0] - sorted_values[2]) > + abs(1.5 * (sorted_values[1] - sorted_values[0]))): + return [] + values_step = sorted_values[1] - sorted_values[0] + full_range = sorted_values[-1] - sorted_values[0] + step = 10 ** int(log10(full_range)) + if step == values_step: + step *= 10 + step_factor = 10 ** (int(log10(step)) + 1) + if round(step * step_factor) % (round(values_step * step_factor) or 1): + # TODO: Find lower common multiple instead + step *= values_step + if full_range <= 2 * step: + step *= .5 + elif full_range >= 5 * step: + step *= 5 + major_values = [ + value for value in values if value / step == round(value / step)] + return [value for value in sorted_values if value in major_values] + + +def round_to_int(number, precision): + """Round a number to a precision""" + precision = int(precision) + rounded = (int(number) + precision / 2) // precision * precision + return rounded + + +def round_to_float(number, precision): + """Round a float to a precision""" + rounded = Decimal( + str(floor((number + precision / 2) // precision)) + ) * Decimal(str(precision)) + return float(rounded) + + +def round_to_scale(number, precision): + """Round a number or a float to a precision""" + if precision < 1: + return round_to_float(number, precision) + return round_to_int(number, precision) + + +def cut(list_, index=0): + """Cut a list by index or arg""" + if isinstance(index, int): + cut_ = lambda x: x[index] + else: + cut_ = lambda x: getattr(x, index) + return list(map(cut_, list_)) + + +def rad(degrees): + """Convert degrees in radiants""" + return pi * degrees / 180 + + +def deg(radiants): + """Convert radiants in degrees""" + return 180 * radiants / pi + + +def _swap_curly(string): + """Swap single and double curly brackets""" + return (string + .replace('{{ ', '{{') + .replace('{{', '\x00') + .replace('{', '{{') + .replace('\x00', '{') + .replace(' }}', '}}') + .replace('}}', '\x00') + .replace('}', '}}') + .replace('\x00', '}')) + + +def template(string, **kwargs): + """Format a string using double braces""" + return _swap_curly(string).format(**kwargs) + + +swap = lambda tuple_: tuple(reversed(tuple_)) +ident = lambda x: x + + +def compute_logarithmic_scale(min_, max_, min_scale, max_scale): + """Compute an optimal scale for logarithmic""" + if max_ <= 0 or min_ <= 0: + return [] + min_order = int(floor(log10(min_))) + max_order = int(ceil(log10(max_))) + positions = [] + amplitude = max_order - min_order + if amplitude <= 1: + return [] + detail = 10. + while amplitude * detail < min_scale * 5: + detail *= 2 + while amplitude * detail > max_scale * 3: + detail /= 2 + for order in range(min_order, max_order + 1): + for i in range(int(detail)): + tick = (10 * i / detail or 1) * 10 ** order + tick = round_to_scale(tick, tick) + if min_ <= tick <= max_ and tick not in positions: + positions.append(tick) + return positions + + +def compute_scale( + min_, max_, logarithmic, order_min, + min_scale, max_scale): + """Compute an optimal scale between min and max""" + if min_ == 0 and max_ == 0: + return [0] + if max_ - min_ == 0: + return [min_] + if logarithmic: + log_scale = compute_logarithmic_scale( + min_, max_, min_scale, max_scale) + if log_scale: + return log_scale + # else we fallback to normal scalling + + order = round(log10(max(abs(min_), abs(max_)))) - 1 + if order_min is not None and order < order_min: + order = order_min + else: + while ((max_ - min_) / (10 ** order) < min_scale and + (order_min is None or order > order_min)): + order -= 1 + step = float(10 ** order) + while (max_ - min_) / step > max_scale: + step *= 2. + positions = [] + position = round_to_scale(min_, step) + while position < (max_ + step): + rounded = round_to_scale(position, step) + if min_ <= rounded <= max_: + if rounded not in positions: + positions.append(rounded) + position += step + if len(positions) < 2: + return [min_, max_] + return positions + + +def text_len(length, fs): + """Approximation of text width""" + return length * 0.6 * fs + + +def reverse_text_len(width, fs): + """Approximation of text length""" + return int(width / (0.6 * fs)) + + +def get_text_box(text, fs): + """Approximation of text bounds""" + return (fs, text_len(len(text), fs)) + + +def get_texts_box(texts, fs): + """Approximation of multiple texts bounds""" + max_len = max(map(len, texts)) + return (fs, text_len(max_len, fs)) + + +def decorate(svg, node, metadata): + """Add metedata next to a node""" + if not metadata: + return node + xlink = metadata.get('xlink') + if xlink: + if not isinstance(xlink, dict): + xlink = {'href': xlink, 'target': '_blank'} + node = svg.node(node, 'a', **xlink) + svg.node(node, 'desc', class_='xlink').text = to_unicode( + xlink.get('href')) + + if 'tooltip' in metadata: + svg.node(node, 'title').text = to_unicode( + metadata['tooltip']) + + if 'color' in metadata: + color = metadata.pop('color') + node.attrib['style'] = 'fill: %s; stroke: %s' % ( + color, color) + + if 'style' in metadata: + node.attrib['style'] = metadata.pop('style') + + if 'label' in metadata: + svg.node(node, 'desc', class_='label').text = to_unicode( + metadata['label']) + return node + + +def alter(node, metadata): + """Override nodes attributes from metadata node mapping""" + if node is not None and metadata and 'node' in metadata: + node.attrib.update( + dict((k, str(v)) for k, v in metadata['node'].items())) + + +def truncate(string, index): + """Truncate a string at index and add ...""" + if len(string) > index and index > 0: + string = string[:index - 1] + u('…') + return string + + +# # Stolen partly from brownie http://packages.python.org/Brownie/ +class cached_property(object): + + """Memoize a property""" + + def __init__(self, getter, doc=None): + """Initialize the decorator""" + self.getter = getter + self.__module__ = getter.__module__ + self.__name__ = getter.__name__ + self.__doc__ = doc or getter.__doc__ + + def __get__(self, obj, type_=None): + """ + Get descriptor calling the property function and replacing it with + its value or on state if we are in the transient state. + """ + if obj is None: + return self + value = self.getter(obj) + if hasattr(obj, 'state'): + setattr(obj.state, self.__name__, value) + else: + obj.__dict__[self.__name__] = self.getter(obj) + return value + + +css_comments = re.compile(r'/\*.*?\*/', re.MULTILINE | re.DOTALL) + + +def minify_css(css): + """Little css minifier""" + # Inspired by slimmer by Peter Bengtsson + remove_next_comment = 1 + for css_comment in css_comments.findall(css): + if css_comment[-3:] == '\*/': + remove_next_comment = 0 + continue + if remove_next_comment: + css = css.replace(css_comment, '') + else: + remove_next_comment = 1 + + # >= 2 whitespace becomes one whitespace + css = re.sub(r'\s\s+', ' ', css) + # no whitespace before end of line + css = re.sub(r'\s+\n', '', css) + # Remove space before and after certain chars + for char in ('{', '}', ':', ';', ','): + css = re.sub(char + r'\s', char, css) + css = re.sub(r'\s' + char, char, css) + css = re.sub(r'}\s(#|\w)', r'}\1', css) + # no need for the ; before end of attributes + css = re.sub(r';}', r'}', css) + css = re.sub(r'}//-->', r'}\n//-->', css) + return css.strip() + + +def compose(f, g): + """Chain functions""" + fun = lambda *args, **kwargs: f(g(*args, **kwargs)) + fun.__name__ = "%s o %s" % (f.__name__, g.__name__) + return fun + + +def safe_enumerate(iterable): + """Enumerate which does not yield None values""" + for i, v in enumerate(iterable): + if v is not None: + yield i, v + + +def split_title(title, width, title_fs): + """Split a string for a specified width and font size""" + titles = [] + if not title: + return titles + size = reverse_text_len(width, title_fs * 1.1) + title_lines = title.split("\n") + for title_line in title_lines: + while len(title_line) > size: + title_part = title_line[:size] + i = title_part.rfind(' ') + if i == -1: + i = len(title_part) + titles.append(title_part[:i]) + title_line = title_line[i:].strip() + titles.append(title_line) + return titles + + +def filter_kwargs(fun, kwargs): + if not hasattr(fun, '__code__'): + return {} + args = fun.__code__.co_varnames[1:] + return dict((k, v) for k, v in kwargs.items() if k in args) + + +def coord_project(rho, alpha): + return rho * sin(-alpha), rho * cos(-alpha) + + +def coord_diff(x, y): + return (x[0] - y[0], x[1] - y[1]) + + +def coord_format(x): + return '%f %f' % x + + +def coord_dual(r): + return coord_format((r, r)) + + +def coord_abs_project(center, rho, theta): + return coord_format(coord_diff(center, coord_project(rho, theta))) + + +def mergextend(list1, list2): + if list1 is None or _ellipsis not in list1: + return list1 + index = list1.index(_ellipsis) + return list(list1[:index]) + list(list2) + list(list1[index + 1:]) + + +def merge(dict1, dict2): + from pygal.config import CONFIG_ITEMS, Key + _list_items = [item.name for item in CONFIG_ITEMS if item.type == list] + for key, val in dict2.items(): + if isinstance(val, Key): + val = val.value + + if key in _list_items: + dict1[key] = mergextend(val, dict1.get(key, ())) + else: + dict1[key] = val diff --git a/pygal/view.py b/pygal/view.py new file mode 100644 index 0000000..9360a58 --- /dev/null +++ b/pygal/view.py @@ -0,0 +1,423 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . + +"""Projection and bounding helpers""" + +from __future__ import division + +from math import cos, log10, pi, sin + + +class Margin(object): + + """Class reprensenting a margin (top, right, left, bottom)""" + + def __init__(self, top, right, bottom, left): + """Create the margin object from the top, right, left, bottom margin""" + self.top = top + self.right = right + self.bottom = bottom + self.left = left + + @property + def x(self): + """Helper for total x margin""" + return self.left + self.right + + @property + def y(self): + """Helper for total y margin""" + return self.top + self.bottom + + +class Box(object): + + """Chart boundings""" + + margin = .02 + + def __init__(self, xmin=0, ymin=0, xmax=1, ymax=1): + """ + Create the chart bounds with min max horizontal + and vertical values + """ + self._xmin = xmin + self._ymin = ymin + self._xmax = xmax + self._ymax = ymax + + def set_polar_box(self, rmin=0, rmax=1, tmin=0, tmax=2 * pi): + """Helper for polar charts""" + self._rmin = rmin + self._rmax = rmax + self._tmin = tmin + self._tmax = tmax + self.xmin = self.ymin = rmin - rmax + self.xmax = self.ymax = rmax - rmin + + @property + def xmin(self): + """X minimum getter""" + return self._xmin + + @xmin.setter + def xmin(self, value): + """X minimum setter""" + if value is not None: + self._xmin = value + + @property + def ymin(self): + """Y minimum getter""" + return self._ymin + + @ymin.setter + def ymin(self, value): + """Y minimum setter""" + if value is not None: + self._ymin = value + + @property + def xmax(self): + """X maximum getter""" + return self._xmax + + @xmax.setter + def xmax(self, value): + """X maximum setter""" + if value is not None: + self._xmax = value + + @property + def ymax(self): + """Y maximum getter""" + return self._ymax + + @ymax.setter + def ymax(self, value): + """Y maximum setter""" + if value or self.ymin: + self._ymax = value + + @property + def width(self): + """Helper for box width""" + return self.xmax - self.xmin + + @property + def height(self): + """Helper for box height""" + return self.ymax - self.ymin + + def swap(self): + """Return the box (for horizontal graphs)""" + self.xmin, self.ymin = self.ymin, self.xmin + self.xmax, self.ymax = self.ymax, self.xmax + + def fix(self, with_margin=True): + """Correct box when no values and take margin in account""" + if not self.width: + self.xmax = self.xmin + 1 + if not self.height: + self.ymin /= 2 + self.ymax += self.ymin + xmargin = self.margin * self.width + self.xmin -= xmargin + self.xmax += xmargin + if with_margin: + ymargin = self.margin * self.height + self.ymin -= ymargin + self.ymax += ymargin + + +class View(object): + + """Projection base class""" + + def __init__(self, width, height, box): + """Create the view with a width an height and a box bounds""" + self.width = width + self.height = height + self.box = box + self.box.fix() + + def x(self, x): + """Project x""" + if x is None: + return None + return self.width * (x - self.box.xmin) / self.box.width + + def y(self, y): + """Project y""" + if y is None: + return None + return (self.height - self.height * + (y - self.box.ymin) / self.box.height) + + def __call__(self, xy): + """Project x and y""" + x, y = xy + return (self.x(x), self.y(y)) + + +class ReverseView(View): + + """Same as view but reversed vertically""" + + def y(self, y): + """Project reversed y""" + if y is None: + return None + return (self.height * (y - self.box.ymin) / self.box.height) + + +class HorizontalView(View): + + """Same as view but transposed""" + + def __init__(self, width, height, box): + """Create the view with a width an height and a box bounds""" + self._force_vertical = None + self.width = width + self.height = height + + self.box = box + self.box.fix() + self.box.swap() + + def x(self, x): + """Project x as y""" + if x is None: + return None + if self._force_vertical: + return super(HorizontalView, self).x(x) + return super(HorizontalView, self).y(x) + + def y(self, y): + """Project y as x""" + if y is None: + return None + if self._force_vertical: + return super(HorizontalView, self).y(y) + return super(HorizontalView, self).x(y) + + +class PolarView(View): + + """Polar projection for pie like graphs""" + + def __call__(self, rhotheta): + """Project rho and theta""" + if None in rhotheta: + return None, None + rho, theta = rhotheta + return super(PolarView, self).__call__( + (rho * cos(theta), rho * sin(theta))) + + +class PolarLogView(View): + + """Logarithmic polar projection""" + + def __init__(self, width, height, box): + """Create the view with a width an height and a box bounds""" + super(PolarLogView, self).__init__(width, height, box) + if not hasattr(box, '_rmin') or not hasattr(box, '_rmax'): + raise Exception( + 'Box must be set with set_polar_box for polar charts') + + self.log10_rmax = log10(self.box._rmax) + self.log10_rmin = log10(self.box._rmin) + if self.log10_rmin == self.log10_rmax: + self.log10_rmax = self.log10_rmin + 1 + + def __call__(self, rhotheta): + """Project rho and theta""" + if None in rhotheta: + return None, None + rho, theta = rhotheta + # Center case + if rho == 0: + return super(PolarLogView, self).__call__((0, 0)) + rho = (self.box._rmax - self.box._rmin) * ( + log10(rho) - self.log10_rmin) / ( + self.log10_rmax - self.log10_rmin) + return super(PolarLogView, self).__call__( + (rho * cos(theta), rho * sin(theta))) + + +class PolarThetaView(View): + + """Logarithmic polar projection""" + + def __init__(self, width, height, box, aperture=pi / 3): + """Create the view with a width an height and a box bounds""" + super(PolarThetaView, self).__init__(width, height, box) + self.aperture = aperture + if not hasattr(box, '_tmin') or not hasattr(box, '_tmax'): + raise Exception( + 'Box must be set with set_polar_box for polar charts') + + def __call__(self, rhotheta): + """Project rho and theta""" + if None in rhotheta: + return None, None + rho, theta = rhotheta + start = 3 * pi / 2 + self.aperture / 2 + theta = start + (2 * pi - self.aperture) * ( + theta - self.box._tmin) / ( + self.box._tmax - self.box._tmin) + return super(PolarThetaView, self).__call__( + (rho * cos(theta), rho * sin(theta))) + + +class PolarThetaLogView(View): + + """Logarithmic polar projection""" + + def __init__(self, width, height, box, aperture=pi / 3): + """Create the view with a width an height and a box bounds""" + super(PolarThetaLogView, self).__init__(width, height, box) + self.aperture = aperture + if not hasattr(box, '_tmin') or not hasattr(box, '_tmax'): + raise Exception( + 'Box must be set with set_polar_box for polar charts') + self.log10_tmax = log10(self.box._tmax) if self.box._tmax > 0 else 0 + self.log10_tmin = log10(self.box._tmin) if self.box._tmin > 0 else 0 + if self.log10_tmin == self.log10_tmax: + self.log10_tmax = self.log10_tmin + 1 + + def __call__(self, rhotheta): + """Project rho and theta""" + if None in rhotheta: + return None, None + rho, theta = rhotheta + # Center case + if theta == 0: + return super(PolarThetaLogView, self).__call__((0, 0)) + theta = self.box._tmin + (self.box._tmax - self.box._tmin) * ( + log10(theta) - self.log10_tmin) / ( + self.log10_tmax - self.log10_tmin) + + start = 3 * pi / 2 + self.aperture / 2 + theta = start + (2 * pi - self.aperture) * ( + theta - self.box._tmin) / ( + self.box._tmax - self.box._tmin) + + return super(PolarThetaLogView, self).__call__( + (rho * cos(theta), rho * sin(theta))) + + +class LogView(View): + + """Y Logarithmic projection""" + + # Do not want to call the parent here + def __init__(self, width, height, box): + """Create the view with a width an height and a box bounds""" + self.width = width + self.height = height + self.box = box + self.log10_ymax = log10(self.box.ymax) if self.box.ymax > 0 else 0 + self.log10_ymin = log10(self.box.ymin) if self.box.ymin > 0 else 0 + if self.log10_ymin == self.log10_ymax: + self.log10_ymax = self.log10_ymin + 1 + self.box.fix(False) + + def y(self, y): + """Project y""" + if y is None or y <= 0 or self.log10_ymax - self.log10_ymin == 0: + return 0 + return (self.height - self.height * + (log10(y) - self.log10_ymin) / ( + self.log10_ymax - self.log10_ymin)) + + +class XLogView(View): + + """X logarithmic projection""" + + # Do not want to call the parent here + def __init__(self, width, height, box): + """Create the view with a width an height and a box bounds""" + self.width = width + self.height = height + self.box = box + self.log10_xmax = log10(self.box.xmax) if self.box.xmax > 0 else 0 + self.log10_xmin = log10(self.box.xmin) if self.box.xmin > 0 else 0 + self.box.fix(False) + + def x(self, x): + """Project x""" + if x is None or x <= 0 or self.log10_xmax - self.log10_xmin == 0: + return None + return (self.width * + (log10(x) - self.log10_xmin) / + (self.log10_xmax - self.log10_xmin)) + + +class XYLogView(XLogView, LogView): + + """X and Y logarithmic projection""" + + def __init__(self, width, height, box): + """Create the view with a width an height and a box bounds""" + self.width = width + self.height = height + self.box = box + self.log10_ymax = log10(self.box.ymax) if self.box.ymax > 0 else 0 + self.log10_ymin = log10(self.box.ymin) if self.box.ymin > 0 else 0 + self.log10_xmax = log10(self.box.xmax) if self.box.xmax > 0 else 0 + self.log10_xmin = log10(self.box.xmin) if self.box.xmin > 0 else 0 + self.box.fix(False) + + +class HorizontalLogView(XLogView): + + """Transposed Logarithmic projection""" + + # Do not want to call the parent here + def __init__(self, width, height, box): + """Create the view with a width an height and a box bounds""" + self._force_vertical = None + self.width = width + self.height = height + self.box = box + self.log10_xmax = log10(self.box.ymax) if self.box.ymax > 0 else 0 + self.log10_xmin = log10(self.box.ymin) if self.box.ymin > 0 else 0 + if self.log10_xmin == self.log10_xmax: + self.log10_xmax = self.log10_xmin + 1 + self.box.fix(False) + self.box.swap() + + def x(self, x): + """Project x as y""" + if x is None: + return None + if self._force_vertical: + return super(HorizontalLogView, self).x(x) + return super(XLogView, self).y(x) + + def y(self, y): + """Project y as x""" + if y is None: + return None + if self._force_vertical: + return super(XLogView, self).y(y) + return super(HorizontalLogView, self).x(y) diff --git a/pygal_gen.py b/pygal_gen.py new file mode 100755 index 0000000..98e0d4b --- /dev/null +++ b/pygal_gen.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +import argparse + +import pygal + +parser = argparse.ArgumentParser( + description='Generate pygal chart in command line', + prog='pygal_gen') + +parser.add_argument('-t', '--type', dest='type', default='Line', + choices=map(lambda x: x.__name__, pygal.CHARTS), + help='Kind of chart to generate') + +parser.add_argument('-o', '--output', dest='filename', default='pygal_out.svg', + help='Filename to write the svg to') + +parser.add_argument('-s', '--serie', dest='series', nargs='+', action='append', + help='Add a serie in the form (title val1 val2...)') + +parser.add_argument('--version', action='version', + version='pygal %s' % pygal.__version__) + +for key in pygal.config.CONFIG_ITEMS: + opt_name = key.name + val = key.value + opts = {} + if key.type == list: + opts['type'] = key.subtype + opts['nargs'] = '+' + else: + opts['type'] = key.type + + if opts['type'] == bool: + del opts['type'] + opts['action'] = 'store_true' if not val else 'store_false' + if val: + opt_name = 'no-' + opt_name + if key.name == 'interpolate': + opts['choices'] = list(pygal.interpolate.INTERPOLATIONS.keys()) + parser.add_argument( + '--%s' % opt_name, dest=key.name, default=val, **opts) + +config = parser.parse_args() + +chart = getattr(pygal, config.type)(**vars(config)) + +for serie in config.series: + chart.add(serie[0], map(float, serie[1:])) + +chart.render_to_file(config.filename) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..7338fd7 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,14 @@ +[wheel] +universal = 1 + +[pytest] +flake8-ignore = + *.py E731 E402 + pygal/__init__.py F401 + pygal/_compat.py F821 F401 + docs/conf.py ALL + +[egg_info] +tag_build = +tag_date = 0 + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c0f2dd3 --- /dev/null +++ b/setup.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2016 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 . +import os +import sys + +from setuptools import find_packages, setup +from setuptools.command.test import test as TestCommand + + +class PyTest(TestCommand): + def finalize_options(self): + TestCommand.finalize_options(self) + self.test_args = ['-x', 'build/lib/pygal'] + self.test_suite = True + + def run_tests(self): + # import here, cause outside the eggs aren't loaded + import pytest + errno = pytest.main(self.test_args) + sys.exit(errno) + + +ROOT = os.path.dirname(__file__) + + +# Explicitly specify the encoding of pygal/__init__.py if we're on py3. +kwargs = {} +if sys.version_info[0] == 3: + kwargs['encoding'] = 'utf-8' + cairosvg = 'cairosvg' +else: + cairosvg = 'cairosvg==0.5' + +tests_requirements = [ + "pyquery", "flask", cairosvg, 'lxml', 'pygal_maps_world', 'pygal_maps_fr', + 'pygal_maps_ch', 'coveralls', + 'pytest-runner', 'pytest-cov', 'pytest-flake8', 'pytest-isort', + 'pytest' +] + +about = {} +with open(os.path.join( + os.path.dirname(__file__), "pygal", "__about__.py")) as f: + exec(f.read(), about) + +setup( + name=about['__title__'], + version=about['__version__'], + description=about['__summary__'], + url=about['__uri__'], + author=about['__author__'], + author_email=about['__email__'], + license=about['__license__'], + platforms="Any", + packages=find_packages(), + provides=['pygal'], + scripts=["pygal_gen.py"], + keywords=[ + "svg", "chart", "graph", "diagram", "plot", "histogram", "kiviat"], + setup_requires=['pytest-runner'], + test_requires=tests_requirements, + cmdclass={'test': PyTest}, + package_data={'pygal': ['css/*', 'graph/maps/*.svg']}, + extras_require={ + 'lxml': ['lxml'], + 'docs': ['sphinx', 'sphinx_rtd_theme', 'pygal_sphinx_directives'], + 'png': [cairosvg], + 'test': tests_requirements + }, + classifiers=[ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: " + "GNU Lesser General Public License v3 or later (LGPLv3+)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 3", + "Topic :: Multimedia :: Graphics :: Presentation"])