From 8036a08d57bc08e4e7665ed06cf346ac6d9c7bb5 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Fri, 10 Feb 2012 17:27:57 +0100 Subject: [PATCH] Fix many things, add various font_sizes, remove alignment baseline as ff is stupid --- demo/moulinrouge/__init__.py | 25 +++++--- out.py | 11 ++-- pygal/base.py | 49 +++++++++++---- pygal/config.py | 27 ++++++++- pygal/css/graph.css | 55 +++++++++-------- pygal/stackedbar.py | 2 +- pygal/style.py | 10 ++- pygal/svg.py | 114 +++++++++++++++++------------------ pygal/util.py | 14 ++++- 9 files changed, 193 insertions(+), 114 deletions(-) diff --git a/demo/moulinrouge/__init__.py b/demo/moulinrouge/__init__.py index 8e09cd5..b5c25fa 100644 --- a/demo/moulinrouge/__init__.py +++ b/demo/moulinrouge/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from flask import Flask, Response, render_template, url_for from log_colorizer import make_colored_stream_handler +from moulinrouge.data import labels, series from logging import getLogger, INFO, DEBUG import pygal from pygal.config import Config @@ -62,8 +63,7 @@ def create_app(): values = [random_value((-max, min)[random.randrange(0, 2)], max) for i in range(data)] g.add(random_label(), values) - - return Response(g.render(), mimetype='image/svg+xml') + return g.render_response() @app.route("/all") def all(): @@ -76,12 +76,19 @@ def create_app(): width=width, height=height) - # @app.route("/rotation[].svg") - # def rotation_svg(angle): - # return generate_vbar( - # show_graph_title=True, - # graph_title="Rotation %d" % angle, - # x_label_rotation=angle) + @app.route("/rotation[].svg") + def rotation_svg(angle): + config = Config() + config.width = 375 + config.height = 245 + config.x_labels = labels + config.x_label_rotation = angle + g = pygal.Line(config) + for serie, values in series.items(): + g.add(serie, values) + + g.add(serie, values) + return g.render_response() @app.route("/rotation") def rotation(): @@ -98,7 +105,7 @@ def create_app(): g = pygal.Line(600, 400) g.x_labels = ['a', 'b', 'c', 'd'] g.add('serie', [11, 50, 133, 2]) - return Response(g.render(), mimetype='image/svg+xml') + return g.render_response() @app.route("/bigline") def big_line(): diff --git a/out.py b/out.py index 2813e46..fc2599b 100755 --- a/out.py +++ b/out.py @@ -3,9 +3,9 @@ from pygal import Line, Bar, XY, Pie, StackedBar, Config from math import cos, sin bar = Bar() -rng = [6, 19] +rng = [-6, -19, 0, -1, 2] bar.add('test1', rng) -# bar.add('test2', map(abs, rng)) +bar.add('test2', map(abs, rng)) bar.x_labels = map(str, rng) bar.title = "Bar test" with open('out-bar.svg', 'w') as f: @@ -13,11 +13,12 @@ with open('out-bar.svg', 'w') as f: stackedbar = StackedBar() rng = [3, -32, 39, 12] -stackedbar.add('test1', rng) +stackedbar.add('@@@@@@@', rng) rng2 = [24, -8, 18, 12] -stackedbar.add('test2', rng2) +stackedbar.add('++++++', rng2) rng3 = [6, 1, -10, 0] -stackedbar.add('test3', rng3) +stackedbar.add('--->', rng3) +stackedbar.x_label_rotation = 35 stackedbar.x_labels = map(lambda x: '%s / %s / %s' % x, zip(map(str, rng), map(str, rng2), diff --git a/pygal/base.py b/pygal/base.py index 8d9ff38..812a5a1 100644 --- a/pygal/base.py +++ b/pygal/base.py @@ -1,9 +1,9 @@ from pygal.serie import Serie from pygal.view import Margin -from pygal.util import round_to_scale +from pygal.util import round_to_scale, cut, rad from pygal.svg import Svg from pygal.config import Config -import math +from math import log10, sin, cos, pi class BaseGraph(object): @@ -13,7 +13,7 @@ class BaseGraph(object): self.config = config or Config() self.svg = Svg(self) self.series = [] - self.margin = Margin(*([10] * 4)) + self.margin = Margin(*([20] * 4)) def __getattr__(self, attr): if attr in dir(self.config): @@ -21,7 +21,7 @@ class BaseGraph(object): return object.__getattribute__(self, attr) def _pos(self, min_, max_, scale): - order = round(math.log10(max(abs(min_), abs(max_)))) - 1 + order = round(log10(max(abs(min_), abs(max_)))) - 1 while (max_ - min_) / float(10 ** order) < 4: order -= 1 step = float(10 ** order) @@ -41,17 +41,38 @@ class BaseGraph(object): return [min_] return positions + def _text_len(self, lenght, fs): + return lenght * 0.6 * fs + + def _get_text_box(self, text, fs): + return (fs, self._text_len(len(text), fs)) + + def _get_texts_box(self, texts, fs): + max_len = max(map(len, texts)) + return (fs, self._text_len(max_len, fs)) + def _compute_margin(self, x_labels=None, y_labels=None): + if self.show_legend: + h, w = self._get_texts_box( + cut(self.series, 'title'), self.legend_font_size) + self.margin.right += 10 + w + self.legend_box_size + + if self.title: + h, w = self._get_text_box(self.title, self.title_font_size) + self.margin.top += 10 + h + + if x_labels: + h, w = self._get_texts_box(cut(x_labels), self.label_font_size) + self.margin.bottom += 10 + max( + w * sin(rad(self.x_label_rotation)), h) + if self.x_label_rotation: + self.margin.right = max( + .5 * w * cos(rad(self.x_label_rotation)), + self.margin.right) if y_labels: + h, w = self._get_texts_box(cut(y_labels), self.label_font_size) self.margin.left += 10 + max( - map(len, [l for l, _ in y_labels]) - ) * 0.6 * self.label_font_size - if x_labels: - self.margin.bottom += 10 + self.label_font_size - self.margin.right += 20 + max( - map(len, [serie.title for serie in self.series]) - ) * 0.6 * self.label_font_size - self.margin.top += 10 + self.label_font_size + w * cos(rad(self.y_label_rotation)), h) def add(self, title, values): self.series.append(Serie(title, values, len(self.series))) @@ -91,3 +112,7 @@ class BaseGraph(object): from lxml.html import open_in_browser self._draw() open_in_browser(self.svg.root, encoding='utf-8') + + def render_response(self): + from flask import Response + return Response(self.render(), mimetype='image/svg+xml') diff --git a/pygal/config.py b/pygal/config.py index 717d8f5..3529709 100644 --- a/pygal/config.py +++ b/pygal/config.py @@ -1,6 +1,10 @@ from pygal.style import DefaultStyle +class FontSizes(object): + """Container for font sizes""" + + class Config(object): """Class holding config values""" @@ -13,7 +17,18 @@ class Config(object): base_css = None # Style holding values injected in css style = DefaultStyle - label_font_size = 12 + # Various font sizes + label_font_size = 10 + values_font_size = 18 + title_font_size = 16 + legend_font_size = 14 + # Specify labels rotation angles in degrees + x_label_rotation = 0 + y_label_rotation = 0 + # Set to false to remove legend + show_legend = True + # Size of legend boxes + legend_box_size = 12 # X labels, must have same len than data. # Leave it to None to disable x labels display. x_labels = None @@ -30,3 +45,13 @@ class Config(object): def __init__(self, **kwargs): """Can be instanciated with config kwargs""" self.__dict__.update(kwargs) + + @property + def font_sizes(self): + fs = FontSizes() + for name in dir(self): + if name.endswith('_font_size'): + setattr(fs, + name.replace('_font_size', ''), + '%dpx' % getattr(self, name)) + return fs diff --git a/pygal/css/graph.css b/pygal/css/graph.css index 4e41ae6..77c4f66 100644 --- a/pygal/css/graph.css +++ b/pygal/css/graph.css @@ -1,12 +1,11 @@ svg { background-color: {{ style.background }}; - box-shadow: 0 0 5px {{ style.foreground }}; } svg * { - -webkit-transition: 250ms; - -moz-transition: 250ms; - transition: 250ms; + -webkit-transition: {{ style.transition }}; + -moz-transition: {{ style.transition }}; + transition: {{ style.transition }}; } .graph > .background { @@ -23,33 +22,44 @@ svg * { .title { fill: {{ style.foreground_light }}; + font-size: {{ font_sizes.title }}; text-anchor: middle; - alignment-baseline: baseline; } -.legend text { - font-size: 12px; - font-family: sans; - alignment-baseline: hanging; +.legends .legend text { + font-family: monospace; + font-size: {{ font_sizes.legend }}; } -.legend rect { - stroke: {{ style.foreground_dark }}; +.legends .legend rect { + fill-opacity: {{ style.opacity }}; +} + +.legends .legend:hover text { + stroke: {{ style.foreground_light }}; +} + +.legends .legend:hover rect { + fill-opacity: 1 } .axis text { - font-size: 12px; + font-size: {{ font_sizes.label }}; font-family: sans; } .axis.x text { + font-family: monospace; text-anchor: middle; - alignment-baseline: baseline; +} + +.axis.x text[transform] { + text-anchor: start; } .axis.y text { + font-family: monospace; text-anchor: end; - alignment-baseline: middle; } .axis .line { @@ -83,23 +93,16 @@ svg * { stroke-width: 5px; } -.series .dots .dot text, .series .bars .bar text, .series .slices .slice text { +.series text { opacity: 0; - font-size: 12px; + font-size: {{ font_sizes.values }}; text-anchor: middle; - alignment-baseline: baseline; stroke: {{ style.foreground_light }}; fill: {{ style.foreground_light }}; - alignment-baseline: baseline; - text-shadow: 0 0 5px {{ style.background }}; - z-index: 9999; -} - -.series .bars .bar text, .series .slices .slice text { - alignment-baseline: middle; + text-shadow: 0 0 16px {{ style.background }}; } -.series .dots .dot:hover text, .series .bars .bar:hover text, .series .slices .slice:hover text { +.series .dot:hover text, .series .bar:hover text, .series .slice:hover text { opacity: 1; } @@ -114,7 +117,7 @@ svg * { } .series .rect, .series .slice { - fill-opacity: .8; + fill-opacity: {{ style.opacity }}; } .series .rect:hover, .series .slice:hover { diff --git a/pygal/stackedbar.py b/pygal/stackedbar.py index 48452ae..100214b 100644 --- a/pygal/stackedbar.py +++ b/pygal/stackedbar.py @@ -34,7 +34,7 @@ class StackedBar(BaseGraph): stack_vals = [[0, 0] for i in range(length)] for serie in self.series: serie_node = self.svg.serie(serie.index) - stack_vals = self.svg.stackbar( + stack_vals = self.svg.bar( serie_node, serie, [ tuple((x_ranges[i][j], v) for j in range(2)) for i, v in enumerate(serie.values)], diff --git a/pygal/style.py b/pygal/style.py index 8bd1368..c2c279c 100644 --- a/pygal/style.py +++ b/pygal/style.py @@ -5,6 +5,8 @@ class Style(object): foreground='#999', foreground_light='#eee', foreground_dark='#555', + opacity='.8', + transition='250ms', colors=( '#ff5995', '#b6e354', '#feed6c', '#8cedff', '#9e6ffe', '#899ca1', '#f8f8f2', '#808384', '#bf4646', '#516083', @@ -15,6 +17,8 @@ class Style(object): self.foreground = foreground self.foreground_light = foreground_light self.foreground_dark = foreground_dark + self.opacity = opacity + self.transition = transition self._colors = colors @property @@ -37,6 +41,10 @@ LightStyle = Style( colors=('#242424', '#9f6767', '#92ac68', '#d0d293', '#9aacc3', '#bb77a4', '#77bbb5', '#777777')) +NeonStyle = Style( + opacity='.1', + transition='1s ease-out') styles = {'default': DefaultStyle, - 'light': LightStyle} + 'light': LightStyle, + 'neon': NeonStyle} diff --git a/pygal/svg.py b/pygal/svg.py index c44c6c6..966c6f8 100644 --- a/pygal/svg.py +++ b/pygal/svg.py @@ -37,7 +37,8 @@ class Svg(object): .replace(' }}', '\x00') .replace('}', '}}') .replace('\x00', '}') - .format(style=self.graph.style)) + .format(style=self.graph.style, + font_sizes=self.graph.font_sizes)) def node(self, parent=None, tag='g', attrib=None, **extras): if parent is None: @@ -90,11 +91,17 @@ class Svg(object): for label, position in labels: guides = self.node(axis, class_='guides') x = self.view.x(position) + y = self.view.height + 5 self.node(guides, 'path', d='M%f %f v%f' % (x, 0, self.view.height), class_='%sline' % ( 'guide ' if position != 0 else '')) - text = self.node(guides, 'text', x=x, y=self.view.height + 5) + text = self.node(guides, 'text', + x=x, + y=y + .5 * self.graph.label_font_size + 5) + if self.graph.x_label_rotation: + text.attrib['transform'] = "rotate(%d %f %f)" % ( + self.graph.x_label_rotation, x, y) text.text = label def y_axis(self, labels): @@ -102,37 +109,58 @@ class Svg(object): return axis = self.node(self.plot, class_="axis y") - # import pdb; pdb.set_trace() + if 0 not in [label[1] for label in labels]: self.node(axis, 'path', d='M%f %f h%f' % (0, self.view.height, self.view.width), class_='line') for label, position in labels: guides = self.node(axis, class_='guides') + x = -5 y = self.view.y(position) self.node(guides, 'path', d='M%f %f h%f' % (0, y, self.view.width), class_='%sline' % ( 'guide ' if position != 0 else '')) - text = self.node(guides, 'text', x=-5, y=y) + text = self.node(guides, 'text', + x=x, + y=y + .35 * self.graph.label_font_size) + if self.graph.y_label_rotation: + text.attrib['transform'] = "rotate(%d %f %f)" % ( + self.graph.y_label_rotation, x, y) text.text = label def legend(self, titles): - legend = self.node( - self.graph_node, class_='legend', + if not self.graph.show_legend: + return + legends = self.node( + self.graph_node, class_='legends', transform='translate(%d, %d)' % ( self.graph.margin.left + self.view.width + 10, self.graph.margin.top + 10)) for i, title in enumerate(titles): - self.node(legend, 'rect', x=0, y=i * 15, - width=8, height=8, class_="color-%d" % i, + legend = self.node(legends, class_='legend') + self.node(legend, 'rect', + x=0, + y=1.5 * i * self.graph.legend_box_size, + width=self.graph.legend_box_size, + height=self.graph.legend_box_size, + class_="color-%d" % i, ).text = title - self.node(legend, 'text', x=15, y=i * 15).text = title + # Serious magical numbers here + self.node(legend, 'text', + x=self.graph.legend_box_size + 5, + y=1.5 * i * self.graph.legend_box_size + + .5 * self.graph.legend_box_size + + .3 * self.graph.legend_font_size + ).text = title def title(self): - self.node(self.graph_node, 'text', class_='title', - x=self.graph.margin.left + self.view.width / 2, - y=10).text = self.graph.title + if self.graph.title: + self.node(self.graph_node, 'text', class_='title', + x=self.graph.margin.left + self.view.width / 2, + y=self.graph.title_font_size + 10 + ).text = self.graph.title def serie(self, serie): return self.node( @@ -153,7 +181,7 @@ class Svg(object): self.node(serie_node, 'path', d='M%s L%s' % (origin, svg_values), class_='line') - def bar(self, serie_node, serie, values): + def bar(self, serie_node, serie, values, stack_vals=None): """Draw a bar graph for a serie""" # value here is a list of tuple range of tuple coord @@ -168,66 +196,36 @@ class Svg(object): width = X - x padding = .1 * width inner_width = width - 2 * padding - bar_width = inner_width / len(self.graph.series) - bar_padding = .1 * bar_width - bar_inner_width = bar_width - 2 * bar_padding - offset = serie.index * bar_width + bar_padding height = self.view.y(0) - y + if stack_vals == None: + bar_width = inner_width / len(self.graph.series) + bar_padding = .1 * bar_width + bar_inner_width = bar_width - 2 * bar_padding + offset = serie.index * bar_width + bar_padding + shift = 0 + else: + offset = 0 + bar_inner_width = inner_width + shift = stack_vals[i][int(height < 0)] + stack_vals[i][int(height < 0)] += height x = x + padding + offset - y_txt = y + height / 2 - if height < 0: - y = y + height - height = -height - y_txt = y + height / 2 - bar = self.node(bars, class_='bar') - self.node(bar, 'rect', - x=x, - y=y, - rx=self.graph.rounded_bars * 1, - ry=self.graph.rounded_bars * 1, - width=bar_inner_width, - height=height, - class_='rect') - self.node(bar, 'text', - x=x + bar_inner_width / 2, - y=y_txt, - ).text = str(values[i][1][1]) - - def stackbar(self, serie_node, serie, values, stack_vals): - """Draw a bar graph for a serie""" - # value here is a list of tuple range of tuple coord - def view(rng): - """Project range""" - return (self.view(rng[0]), self.view(rng[1])) - - bars = self.node(serie_node, class_="bars") - view_values = map(view, values) - for i, ((x, y), (X, Y)) in enumerate(view_values): - # x and y are left range coords and X, Y right ones - width = X - x - padding = .1 * width - inner_width = width - 2 * padding - height = self.view.y(0) - y - x = x + padding - y_txt = y + height / 2 - shift = stack_vals[i][int(height < 0)] - stack_vals[i][int(height < 0)] += height if height < 0: y = y + height height = -height - y_txt = y + height / 2 + + y_txt = y + height / 2 + .3 * self.graph.values_font_size bar = self.node(bars, class_='bar') self.node(bar, 'rect', x=x, y=y - shift, rx=self.graph.rounded_bars * 1, ry=self.graph.rounded_bars * 1, - width=inner_width, + width=bar_inner_width, height=height, class_='rect') self.node(bar, 'text', - x=x + inner_width / 2, + x=x + bar_inner_width / 2, y=y_txt - shift, ).text = str(values[i][1][1]) return stack_vals diff --git a/pygal/util.py b/pygal/util.py index ff2f063..4e919c6 100644 --- a/pygal/util.py +++ b/pygal/util.py @@ -1,5 +1,5 @@ from decimal import Decimal -from math import floor +from math import floor, pi def round_to_int(number, precision): @@ -18,3 +18,15 @@ def round_to_scale(number, precision): if precision < 1: return round_to_float(number, precision) return round_to_int(number, precision) + + +def cut(list_, index=0): + if isinstance(index, int): + cut = lambda x: x[index] + else: + cut = lambda x: getattr(x, index) + return map(cut, list_) + + +def rad(deg): + return pi * deg / 180.