Browse Source

Rework formatters. Add a formatter option for chart / serie / value.

pull/307/head
Florian Mounier 9 years ago
parent
commit
7f77027392
  1. 27
      demo/moulinrouge/__init__.py
  2. 49
      demo/moulinrouge/tests.py
  3. 3
      docs/changelog.rst
  4. 14
      docs/documentation/configuration/serie.rst
  5. 13
      docs/documentation/configuration/value.rst
  6. 18
      pygal/config.py
  7. 109
      pygal/formatters.py
  8. 2
      pygal/graph/bar.py
  9. 48
      pygal/graph/box.py
  10. 6
      pygal/graph/dot.py
  11. 8
      pygal/graph/dual.py
  12. 13
      pygal/graph/funnel.py
  13. 14
      pygal/graph/gauge.py
  14. 77
      pygal/graph/graph.py
  15. 2
      pygal/graph/histogram.py
  16. 2
      pygal/graph/line.py
  17. 12
      pygal/graph/map.py
  18. 15
      pygal/graph/pie.py
  19. 7
      pygal/graph/pyramid.py
  20. 12
      pygal/graph/radar.py
  21. 16
      pygal/graph/stackedline.py
  22. 16
      pygal/graph/time.py
  23. 7
      pygal/graph/treemap.py
  24. 5
      pygal/graph/xy.py
  25. 7
      pygal/table.py
  26. 29
      pygal/test/test_config.py
  27. 88
      pygal/test/test_formatters.py
  28. 6
      pygal/test/test_graph.py
  29. 4
      pygal/test/test_line.py
  30. 16
      pygal/test/test_stacked.py
  31. 32
      pygal/test/test_util.py
  32. 29
      pygal/util.py

27
demo/moulinrouge/__init__.py

@ -70,13 +70,13 @@ def create_app():
random_value((-max, min)[random.randrange(0, 2)], max),
random_value((-max, min)[random.randrange(0, 2)], max)
) for i in range(data)]
series.append((random_label(), values))
series.append((random_label(), values, {}))
return series
def _random_series(type, data, order):
max = 10 ** order
min = 10 ** random.randrange(0, order)
with_2nd = bool(random.randint(0, 1))
with_secondary = bool(random.randint(0, 1))
series = []
for i in range(random.randrange(1, 10)):
if type == 'Pie':
@ -89,10 +89,10 @@ def create_app():
else:
values = [random_value((-max, min)[random.randrange(1, 2)],
max) for i in range(data)]
is_2nd = False
if with_2nd:
is_2nd = bool(random.randint(0, 1))
series.append((random_label(), values, is_2nd))
config = {
'secondary': with_secondary and bool(random.randint(0, 1))
}
series.append((random_label(), values, config))
return series
from .tests import get_test_routes
@ -110,15 +110,17 @@ def create_app():
def svg(type, series, config):
graph = get(type)(
pickle.loads(b64decode(str(config))))
for title, values, is_2nd in pickle.loads(b64decode(str(series))):
graph.add(title, values, secondary=is_2nd)
for title, values, serie_config in pickle.loads(
b64decode(str(series))):
graph.add(title, values, **serie_config)
return graph.render_response()
@app.route("/table/<type>/<series>/<config>")
def table(type, series, config):
graph = get(type)(pickle.loads(b64decode(str(config))))
for title, values, is_2nd in pickle.loads(b64decode(str(series))):
graph.add(title, values, secondary=is_2nd)
for title, values, serie_config in pickle.loads(
b64decode(str(series))):
graph.add(title, values, **serie_config)
return graph.render_table()
@app.route("/sparkline/<style>")
@ -196,16 +198,15 @@ def create_app():
xy_series = _random(data, order)
other_series = []
for title, values in xy_series:
for title, values, config in xy_series:
other_series.append(
(title, cut(values, 1)))
(title, cut(values, 1), config))
xy_series = b64encode(pickle.dumps(xy_series))
other_series = b64encode(pickle.dumps(other_series))
config = Config()
config.width = width
config.height = height
config.fill = bool(random.randrange(0, 2))
config.human_readable = True
config.interpolate = interpolate
config.style = style
svgs = []

