diff --git a/demo/simple_test.py b/demo/simple_test.py index a1f320e..c2b2813 100755 --- a/demo/simple_test.py +++ b/demo/simple_test.py @@ -28,7 +28,7 @@ dot = Dot() dot.x_labels = map(str, range(4)) dot.add('a', [1, lnk(3, 'Foo'), 5, 3]) -dot.add('b', [2, -2, 0, 2]) +dot.add('b', [2, 2, 0, 2]) dot.add('c', [5, 1, 5, lnk(3, 'Bar')]) dot.add('d', [5, 5, lnk(0, 'Babar'), 3]) @@ -45,7 +45,8 @@ bar.add('1234', [ bar.add('4321', [40, {'value': 30, 'label': 'Thirty', 'xlink': 'http://google.com?q=30'}, 20, 10]) bar.x_labels = map(str, range(1, 5)) - +bar.logarithmic = True +bar.zero = 1 # bar.included_js = [] # bar.external_js = [ # 'http://localhost:7575/svg.jquery.js', @@ -163,7 +164,7 @@ config.x_labels = ( 'black', 'red', 'blue', 'yellow', 'orange', 'green', 'white') # config.interpolate = 'nearest' radar = Radar(config) -radar.add('test', [1, 4, lnk(10), 5, None, -2, 5]) +radar.add('test', [1, 4, lnk(10), 5, None, 2, 5]) radar.add('test2', [10, 2, 0, 5, 1, 9, 4]) radar.title = "Radar test" diff --git a/pygal/config.py b/pygal/config.py index 28bb737..0fd2410 100644 --- a/pygal/config.py +++ b/pygal/config.py @@ -21,7 +21,6 @@ Config module with all options """ -import os from pygal.style import DefaultStyle @@ -40,11 +39,10 @@ class Config(object): human_readable = False #: Display values in logarithmic scale logarithmic = False - #: If set to a filename, this will replace the default css - base_css = None - #: or default js - included_js = [] - external_js = [ + #: List of css file, can be an absolute file path or an external link + css = ['style.css', 'graph.css'] # Relative path to pygal css + #: List of js file, can be a filepath or an external link + js = [ 'https://raw.github.com/Kozea/pygal.js/master/svg.jquery.js', 'https://raw.github.com/Kozea/pygal.js/master/pygal-tooltips.js' ] diff --git a/pygal/css/base.css b/pygal/css/base.css new file mode 100644 index 0000000..5391838 --- /dev/null +++ b/pygal/css/base.css @@ -0,0 +1,48 @@ +/* + * 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 + */ + +.title { + font-family: monospace; + font-size: {{ font_sizes.title }}; +} + +.legends .legend text { + font-family: monospace; + font-size: {{ font_sizes.legend }}; +} + +.axis text { + font-family: monospace; + font-size: {{ font_sizes.label }}; +} + +.series text { + font-family: monospace; + font-size: {{ font_sizes.value }}; +} + +#tooltip text { + font-family: monospace; + font-size: {{ font_sizes.tooltip }}; +} diff --git a/pygal/css/graph.css b/pygal/css/graph.css index 668d0f5..6e65b12 100644 --- a/pygal/css/graph.css +++ b/pygal/css/graph.css @@ -18,30 +18,7 @@ * along with pygal. If not, see . */ -svg { - background-color: {{ style.background }}; -} - -svg * { - -webkit-transition: {{ style.transition }}; - -moz-transition: {{ style.transition }}; - transition: {{ style.transition }}; -} - -.graph > .background { - fill: {{ style.background }}; -} - -.plot > .background { - fill: {{ style.plot_background }}; -} - -.graph { - fill: {{ style.foreground }}; -} - text.no_data { - fill: {{ style.foreground_light }}; text-anchor: middle; } @@ -54,29 +31,14 @@ text.no_data { } .title { - fill: {{ style.foreground_light }}; - font-size: {{ font_sizes.title }}; text-anchor: middle; } .legends .legend text { - font-family: monospace; - font-size: {{ font_sizes.legend }}; - fill: {{ style.foreground }}; fill-opacity: 1; } -.legends .legend:hover text { - fill: {{ style.foreground_light }}; -} - -.axis text { - font-size: {{ font_sizes.label }}; - font-family: sans; -} - .axis.x text { - font-family: monospace; text-anchor: middle; } @@ -85,7 +47,6 @@ text.no_data { } .axis.y text { - font-family: monospace; text-anchor: end; } @@ -93,26 +54,19 @@ text.no_data { font-size: 50%; } -.axis .line { - stroke: {{ style.foreground_light }}; -} - .axis .guide.line { - stroke: {{ style.foreground_dark }}; stroke-dasharray: 4,4; } .axis .major.line { - stroke: {{ style.foreground }}; stroke-dasharray: 6,6; } .axis text.major { stroke-width: 0.5px; - stroke: {{ style.foreground_light }}; - fill: {{ style.foreground_light }}; } -.axis.{{ hidden }} .guide.line { +.horizontal .axis.y .guide.line, +.vertical .axis.x .guide.line { opacity: 0; } @@ -120,12 +74,10 @@ text.no_data { .line-graph .axis.x .guides:hover .guide.line, .stackedline-graph .axis.x .guides:hover .guide.line, .xy-graph .axis.x .guides:hover .guide.line { - stroke: {{ style.foreground_light }}; opacity: 1; } .axis .guides:hover text { - fill: {{ style.foreground_light }}; opacity: 1; } @@ -133,14 +85,6 @@ text.no_data { fill: none; } -.reactive { - fill-opacity: {{ style.opacity }}; -} - -.reactive.active, .active .reactive { - fill-opacity: {{ style.opacity_hover }}; -} - .dot { stroke-width: 1px; fill-opacity: 1; @@ -151,9 +95,7 @@ text.no_data { } .series text { - font-size: {{ font_sizes.value }}; stroke: none; - fill: {{ style.foreground_light }}; } .series text.active { @@ -162,14 +104,10 @@ text.no_data { #tooltip rect { fill-opacity: 0.8; - fill: {{ style.plot_background }}; - stroke: {{ style.foreground_light }}; } #tooltip text { fill-opacity: 1; - fill: {{ style.foreground_light }}; - font-size: {{ font_sizes.tooltip }}; } #tooltip text tspan.label { @@ -179,5 +117,3 @@ text.no_data { a:visited { fill: none; } - -{{ style.colors }} diff --git a/pygal/css/style.css b/pygal/css/style.css new file mode 100644 index 0000000..25824b9 --- /dev/null +++ b/pygal/css/style.css @@ -0,0 +1,112 @@ +/* + * 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 + */ + +svg { + background-color: {{ style.background }}; +} + +svg * { + -webkit-transition: {{ style.transition }}; + -moz-transition: {{ style.transition }}; + transition: {{ style.transition }}; +} + +.graph > .background { + fill: {{ style.background }}; +} + +.plot > .background { + fill: {{ style.plot_background }}; +} + +.graph { + fill: {{ style.foreground }}; +} + +text.no_data { + fill: {{ style.foreground_light }}; +} + +.title { + fill: {{ style.foreground_light }}; +} + +.legends .legend text { + fill: {{ style.foreground }}; +} + +.legends .legend:hover text { + fill: {{ style.foreground_light }}; +} + +.axis .line { + stroke: {{ style.foreground_light }}; +} + +.axis .guide.line { + stroke: {{ style.foreground_dark }}; +} + +.axis .major.line { + stroke: {{ style.foreground }}; +} + +.axis text.major { + stroke: {{ style.foreground_light }}; + fill: {{ style.foreground_light }}; +} + +.axis.y .guides:hover .guide.line, +.line-graph .axis.x .guides:hover .guide.line, +.stackedline-graph .axis.x .guides:hover .guide.line, +.xy-graph .axis.x .guides:hover .guide.line { + stroke: {{ style.foreground_light }}; +} + +.axis .guides:hover text { + fill: {{ style.foreground_light }}; +} + +.reactive { + fill-opacity: {{ style.opacity }}; +} + +.reactive.active, .active .reactive { + fill-opacity: {{ style.opacity_hover }}; +} + +.series text { + fill: {{ style.foreground_light }}; +} + +#tooltip rect { + fill: {{ style.plot_background }}; + stroke: {{ style.foreground_light }}; +} + +#tooltip text { + fill: {{ style.foreground_light }}; +} + +{{ style.colors }} diff --git a/pygal/graph/bar.py b/pygal/graph/bar.py index d685227..96be243 100644 --- a/pygal/graph/bar.py +++ b/pygal/graph/bar.py @@ -68,9 +68,9 @@ class Bar(Graph): padding = .1 * width inner_width = width - 2 * padding if self.horizontal: - height = self.view.x(0) - y + height = self.view.x(self.zero) - y else: - height = self.view.y(0) - y + height = self.view.y(self.zero) - y if stack_vals == None: bar_width = inner_width / len(self.series) bar_padding = .1 * bar_width diff --git a/pygal/graph/base.py b/pygal/graph/base.py index b87f90c..9fa7f94 100644 --- a/pygal/graph/base.py +++ b/pygal/graph/base.py @@ -23,7 +23,7 @@ Base for pygal charts from __future__ import division import io -from pygal.serie import Serie, Value +from pygal.serie import Serie, Value, PositiveValue from pygal.view import Margin, Box from pygal.util import get_text_box, get_texts_box, cut, rad, humanize from pygal.svg import Svg @@ -60,6 +60,9 @@ class BaseGraph(object): """(Re-)Init the graph""" self.margin = Margin(*([20] * 4)) self._box = Box() + if self.logarithmic and self.zero == 0: + # If logarithmic, default zero to 1 + self.zero = 1 def __getattr__(self, attr): """Search in config, then in self""" @@ -129,6 +132,7 @@ class BaseGraph(object): def _draw(self): """Draw all the things""" + self._prepare_data() self._compute() self._compute_margin() self._decorate() @@ -142,6 +146,14 @@ class BaseGraph(object): return False return True + def _prepare_data(self): + """Remove aberrant values""" + if self.logarithmic: + for serie in self.series: + for metadata in serie.metadata: + if metadata.value <= 0: + metadata.value = None + def _uniformize_data(self): """Make all series to max len""" for serie in self.series: diff --git a/pygal/graph/graph.py b/pygal/graph/graph.py index 164542d..3b07461 100644 --- a/pygal/graph/graph.py +++ b/pygal/graph/graph.py @@ -55,7 +55,9 @@ class Graph(BaseGraph): def _make_graph(self): """Init common graph svg structure""" self.nodes['graph'] = self.svg.node( - class_='graph %s-graph' % self.__class__.__name__.lower()) + 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, diff --git a/pygal/serie.py b/pygal/serie.py index 176b2d3..0e5f36c 100644 --- a/pygal/serie.py +++ b/pygal/serie.py @@ -50,7 +50,7 @@ class PositiveValue(Value): """Positive or zero value container""" def __init__(self, value): - super(PositiveValue, self).__init__(max(0, value)) + super(PositiveValue, self).__init__(max(value, 0)) class Label(object): diff --git a/pygal/svg.py b/pygal/svg.py index 12d15e3..d5b325b 100644 --- a/pygal/svg.py +++ b/pygal/svg.py @@ -27,6 +27,7 @@ import os import json from lxml import etree from math import cos, sin, pi +from urlparse import urlparse from pygal.util import template, coord_format from pygal import __version__ @@ -37,6 +38,8 @@ class Svg(object): def __init__(self, graph): self.graph = graph + self.processing_instructions = [ + etree.PI('xml', "version='1.0' encoding='utf-8'")] self.root = None self.defs = None @@ -47,24 +50,30 @@ class Svg(object): nsmap={ None: self.ns, 'xlink': 'http://www.w3.org/1999/xlink', - }, - # onload="svg_load(evt);" -) + }) self.root.append(etree.Comment( u'Generated with pygal %s ©Kozea 2012' % __version__)) self.root.append(etree.Comment(u'http://github.com/Kozea/pygal')) self.defs = self.node(tag='defs') - def add_style(self, css): + def add_styles(self): """Add the css to the svg""" - style = self.node(self.defs, 'style', type='text/css') - with io.open(css, encoding='utf-8') as f: - templ = template( - f.read(), - style=self.graph.style, - font_sizes=self.graph.font_sizes(), - hidden='y' if self.graph.horizontal else 'x') - style.text = templ + for css in ['base.css'] + list(self.graph.css): + if urlparse(css).scheme: + self.processing_instructions.append( + etree.PI( + 'xml-stylesheet', 'href="%s"' % css)) + else: + 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: + templ = template( + f.read(), + style=self.graph.style, + font_sizes=self.graph.font_sizes()) + self.node( + self.defs, 'style', type='text/css').text = templ def add_scripts(self): """Add the js to the svg""" @@ -72,13 +81,14 @@ class Svg(object): common_script.text = " = ".join( ("window.config", json.dumps(self.graph.config.to_dict()))) - for external_js in self.graph.external_js: - self.node( - self.defs, 'script', type='text/javascript', href=external_js) - for included_js in self.graph.included_js: - script = self.node(self.defs, 'script', type='text/javascript') - with io.open(included_js, encoding='utf-8') as f: - script.text = f.read() + for js in self.graph.js: + if urlparse(js).scheme: + self.node( + self.defs, 'script', type='text/javascript', href=js) + else: + script = self.node(self.defs, 'script', type='text/javascript') + with io.open(js, encoding='utf-8') as f: + script.text = f.read() def node(self, parent=None, tag='g', attrib=None, **extras): """Make a new svg node""" @@ -160,8 +170,7 @@ class Svg(object): def pre_render(self, no_data=False): """Last things to do before rendering""" - self.add_style(self.graph.base_css or os.path.join( - os.path.dirname(__file__), 'css', 'graph.css')) + self.add_styles() self.add_scripts() self.root.set( 'viewBox', '0 0 %d %d' % (self.graph.width, self.graph.height)) @@ -179,8 +188,12 @@ class Svg(object): """Last thing to do before rendering""" svg = etree.tostring( self.root, pretty_print=True, - xml_declaration=not self.graph.disable_xml_declaration, + xml_declaration=False, encoding='utf-8') + if not self.graph.disable_xml_declaration: + svg = '\n'.join( + [etree.tostring(pi) for pi in self.processing_instructions] + ) + '\n' + svg if self.graph.disable_xml_declaration or is_unicode: svg = svg.decode('utf-8') return svg diff --git a/pygal/test/__init__.py b/pygal/test/__init__.py index 98db20d..ac7e4f2 100644 --- a/pygal/test/__init__.py +++ b/pygal/test/__init__.py @@ -16,3 +16,34 @@ # # You should have received a copy of the GNU Lesser General Public License # along with pygal. If not, see . + +import pygal +from pygal.util import cut + + +def get_data(i): + return [ + [(-1, 1), (2, 0), (0, 4)], + [(0, 1), (None, 2), (3, 2)], + [(-3, 3), (1, 3), (1, 1)], + [(1, 1), (1, 1), (1, 1)], + [(3, 2), (2, 1), (1, 1)]][i] + + +def pytest_generate_tests(metafunc): + 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) + ]) + + +def make_data(chart, datas): + for data in datas: + chart.add(data[0], + data[1] if chart.__class__ == pygal.XY else cut(data[1])) + return chart diff --git a/pygal/test/test_config.py b/pygal/test/test_config.py index daa55c7..ff9e04b 100644 --- a/pygal/test/test_config.py +++ b/pygal/test/test_config.py @@ -16,8 +16,9 @@ # # You should have received a copy of the GNU Lesser General Public License # along with pygal. If not, see . -from pygal import Line +from pygal import Line, Dot, Pie, Radar from pygal.test.utils import texts +from pygal.test import pytest_generate_tests, make_data def test_logarithmic(): diff --git a/pygal/test/test_graph.py b/pygal/test/test_graph.py index dc43e02..88309e9 100644 --- a/pygal/test/test_graph.py +++ b/pygal/test/test_graph.py @@ -17,38 +17,34 @@ # You should have received a copy of the GNU Lesser General Public License # along with pygal. If not, see . import os -from pygal import Line import pygal import uuid +from pygal.util import cut +from pygal.test import pytest_generate_tests, make_data -def test_multi_render(): - for Chart in pygal.CHARTS: - chart = Chart() - rng = range(20) - if Chart == pygal.XY: - rng = zip(rng, rng) - chart.add('Serie', rng) - chart.add('Serie 2', list(reversed(rng))) - svg = chart.render() - for i in range(2): - assert svg == chart.render() +def test_multi_render(Chart, datas): + chart = Chart() + chart = make_data(chart, datas) + svg = chart.render() + for i in range(2): + assert svg == chart.render() -def test_render_to_file(): +def test_render_to_file(Chart, datas): file_name = '/tmp/test_graph-%s.svg' % uuid.uuid4() if os.path.exists(file_name): os.remove(file_name) - line = Line() - line.add('Serie 1', [1]) - line.render_to_file(file_name) + chart = Chart() + chart = make_data(chart, datas) + chart.render_to_file(file_name) with open(file_name) as f: assert 'pygal' in f.read() os.remove(file_name) -def test_render_to_png(): +def test_render_to_png(Chart, datas): try: import cairosvg except ImportError: @@ -58,9 +54,34 @@ def test_render_to_png(): if os.path.exists(file_name): os.remove(file_name) - line = Line() - line.add('Serie 1', [1]) - line.render_to_png(file_name) + chart = Chart() + chart = make_data(chart, datas) + chart.render_to_png(file_name) with open(file_name, 'rb') as f: assert f.read() os.remove(file_name) + + +def test_metadata(Chart): + chart = Chart() + v = range(7) + if Chart == pygal.XY: + v = map(lambda x: (x, x + 1), v) + + 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', 'http://4.example.com/', + 'Five', 'http://7.example.com/', 'Seven'): + assert md in cut(q('desc'), 'text') diff --git a/pygal/util.py b/pygal/util.py index 599fe76..5c3cc70 100644 --- a/pygal/util.py +++ b/pygal/util.py @@ -123,6 +123,8 @@ ident = lambda x: x def compute_logarithmic_scale(min_, max_): """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 = [] @@ -199,6 +201,8 @@ def decorate(svg, node, metadata): for key in dir(metadata): if key not in ('value') and not key.startswith('_'): value = getattr(metadata, key) + if key == 'xlink' and isinstance(value, dict): + value = value.get('href', value) if value: svg.node(node, 'desc', class_=key).text = str(value) return node