diff --git a/pygal/__init__.py b/pygal/__init__.py index 8378559..10e7184 100644 --- a/pygal/__init__.py +++ b/pygal/__init__.py @@ -1,3 +1,20 @@ from collections import namedtuple Serie = namedtuple('Serie', ('title', 'values')) +Label = namedtuple('Label', ('label', 'pos')) + + +class Margin(object): + def __init__(self, top, right, bottom, left): + self.top = top + self.right = right + self.bottom = bottom + self.left = left + + @property + def x(self): + return self.left + self.right + + @property + def y(self): + return self.top + self.bottom diff --git a/pygal/css/graph.css b/pygal/css/graph.css new file mode 100644 index 0000000..d570348 --- /dev/null +++ b/pygal/css/graph.css @@ -0,0 +1,64 @@ +.graph > .background { + fill: black; +} + +.plot > .background { + fill: #111; +} + +.axis text { + fill: #777; + font-size: 12px; + font-family: sans; +} + +.axis.x text { + text-anchor: middle; + alignment-baseline: baseline; +} + +.axis.y text { + text-anchor: end; + alignment-baseline: middle; +} + +.axis .line { + stroke: #ccc; +} + +.axis .guide.line { + stroke: #555; + stroke-dasharray: 5,5; +} + +.series .dots .dot text { + font-size: 10px; + text-anchor: middle; + alignment-baseline: baseline; + stroke: none; + fill: none; +} + +.series .dots .dot:hover text { + fill: #ccc; +} + +.series .line { + fill: none; + stroke-width: 1.5px; +} + +.series .line:hover { + fill: none; + stroke-width: 3px; +} + +.serie-0 { + stroke: blue; + fill: blue; +} + +.serie-1 { + stroke: red; + fill: red; +} diff --git a/pygal/line.py b/pygal/line.py index ff3f41c..d02ff39 100644 --- a/pygal/line.py +++ b/pygal/line.py @@ -1,4 +1,4 @@ -from pygal import Serie +from pygal import Serie, Margin, Label from pygal.svg import Svg from pygal.base import BaseGraph @@ -10,23 +10,55 @@ class Line(BaseGraph): self.width = width self.height = height self.svg = Svg(width, height) + self.label_font_size = 12 self.series = [] + self.x_labels = None def add(self, title, values): self.series.append( Serie(title, values)) - def draw(self): - min_value = min((val - for serie in self.series - for val in serie.values)) - max_value = max((val - for serie in self.series - for val in serie.values)) - self.svg.graph(min_value, max_value) + def set_labels(self, labels): + values = float(len(labels) - 1) + self.x_labels = [Label(label, i / values) + for i, label in enumerate(labels)] + + def y_labels(self, ymin, ymax): + step = (ymax - ymin) / 20. + label = ymin + labels = [] + while label < ymax: + labels.append(Label(str(label), label)) + label += step + return labels + + def validate(self): + assert len(self.series) + if self.x_labels: + assert len(self.series[0].values) == len(self.x_labels) for serie in self.series: - n_values = len(serie.values) - 1 - x_spacing = self.width / n_values - self.svg.line([ - (i * x_spacing, v) + assert len(self.series[0].values) == len(serie.values) + + def draw(self): + self.validate() + + vals = [val for serie in self.series for val in serie.values] + margin = Margin(*(4 * [20])) + ymin, ymax = min(vals), max(vals) + x_labels = self.x_labels + y_labels = self.y_labels(ymin, ymax) + margin.left += 10 + max( + map(len, [l.label for l in y_labels])) * 0.6 * self.label_font_size + margin.bottom += 10 + self.label_font_size + + # Actual drawing + + self.svg.set_view(margin, ymin, ymax) + self.svg.graph(margin) + self.svg.x_axis(x_labels) + self.svg.y_axis(y_labels) + for serie_index, serie in enumerate(self.series): + serie_node = self.svg.serie(serie_index) + self.svg.line(serie_node, [ + (x_labels[i].pos, v) for i, v in enumerate(serie.values)]) diff --git a/pygal/svg.py b/pygal/svg.py index 0ca7e37..1611379 100644 --- a/pygal/svg.py +++ b/pygal/svg.py @@ -1,13 +1,16 @@ +import os from lxml import etree +from pygal.view import View class Svg(object): """Svg object""" ns = 'http://www.w3.org/2000/svg' - def __init__(self, width, height): + def __init__(self, width, height, base_css=None): self.width = width self.height = height + self.margin = () self.root = etree.Element( "{%s}svg" % self.ns, attrib={ @@ -18,6 +21,17 @@ class Svg(object): 'xlink': 'http://www.w3.org/1999/xlink', }) + self.defs = self.node(tag='defs') + + base_css = base_css or os.path.join( + os.path.dirname(__file__), 'css', 'graph.css') + self.add_style(base_css) + + def add_style(self, css): + style = self.node(self.defs, 'style', type='text/css') + with open(css) as f: + style.text = f.read() + def node(self, parent=None, tag='g', attrib=None, **extras): if parent is None: parent = self.root @@ -32,22 +46,71 @@ class Svg(object): return etree.SubElement(parent, tag, attrib) - def format_coords(self, xy): - return '%f %f' % xy - - def graph(self, min_value, max_value): - self.graph = self.node( - id='graph', - transform="scale(1, %d) translate(0, %d)" % ( - self.height / (max_value - min_value), -min_value)) - self.node(self.graph, 'rect', id='graph_background', - x=0, y=0, width=self.width, height=self.height) - - def line(self, values, origin=None): - origin = self.format_coords(origin or values[0]) - values = ' '.join(map(self.format_coords, values)) - self.node(self.graph, 'path', style="stroke: blue", - d='M%s L%s' % (origin, values)) + def set_view(self, margin, ymin, ymax, xmin=0, xmax=1): + self.view = View( + self.width - margin.x, + self.height - margin.y, + xmin, xmax, ymin, ymax) + + def graph(self, margin): + self.graph = self.node(class_='graph') + self.node(self.graph, 'rect', + class_='background', + x=0, y=0, + width=self.width, + height=self.height) + self.plot = self.node( + self.graph, class_="plot", + transform="translate(%d, %d)" % (margin.left, margin.top)) + self.node(self.plot, 'rect', + class_='background', + x=0, y=0, + width=self.view.width, + height=self.view.height) + + def x_axis(self, labels): + axis = self.node(self.plot, class_="axis x") + # Plot axis + self.node(axis, 'path', + d='M%f %f v%f' % (0, 0, self.view.height), + class_='line') + for label in labels: + x = self.view.x(label.pos) + text = self.node(axis, 'text', x=x, y=self.view.height + 5) + text.text = label.label + + def y_axis(self, labels): + axis = self.node(self.plot, class_="axis y") + # Plot axis + self.node(axis, 'path', + d='M%f %f h%f' % (0, self.view.height, self.view.width), + class_='line') + for label in labels: + y = self.view.y(label.pos) + if y != self.view.height: + self.node(axis, 'path', + d='M%f %f h%f' % (0, y, self.view.width), + class_='guide line') + text = self.node(axis, 'text', x=-5, y=y) + text.text = label.label + + def serie(self, serie): + return self.node(self.plot, class_='series serie-%d' % serie) + + def line(self, serie, values, origin=None): + view_values = map(self.view, values) + if origin == None: + origin = '%f %f' % view_values[0] + + dots = self.node(serie, class_="dots") + for i, (x, y) in enumerate(view_values): + dot = self.node(dots, class_='dot') + self.node(dot, 'circle', cx=x, cy=y, r=2.5) + self.node(dot, 'text', x=x, y=y).text = str(values[i][1]) + + svg_values = ' '.join(map(lambda x: '%f %f' % x, view_values)) + self.node(serie, 'path', + d='M%s L%s' % (origin, svg_values), class_='line') def render(self): return etree.tostring( diff --git a/pygal/test/test_line.py b/pygal/test/test_line.py index 2b21e97..1d791ba 100644 --- a/pygal/test/test_line.py +++ b/pygal/test/test_line.py @@ -1,7 +1,10 @@ from pygal.line import Line +from math import cos, sin def test_simple_line(): line = Line(800, 600) - line.add('test', [10, 20, 5, 17]) + line.add('test2', [cos(x / 10.) for x in range(-30, 30, 5)]) + line.add('test2', [sin(x / 10.) for x in range(-30, 30, 5)]) + line.set_labels(map(str, range(-30, 30, 5))) line._in_browser() diff --git a/pygal/test/test_svg.py b/pygal/test/test_svg.py index d17774c..b442bab 100644 --- a/pygal/test/test_svg.py +++ b/pygal/test/test_svg.py @@ -3,9 +3,9 @@ from pygal.svg import Svg def test_root(): svg = Svg(800, 600) - assert svg.render() == ('\n'.join(( + assert svg.render().startswith('\n'.join(( '', '', + 'viewBox="0 0 800 600">', ''))) diff --git a/pygal/view.py b/pygal/view.py new file mode 100644 index 0000000..8cb163d --- /dev/null +++ b/pygal/view.py @@ -0,0 +1,28 @@ + + +class Box(object): + def __init__(self, x, y, width, height): + self.x = x + self.y = y + self.width = width + self.height = height + + +class View(object): + def __init__(self, width, height, xmin, xmax, ymin, ymax): + self.width = width + self.height = height + self.box = Box(xmin, ymin, xmax - xmin, ymax - ymin) + + def x(self, x): + return self.width * (x - self.box.x) / float(self.box.width) + + def y(self, y): + return (self.height - self.height * + (y - self.box.y) / float(self.box.height)) + + def __call__(self, xy): + x, y = xy + return ( + self.x(x), + self.y(y)) diff --git a/relauncher b/relauncher new file mode 100755 index 0000000..05cf91b --- /dev/null +++ b/relauncher @@ -0,0 +1,4 @@ +#!/bin/sh +while inotifywait -e modify **/*.py >> ~/.log/inotifywait.log 2>&1; do + py.test pygal/test +done