49
demo/moulinrouge/tests.py

@ -24,7 +24,7 @@ except ImportError:
from flask import abort
from pygal.style import styles, Style, RotateStyle
from pygal.colors import rotate
from pygal import stats
from pygal import stats, formatters
from pygal.graph.horizontal import HorizontalGraph
from random import randint, choice
from datetime import datetime, date
@ -38,7 +38,7 @@ def get_test_routes(app):
@app.route('/test/unsorted')
def test_unsorted():
bar = Bar(style=styles['neon'], human_readable=True)
bar = Bar(style=styles['neon'], value_formatter=formatters.human_readable)
bar.add('A', {'red': 10, 'green': 12, 'blue': 14})
bar.add('B', {'green': 11, 'blue': 7})
bar.add('C', {'blue': 7})
@ -352,12 +352,24 @@ def get_test_routes(app):
bar.x_labels_major = [4]
return bar.render_response()
@app.route('/test/formatters/<chart>')
def test_formatters_for(chart):
chart = CHARTS_BY_NAME[chart](
print_values=True, formatter=lambda x, chart, serie: '%s%s$' % (
x, serie.title))
chart.add('_a', [1, 2, {'value': 3, 'formatter': lambda x: '%s¥' % x}])
chart.add('_b', [4, 5, 6], formatter=lambda x: '%s' % x)
chart.x_labels = [2, 4, 6]
chart.x_labels_major = [4]
return chart.render_response()
@app.route('/test/bar/position')
def test_bar_print_values_position():
bar = StackedBar(print_values=True, print_values_position='top', zero=2,
style=styles['default'](
value_font_family='googlefont:Raleway',
value_font_size=46))
bar = StackedBar(
print_values=True, print_values_position='top', zero=2,
style=styles['default'](
value_font_family='googlefont:Raleway',
value_font_size=46))
bar.add('1', [1, -2, 3])
bar.add('2', [4, -5, 6])
bar.x_labels = [2, 4, 6]
@ -366,7 +378,10 @@ def get_test_routes(app):
@app.route('/test/histogram')
def test_histogram():
hist = Histogram(print_values=True, print_values_position='top', style=styles['neon'])
hist = Histogram(
print_values=True,
print_values_position='top',
style=styles['neon'])
hist.add('1', [
(2, 0, 1),
(4, 1, 3),
@ -422,7 +437,7 @@ def get_test_routes(app):
@app.route('/test/box')
def test_box():
chart = Box()
chart.js = ('http://l:2343/2.0.x/pygal-tooltips.js',)
# chart.js = ('http://l:2343/2.0.x/pygal-tooltips.js',)
chart.box_mode = '1.5IQR'
chart.add('One', [15, 8, 2, -12, 9, 23])
chart.add('Two', [5, 8, 2, -9, 23, 12])
@ -576,21 +591,9 @@ def get_test_routes(app):
if fr is None:
abort(404)
fmap = fr.Departments(style=choice(list(styles.values())))
fmap.add('', [(69, 2), (42, 7), (38, 3), (26, 0)])
# for i in range(10):
# fmap.add('s%d' % i, [
# (choice(list(fr.DEPARTMENTS.keys())), randint(0, 100))
# for _ in range(randint(1, 5))])
# fmap.add('links', [{
# 'value': (69, 10),
# 'label': '\o/',
# 'xlink': 'http://google.com?q=69'
# }, {
# 'value': ('42', 20),
# 'label': 'Y',
# }])
# fmap.add('6th', [3, 5, 34, 12])
fmap.add('', [(i, i) for i in range(1, 100)])
fmap.add('', [(970 + i, i) for i in range(1, 7)])
fmap.add('', [('2A', 1), ('2B', 2)])
fmap.title = 'French map'
return fmap.render_response()

3
docs/changelog.rst

@ -8,7 +8,8 @@ Changelog
* Support interruptions in line charts (thanks @piotrmaslanka #300)
* Fix confidence interval reactiveness (thanks @chartique #296)
* Add horizontal line charts (thanks @chartique #301)
* There is now a `formatter` config option to format values as specified. The formatter callable may or may not take `chart`, `serie` and `index` as argument. The default value formatting is now chart dependent and is value_formatter for most graph but could be a combination of value_formatter and x_value_formatter for dual charts.
* The `human_readable` option has been removed. Now you have to use the pygal.formatters.human_readable formatter (value_formatter=human_readable instead of human_readable=True)
2.1.1
=====

14
docs/documentation/configuration/serie.rst

@ -138,3 +138,17 @@ You can set `allow_interruptions` to True in order to break lines on None values
allow_interruptions=True)
interrupted_chart.add(
'Temperature', [11, 17, 21.5, 6, None, 6, 27.5, None, 28])
formatter
~~~~~~~~~
You can add a `formatter` function for this serie values.
It will be used for value printing and tooltip. (Not for axis.)
.. pygal-code::
chart = pygal.Bar(print_values=True, value_formatter=lambda x: '{}$'.format(x))
chart.add('bar', [.0002, .0005, .00035], formatter=lambda x: '<%s>' % x)
chart.add('bar', [.0004, .0009, .001])

13
docs/documentation/configuration/value.rst

@ -55,6 +55,19 @@ The color key set the fill and the stroke style. You can also set the css style
])
Value formatting
~~~~~~~~~~~~~~~~
You can add a `formatter` metadata for a specific value.
.. pygal-code::
chart = pygal.Bar(print_values=True, value_formatter=lambda x: '{}$'.format(x))
chart.add('bar', [.0002, .0005, .00035], formatter=lambda x: '<%s>' % x)
chart.add('bar', [.0004, {'value': .0009, 'formatter': lambda x: '«%s»' % x}, .001])
Node attributes
---------------

18
pygal/config.py

@ -22,9 +22,11 @@ from copy import deepcopy
from pygal.interpolate import INTERPOLATIONS
from pygal.style import DefaultStyle, Style
from pygal import formatters
CONFIG_ITEMS = []
callable = type(lambda: 1)
class Key(object):
@ -221,6 +223,12 @@ class CommonConfig(BaseConfig):
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):
@ -383,18 +391,14 @@ class Config(CommonConfig):
"'x' (default), 'y' or 'either'")
# Value #
human_readable = Key(
False, bool, "Value", "Display values in human readable format",
"(ie: 12.4M)")
x_value_formatter = Key(
None, type(lambda: 1), "Value",
formatters.default, callable, "Value",
"A function to convert abscissa numeric value to strings "
"(used in XY and Date charts)")
value_formatter = Key(
None, type(lambda: 1), "Value",
"A function to convert numeric value to strings")
formatters.default, callable, "Value",
"A function to convert ordinate numeric value to strings")
logarithmic = Key(
False, bool, "Value", "Display values in logarithmic scale")

109
pygal/formatters.py

@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
# This file is part of pygal
#
# A python svg graph plotting library
# Copyright © 2012-2015 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 <http://www.gnu.org/licenses/>.
"""
Formatters to use with `value_formatter` and `x_value_formatter` configs
"""
from datetime import datetime, date, time
from math import floor, log
from pygal._compat import u, to_str
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()

2
pygal/graph/bar.py

@ -109,7 +109,7 @@ class Bar(Graph):
if None in (x, y) or (self.logarithmic and y <= 0):
continue
metadata = serie.metadata.get(i)
val = self._format(serie.values[i])
val = self._format(serie, i)
bar = decorate(
self.svg,

48
pygal/graph/box.py

@ -26,7 +26,6 @@ from __future__ import division
from bisect import bisect_left, bisect_right
from pygal._compat import is_list_like
from pygal.graph.graph import Graph
from pygal.util import alter, decorate
@ -44,27 +43,25 @@ class Box(Graph):
_series_margin = .06
@property
def _format(self):
"""Return the value formatter for this graph"""
sup = super(Box, self)._format
def format_maybe_quartile(x):
if is_list_like(x):
if self.box_mode == "extremes":
return (
'Min: %s\nQ1 : %s\nQ2 : %s\nQ3 : %s\nMax: %s' %
tuple(map(sup, x[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(sup, x)))
elif self.box_mode == '1.5IQR':
# 1.5IQR mode
return 'Q1: %s\nQ2: %s\nQ3: %s' % tuple(map(sup, x[2:5]))
else:
return sup(x)
return format_maybe_quartile
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):
"""
@ -72,7 +69,7 @@ class Box(Graph):
within the rendering process
"""
for serie in self.series:
serie.values, serie.outliers = \
serie.points, serie.outliers = \
self._box_points(serie.values, self.box_mode)
self._x_pos = [
@ -107,10 +104,11 @@ class Box(Graph):
self.svg,
self.svg.node(boxes, class_='box'),
metadata)
val = self._format(serie.values)
val = self._format(serie, 0)
x_center, y_center = self._draw_box(
box, serie.values[1:6], serie.outliers, serie.index, metadata)
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)

6
pygal/graph/dot.py

@ -68,11 +68,11 @@ class Dot(Graph):
class_='dot reactive tooltip-trigger' + (
' negative' if value < 0 else '')), metadata)
value = self._format(value)
val = self._format(serie, i)
self._tooltip_data(
dots, value, x, y, 'centered',
dots, val, x, y, 'centered',
self._get_x_label(i))
self._static_value(serie_node, value, x, y, metadata)
self._static_value(serie_node, val, x, y, metadata)
def _compute(self):
"""Compute y min and max and y scale and set labels"""

8
pygal/graph/dual.py

@ -27,6 +27,14 @@ 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,

13
pygal/graph/funnel.py

@ -31,10 +31,9 @@ class Funnel(Graph):
_adapters = [positive, none_to_zero]
def _format(self, value):
"""Return the value formatter for this graph here its absolute value"""
value = value and abs(value)
return super(Funnel, self)._format(value)
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"""
@ -42,7 +41,7 @@ class Funnel(Graph):
fmt = lambda x: '%f %f' % x
for i, poly in enumerate(serie.points):
metadata = serie.metadata.get(i)
value = self._format(serie.values[i])
val = self._format(serie, i)
funnels = decorate(
self.svg,
@ -59,9 +58,9 @@ class Funnel(Graph):
self._center(self._x_pos[serie.index]),
sum([point[1] for point in poly]) / len(poly)))
self._tooltip_data(
funnels, value, x, y, 'centered',
funnels, val, x, y, 'centered',
self._get_x_label(serie.index))
self._static_value(serie_node, value, x, y, metadata)
self._static_value(serie_node, val, x, y, metadata)
def _center(self, x):
return x - 1 / (2 * self._order)

14
pygal/graph/gauge.py

@ -55,7 +55,7 @@ class Gauge(Graph):
def point(x, y):
return '%f %f' % self.view((x, y))
value = self._format(serie.values[i])
val = self._format(serie, i)
metadata = serie.metadata.get(i)
gauges = decorate(
self.svg,
@ -88,9 +88,9 @@ class Gauge(Graph):
x, y = self.view((.75, theta))
self._tooltip_data(
gauges, value, x, y,
gauges, val, x, y,
xlabel=self._get_x_label(i))
self._static_value(serie_node, value, x, y, metadata)
self._static_value(serie_node, val, x, y, metadata)
def _y_axis(self, draw_axes=True):
"""Override y axis to plot a polar axis"""
@ -120,7 +120,7 @@ class Gauge(Graph):
self.svg.node(
guides, 'title',
).text = self._format(theta)
).text = self._y_format(theta)
def _x_axis(self, draw_axes=True):
"""Override x axis to put a center circle in center"""
@ -154,13 +154,13 @@ class Gauge(Graph):
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._format(pos))
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._format(pos)
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)))
@ -169,7 +169,7 @@ class Gauge(Graph):
self.min_,
self.max_)
else:
self._y_labels = list(zip(map(self._format, y_pos), y_pos))
self._y_labels = list(zip(map(self._y_format, y_pos), y_pos))
def _plot(self):
"""Plot all needles"""

77
pygal/graph/graph.py

@ -30,8 +30,8 @@ from pygal.interpolate import INTERPOLATIONS
from pygal import stats
from pygal.util import (
cached_property, compute_scale, cut, decorate,
get_text_box, get_texts_box, humanize, majorize, rad, reverse_text_len,
split_title, truncate)
get_text_box, get_texts_box, majorize, rad, reverse_text_len,
split_title, truncate, filter_kwargs)
from pygal.view import LogView, ReverseView, View, XYLogView
@ -285,7 +285,7 @@ class Graph(PublicApi):
' ') or []) + ['backwards'])
self.svg.node(
guides, 'title',
).text = self._format(position)
).text = self._y_format(position)
if self._y_2nd_labels:
secondary_ax = self.svg.node(
@ -521,10 +521,6 @@ class Graph(PublicApi):
y=y + self.style.value_font_size / 3
).text = value if self.print_zeroes or value != '0' else ''
def _get_value(self, values, i):
"""Get the value formatted for tooltip"""
return self._format(values[i][1])
def _points(self, x_pos):
"""
Convert given data values into drawable points (x, y)
@ -554,7 +550,7 @@ class Graph(PublicApi):
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._format(ymin + i * scale), pos)
self._y_2nd_labels = [(self._y_format(ymin + i * scale), pos)
for i, pos in enumerate(y_pos)]
self._scale = left_range / right_range
@ -578,15 +574,60 @@ class Graph(PublicApi):
@property
def _x_format(self):
"""Return the value formatter for this graph"""
return self.x_value_formatter or (
humanize if self.human_readable else to_str)
"""Return the abscissa value formatter (always unary)"""
return self.x_value_formatter
@property
def _default_formatter(self):
return to_str
@property
def _format(self):
"""Return the value formatter for this graph"""
return self.value_formatter or (
humanize if self.human_readable else to_str)
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"""
@ -815,18 +856,18 @@ class Graph(PublicApi):
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._format(pos))
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._format(pos)
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._format, y_pos), y_pos))
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:

2
pygal/graph/histogram.py

@ -95,7 +95,7 @@ class Histogram(Dual, Bar):
self.svg,
self.svg.node(bars, class_='histbar'),
metadata)
val = self._format(serie.values[i][0])
val = self._format(serie, i)
bounds = self._bar(
serie, bar, x0, x1, y, i, self.zero, secondary=rescale)

2
pygal/graph/line.py

@ -120,7 +120,7 @@ class Line(Graph):
self.svg.node(serie_node['overlay'], class_="dots"),
metadata)
val = self._get_value(serie.points, i)
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)

12
pygal/graph/map.py

@ -52,9 +52,13 @@ class BaseMap(Graph):
"""Hook to change the area code"""
return area_code
def _get_value(self, value):
"""Get the value formatted for tooltip"""
return '%s: %s' % (self.area_names[value[0]], self._format(value[1]))
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"""
@ -120,7 +124,7 @@ class BaseMap(Graph):
node.set('class', ' '.join(cls))
alter(node, metadata)
val = self._get_value((area_code, value))
val = self._format(serie, j)
self._tooltip_data(area, val, 0, 0, 'auto')
self.nodes['plot'].append(map)

15
pygal/graph/pie.py

@ -36,15 +36,6 @@ class Pie(Graph):
_adapters = [positive, none_to_zero]
@property
def _format(self):
"""Return the value formatter for this graph"""
def percentage_formatter(y, self=self):
total = sum(map(sum, map(lambda x: x.values, self.series)))
perc = y/total
return '{0:.2%}'.format(perc)
return self.value_formatter or percentage_formatter
def slice(self, serie, start_angle, total):
"""Make a serie slice"""
serie_node = self.svg.serie(serie)
@ -52,7 +43,6 @@ class Pie(Graph):
slices = self.svg.node(serie_node['plot'], class_="slices")
serie_angle = 0
total_perc = 0
original_start_angle = start_angle
if self.half_pie:
center = ((self.width - self.margin_box.x) / 2.,
@ -69,7 +59,7 @@ class Pie(Graph):
else:
angle = 2 * pi * perc
serie_angle += angle
val = self._format(val)
val = self._format(serie, i)
metadata = serie.metadata.get(i)
slice_ = decorate(
self.svg,
@ -86,10 +76,9 @@ class Pie(Graph):
serie_node, slice_, big_radius, small_radius,
angle, start_angle, center, val, i, metadata), metadata)
start_angle += angle
total_perc += perc
if dual:
val = self._format(total_perc*total)
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,

7
pygal/graph/pyramid.py

@ -35,10 +35,9 @@ class VerticalPyramid(StackedBar):
_adapters = [positive]
def _format(self, value):
"""Return the value formatter for this graph here its absolute value"""
value = value and abs(value)
return super(VerticalPyramid, self)._format(value)
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"""

12
pygal/graph/radar.py

@ -49,10 +49,6 @@ class Radar(Line):
"""Add extra values to fill the line"""
return values
def _get_value(self, values, i):
"""Get the value formatted for tooltip"""
return self._format(values[i][0])
@cached_property
def _values(self):
"""Getter for series values (flattened)"""
@ -160,7 +156,7 @@ class Radar(Line):
self.svg.node(
guides, 'title',
).text = self._format(r)
).text = self._y_format(r)
def _compute(self):
"""Compute r min max and labels position"""
@ -198,17 +194,17 @@ class Radar(Line):
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._format(pos))
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._format(pos)
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._format, y_pos), y_pos))
self._y_labels = list(zip(map(self._y_format, y_pos), y_pos))

16
pygal/graph/stackedline.py

@ -39,6 +39,22 @@ class StackedLine(Line):
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:

16
pygal/graph/time.py

@ -101,9 +101,7 @@ class DateTimeLine(XY):
"""Return the value formatter for this graph"""
def datetime_to_str(x):
dt = datetime.utcfromtimestamp(x)
if self.x_value_formatter:
return self.x_value_formatter(dt)
return dt.isoformat()
return self.x_value_formatter(dt)
return datetime_to_str
@ -116,9 +114,7 @@ class DateLine(DateTimeLine):
"""Return the value formatter for this graph"""
def date_to_str(x):
d = date.fromtimestamp(x)
if self.x_value_formatter:
return self.x_value_formatter(d)
return d.isoformat()
return self.x_value_formatter(d)
return date_to_str
@ -133,9 +129,7 @@ class TimeLine(DateTimeLine):
"""Return the value formatter for this graph"""
def date_to_str(x):
t = seconds_to_time(x)
if self.x_value_formatter:
return self.x_value_formatter(t)
return t.isoformat()
return self.x_value_formatter(t)
return date_to_str
@ -150,8 +144,6 @@ class TimeDeltaLine(XY):
"""Return the value formatter for this graph"""
def timedelta_to_str(x):
td = timedelta(seconds=x)
if self.x_value_formatter:
return self.x_value_formatter(td)
return str(td)
return self.x_value_formatter(td)
return timedelta_to_str

7
pygal/graph/treemap.py

@ -39,7 +39,8 @@ class Treemap(Graph):
rh -= ry
metadata = serie.metadata.get(i)
value = self._format(val)
val = self._format(serie, i)
rect = decorate(
self.svg,
@ -57,13 +58,13 @@ class Treemap(Graph):
metadata)
self._tooltip_data(
rect, value,
rect, val,
rx + rw / 2,
ry + rh / 2,
'centered',
self._get_x_label(i))
self._static_value(
serie_node, value,
serie_node, val,
rx + rw / 2,
ry + rh / 2,
metadata)

5
pygal/graph/xy.py

@ -37,11 +37,6 @@ class XY(Line, Dual):
_x_adapters = []
def _get_value(self, values, i):
"""Get the value formatted for tooltip"""
vals = values[i]
return '%s: %s' % (self._x_format(vals[0]), self._format(vals[1]))
@cached_property
def xvals(self):
"""All x values"""

7
pygal/table.py

@ -57,7 +57,6 @@ class Table(object):
"""
self.chart.setup()
ln = self.chart._len
fmt = self.chart._format
html = HTML()
attrs = {}
@ -92,10 +91,10 @@ class Table(object):
v = value or 0
acc[j] += v
sum_ += v
row.append(fmt(value))
row.append(self.chart._format(serie, j))
if total:
acc[-1] += sum_
row.append(fmt(sum_))
row.append(self.chart._value_format(serie, sum_))
table.append(row)
width = ln + 1
@ -103,7 +102,7 @@ class Table(object):
width += 1
table.append(['Total'])
for val in acc:
table[-1].append(fmt(val))
table[-1].append(self.chart._value_format(serie, val))
# Align values
len_ = max([len(r) for r in table] or [0])

29
pygal/test/test_config.py

@ -28,6 +28,7 @@ from pygal import (
from pygal.graph.map import BaseMap
from pygal.graph.horizontal import HorizontalGraph
from pygal.graph.dual import Dual
from pygal import formatters
from pygal._compat import u
from pygal.test.utils import texts
from tempfile import NamedTemporaryFile
@ -253,8 +254,10 @@ def test_human_readable():
line.add('_', [10 ** 4, 10 ** 5, 23 * 10 ** 4])
q = line.render_pyquery()
assert q(".axis.y text").map(texts) == list(map(
str, map(float, range(20000, 240000, 20000))))
line.human_readable = True
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)))
@ -309,12 +312,12 @@ def test_include_x_axis(Chart):
yaxis = ".axis.%s .guides text" % (
'y' if not getattr(chart, 'horizontal', False) else 'x')
if not isinstance(chart, Bar):
assert '0.0' not in q(yaxis).map(texts)
assert '0' not in q(yaxis).map(texts)
else:
assert '0.0' in q(yaxis).map(texts)
assert '0' in q(yaxis).map(texts)
chart.include_x_axis = True
q = chart.render_pyquery()
assert '0.0' in q(yaxis).map(texts)
assert '0' in q(yaxis).map(texts)
def test_css(Chart):
@ -504,3 +507,19 @@ def test_render_data_uri(Chart):
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: '%s¥' % x}])
chart.add('_b', [4, 5, 6], formatter=lambda x: '%s' % x)
chart.x_labels = [2, 4, 6]
chart.x_labels_major = [4]
q = chart.render_pyquery()
assert {v.text for v in q(".value")} == set((
'4€', '5€', '6€', '1_a$', '2_a$', '') + (
('6_a$', '15€') if Chart == Pie else ()))

88
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-2015 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 <http://www.gnu.org/licenses/>.
"""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('')
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) == ''
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'

6
pygal/test/test_graph.py

@ -399,11 +399,7 @@ def test_labels_with_links(Chart):
q = chart.render_pyquery()
links = q('a')
if isinstance(chart, BaseMap):
# No country is found in this case so:
assert len(links) == 3
else:
assert len(links) == 7
assert len(links) == 7 or isinstance(chart, BaseMap) and len(links) == 3
def test_sparkline(Chart, datas):

4
pygal/test/test_line.py

@ -46,8 +46,8 @@ def test_simple_line():
'-30', '-25', '-20', '-15', '-10', '-5',
'0', '5', '10', '15', '20', '25', '30']
assert q(".axis.y text").map(texts) == [
'-1.2', '-1.0', '-0.8', '-0.6', '-0.4', '-0.2',
'0.0', '0.2', '0.4', '0.6', '0.8', '1.0', '1.2']
'-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']

16
pygal/test/test_stacked.py

@ -28,8 +28,8 @@ def test_stacked_line():
stacked.add('one_two', [1, 2])
stacked.add('ten_twelve', [10, 12])
q = stacked.render_pyquery()
assert set(q("desc.value").text().split(' ')) == set(
('1', '2', '11', '14'))
assert set([v.text for v in q("desc.value")]) == set(
('1', '2', '11 (+10)', '14 (+12)'))
def test_stacked_line_reverse():
@ -38,8 +38,8 @@ def test_stacked_line_reverse():
stacked.add('one_two', [1, 2])
stacked.add('ten_twelve', [10, 12])
q = stacked.render_pyquery()
assert set(q("desc.value").text().split(' ')) == set(
('11', '14', '10', '12'))
assert set([v.text for v in q("desc.value")]) == set(
('11 (+1)', '14 (+2)', '10', '12'))
def test_stacked_line_log():
@ -48,8 +48,8 @@ def test_stacked_line_log():
stacked.add('one_two', [1, 2])
stacked.add('ten_twelve', [10, 12])
q = stacked.render_pyquery()
assert set(q("desc.value").text().split(' ')) == set(
('1', '2', '11', '14'))
assert set([v.text for v in q("desc.value")]) == set(
('1', '2', '11 (+10)', '14 (+12)'))
def test_stacked_line_interpolate():
@ -58,5 +58,5 @@ def test_stacked_line_interpolate():
stacked.add('one_two', [1, 2])
stacked.add('ten_twelve', [10, 12])
q = stacked.render_pyquery()
assert set(q("desc.value").text().split(' ')) == set(
('1', '2', '11', '14'))
assert set([v.text for v in q("desc.value")]) == set(
('1', '2', '11 (+10)', '14 (+12)'))

32
pygal/test/test_util.py

@ -21,7 +21,7 @@
from pygal._compat import u
from pygal.util import (
round_to_int, round_to_float, _swap_curly, template, humanize,
round_to_int, round_to_float, _swap_curly, template,
truncate, minify_css, majorize)
from pytest import raises
@ -86,36 +86,6 @@ def test_format():
o=obj) == 'foo 1 True-3'
def test_humanize():
"""Test humanize function"""
assert humanize(1) == '1'
assert humanize(1.) == '1'
assert humanize(10) == '10'
assert humanize(12.5) == '12.5'
assert humanize(1000) == '1k'
assert humanize(5000) == '5k'
assert humanize(100000) == '100k'
assert humanize(1253) == '1.253k'
assert humanize(1250) == '1.25k'
assert humanize(0.1) == '100m'
assert humanize(0.01) == '10m'
assert humanize(0.001) == '1m'
assert humanize(0.002) == '2m'
assert humanize(0.0025) == '2.5m'
assert humanize(0.0001) == u('100µ')
assert humanize(0.000123) == u('123µ')
assert humanize(0.00001) == u('10µ')
assert humanize(0.000001) == u('')
assert humanize(0.0000001) == u('100n')
assert humanize(0.0000000001) == u('100p')
assert humanize(0) == '0'
assert humanize(0.) == '0'
assert humanize(-1337) == '-1.337k'
assert humanize(-.000000042) == '-42n'
def test_truncate():
"""Test truncate function"""
assert truncate('1234567890', 50) == '1234567890'

29
pygal/util.py

@ -24,12 +24,9 @@ from __future__ import division
import re
from decimal import Decimal
from math import ceil, floor, log, log10, pi
from math import ceil, floor, log10, pi
from pygal._compat import is_list_like, to_unicode, u
ORDERS = u("yzafpnµm kMGTPEZY")
from pygal._compat import to_unicode, u
def float_format(number):
@ -37,21 +34,6 @@ def float_format(number):
return ("%.3f" % number).rstrip('0').rstrip('.')
def humanize(number):
"""Format a number to engineer scale"""
if is_list_like(number):
return', '.join(map(humanize, number))
if number is None:
return u('')
order = number and int(floor(log(abs(number)) / log(1000)))
human_readable = ORDERS.split(" ")[int(order > 0)]
if order == 0 or order > len(human_readable):
return float_format(number / (1000 ** int(order)))
return (
float_format(number / (1000 ** int(order))) +
human_readable[int(order) - int(order > 0)])
def majorize(values):
"""Filter sequence to return only major considered numbers"""
sorted_values = sorted(values)
@ -355,3 +337,10 @@ def split_title(title, width, title_fs):
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)

Loading…
Cancel
Save