From a5f94ebd7d356c06948b18c3bf75271e84e20606 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Mon, 16 Feb 2015 17:28:04 +0100 Subject: [PATCH] Remove DateY and replace it by real XY datetime, date, time and timedelta support. Introduce new XY configuration options: `xrange`, `x_value_formatter`. --- CHANGELOG | 7 +- demo/moulinrouge/tests.py | 52 +++++++++++++- pygal/_compat.py | 12 ++++ pygal/adapters.py | 13 ---- pygal/config.py | 12 +++- pygal/ghost.py | 15 +++- pygal/graph/__init__.py | 8 ++- pygal/graph/base.py | 8 +++ pygal/graph/box.py | 5 ++ pygal/graph/datey.py | 147 -------------------------------------- pygal/graph/time.py | 108 ++++++++++++++++++++++++++++ pygal/graph/xy.py | 26 +++++-- pygal/test/test_config.py | 9 ++- pygal/test/test_date.py | 124 ++++++++++++++++++++++++-------- pygal/test/test_util.py | 1 + pygal/util.py | 13 +++- pygal/view.py | 10 ++- 17 files changed, 360 insertions(+), 210 deletions(-) delete mode 100644 pygal/graph/datey.py create mode 100644 pygal/graph/time.py diff --git a/CHANGELOG b/CHANGELOG index c4e1780..c7f0258 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,9 @@ -V 1.6.3 UNRELEASED +V 2.0.0 UNRELEASED + Remove DateY and replace it by real XY datetime, date, time and timedelta support. + Introduce new XY configuration options: `xrange`, `x_value_formatter` + Rework the ghost mechanism to come back to a more object oriented behavior (WIP) + +V 1.6.3 Add show_x_labels option to remove them and the x axis. Set print_values to False by default. Fix secondary serie text values when None in data. (#192) diff --git a/demo/moulinrouge/tests.py b/demo/moulinrouge/tests.py index 5149d20..15e7870 100644 --- a/demo/moulinrouge/tests.py +++ b/demo/moulinrouge/tests.py @@ -3,7 +3,7 @@ from pygal import ( Bar, Gauge, Pyramid, Funnel, Dot, StackedBar, StackedLine, XY, CHARTS_BY_NAME, Config, Line, DateY, Worldmap, Histogram, Box, - FrenchMap_Departments, FrenchMap_Regions, Pie, Treemap) + FrenchMap_Departments, FrenchMap_Regions, Pie, Treemap, TimeLine, DateLine) from pygal.style import styles, Style, RotateStyle from pygal.colors import rotate from pygal.graph.frenchmap import DEPARTMENTS, REGIONS @@ -58,7 +58,7 @@ def get_test_routes(app): @app.route('/test/xy_links') def test_xy_links(): - xy = XY(style=styles['neon']) + xy = XY(style=styles['neon'], interpolate='cubic') xy.add('1234', [ {'value': (10, 5), 'label': 'Ten', @@ -219,6 +219,12 @@ def get_test_routes(app): graph.title = '123456789 ' * 30 return graph.render_response() + @app.route('/test/xy_single') + def test_xy_single(): + graph = XY(interpolate='cubic') + graph.add('Single', [(1, 1)]) + return graph.render_response() + @app.route('/test/datey_single') def test_datey_single(): graph = DateY(interpolate='cubic') @@ -371,6 +377,19 @@ def get_test_routes(app): stacked.add('2', [4, 5, 6]) return stacked.render_response() + @app.route('/test/dateline') + def test_dateline(): + from datetime import date + datey = DateLine(show_dots=False) + datey.add('1', [ + (datetime(2013, 1, 2), 300), + (datetime(2013, 1, 12), 412), + (datetime(2013, 2, 2), 823), + (datetime(2013, 2, 22), 672) + ]) + datey.x_label_rotation = 25 + return datey.render_response() + @app.route('/test/datey') def test_datey(): from datetime import datetime @@ -384,6 +403,35 @@ def get_test_routes(app): datey.x_label_rotation = 25 return datey.render_response() + @app.route('/test/datexy') + def test_datexy(): + from datetime import datetime, date, timedelta + datey = DateY() + datey.add('1', [ + (datetime(2011, 12, 21), 10), + (datetime(2014, 4, 8), 12), + (datetime(2010, 2, 28), 2) + ]) + datey.add('2', map( + lambda t: (date.today() + timedelta(days=t[0]), t[1]), + [(12, 4), (219, 8), (928, 6)])) + datey.x_label_rotation = 25 + return datey.render_response() + + @app.route('/test/timexy') + def test_timexy(): + from datetime import time + datey = TimeLine() + datey.add('1', [ + (time(1, 12, 29), 2), + (time(21, 2, 29), 10), + (time(12, 30, 59), 7) + ]) + datey.add('2', + [(time(12, 12, 12), 4), (time(), 8), (time(23, 59, 59), 6)]) + datey.x_label_rotation = 25 + return datey.render_response() + @app.route('/test/worldmap') def test_worldmap(): wmap = Worldmap(style=choice(list(styles.values()))) diff --git a/pygal/_compat.py b/pygal/_compat.py index d16d706..138ea12 100644 --- a/pygal/_compat.py +++ b/pygal/_compat.py @@ -18,6 +18,7 @@ # along with pygal. If not, see . import sys from collections import Iterable +import time if sys.version_info[0] == 3: @@ -60,3 +61,14 @@ def total_seconds(td): (td.days * 86400 + td.seconds) * 10 ** 6 + td.microseconds ) / 10 ** 6 return td.total_seconds() + + +def to_timestamp(x): + if hasattr(x, 'timestamp'): + return x.timestamp() + else: + if hasattr(x, 'utctimetuple'): + t = x.utctimetuple() + else: + t = x.timetuple() + return time.mktime(t) diff --git a/pygal/adapters.py b/pygal/adapters.py index 11b97eb..5b650f1 100644 --- a/pygal/adapters.py +++ b/pygal/adapters.py @@ -20,8 +20,6 @@ Value adapters to use when a chart doesn't accept all value types """ -import datetime -from numbers import Number from decimal import Decimal @@ -43,17 +41,6 @@ def none_to_zero(x): return x or 0 -def date(x): - # Make int work for date graphs by counting days number from now - if isinstance(x, Number): - try: - d = datetime.date.today() + datetime.timedelta(days=x) - return datetime.datetime.combine(d, datetime.time(0, 0, 0)) - except OverflowError: - return None - return x - - def decimal_to_float(x): if isinstance(x, Decimal): return float(x) diff --git a/pygal/config.py b/pygal/config.py index f85bf3a..9e91fad 100644 --- a/pygal/config.py +++ b/pygal/config.py @@ -232,7 +232,7 @@ class Config(CommonConfig): stack_from_top = Key( False, bool, "Look", "Stack from top to zero, this makes the stacked " - "data match the legend order") + "data match the legend order") spacing = Key( 10, int, "Look", @@ -336,6 +336,11 @@ class Config(CommonConfig): False, bool, "Value", "Display values in human readable format", "(ie: 12.4M)") + x_value_formatter = Key( + None, type(lambda: 1), "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") @@ -367,6 +372,11 @@ class Config(CommonConfig): None, list, "Value", "Explicitly specify min and max of 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") diff --git a/pygal/ghost.py b/pygal/ghost.py index 27019d8..9d43e64 100644 --- a/pygal/ghost.py +++ b/pygal/ghost.py @@ -37,9 +37,20 @@ class ChartCollection(object): pass -REAL_CHARTS = {} +REAL_CHARTS = { + 'DateY': 'pygal.graph.time.DateY', + 'DateTimeLine': 'pygal.graph.time.DateTimeLine', + 'DateLine': 'pygal.graph.time.DateLine', + 'TimeLine': 'pygal.graph.time.TimeLine', + 'TimeDeltaLine': 'pygal.graph.time.TimeDeltaLine' +} + for NAME in CHARTS_NAMES: - mod_name = 'pygal.graph.%s' % NAME.lower() + if NAME in REAL_CHARTS: + mod_name = 'pygal.graph.time' + else: + mod_name = 'pygal.graph.%s' % NAME.lower() + __import__(mod_name) mod = sys.modules[mod_name] chart = getattr(mod, NAME) diff --git a/pygal/graph/__init__.py b/pygal/graph/__init__.py index 250ee08..0a05b2a 100644 --- a/pygal/graph/__init__.py +++ b/pygal/graph/__init__.py @@ -36,11 +36,15 @@ CHARTS_NAMES = [ 'VerticalPyramid', 'Dot', 'Gauge', - 'DateY', 'Worldmap', 'SupranationalWorldmap', 'Histogram', 'Box', 'FrenchMap', - 'Treemap' + 'Treemap', + 'DateY', + 'DateTimeLine', + 'DateLine', + 'TimeLine', + 'TimeDeltaLine' ] diff --git a/pygal/graph/base.py b/pygal/graph/base.py index 8d6d68c..fd4b131 100644 --- a/pygal/graph/base.py +++ b/pygal/graph/base.py @@ -69,6 +69,8 @@ class BaseGraph(object): 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() @@ -76,6 +78,12 @@ class BaseGraph(object): def all_series(self): return self.series + self.secondary_series + @property + def _x_format(self): + """Return the value formatter for this graph""" + return self.config.x_value_formatter or ( + humanize if self.human_readable else str) + @property def _format(self): """Return the value formatter for this graph""" diff --git a/pygal/graph/box.py b/pygal/graph/box.py index 5e41f13..fa29bdc 100644 --- a/pygal/graph/box.py +++ b/pygal/graph/box.py @@ -89,6 +89,11 @@ class Box(Graph): for serie in self.series: self._boxf(serie) + @property + def _len(self): + """Len is always 5 here""" + return 5 + def _boxf(self, serie): """ For a specific series, draw the box plot. diff --git a/pygal/graph/datey.py b/pygal/graph/datey.py deleted file mode 100644 index 3476332..0000000 --- a/pygal/graph/datey.py +++ /dev/null @@ -1,147 +0,0 @@ -# -*- coding: utf-8 -*- -# This file is proposed as a part of pygal -# A python svg graph plotting library -# -# A python svg graph plotting library -# Copyright © 2012 Snarkturne (modified from Kozea XY class) -# -# 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 . - -""" -DateY graph - -Example : -import pygal -from datetime import datetime,timedelta - -def jour(n) : - return datetime(year=2014,month=1,day=1)+timedelta(days=n) - -x=(1,20,35,54,345,898) -x=tuple(map(jour,x)) -x_label=(0,100,200,300,400,500,600,700,800,900,1000) -x_label=map(jour,x_label) -y=(1,3,4,2,3,1) -graph=pygal.DateY(x_label_rotation=20) -graph.x_label_format = "%Y-%m-%d" -graph.x_labels = x_label -graph.add("graph1",list(zip(x,y))+[None,None]) -graph.render_in_browser() -""" - -from pygal._compat import total_seconds -from pygal.adapters import date -from pygal.util import compute_scale -from pygal.graph.xy import XY -import datetime - - -class DateY(XY): - """ DateY Graph """ - _offset = datetime.datetime(year=2000, month=1, day=1) - _adapters = [date] - - def _todate(self, d): - """ Converts a number to a date """ - currDateTime = self._offset + datetime.timedelta(seconds=d or 0) - return currDateTime.strftime(self.x_label_format) - - def _tonumber(self, d): - """ Converts a date to a number """ - if d is None: - return None - return total_seconds(d - self._offset) - - def _get_value(self, values, i): - return 'x=%s, y=%s' % ( - self._todate(values[i][0]), self._format(values[i][1])) - - def _compute(self): - # Approximatively the same code as in XY. - # The only difference is the transformation of dates to numbers - # (beginning) and the reversed transformation to dates (end) - self._offset = min([val[0] - for serie in self.series - for val in serie.values - if val[0] is not None] - or [datetime.datetime.fromtimestamp(0)]) - for serie in self.all_series: - serie.values = [(self._tonumber(v[0]), v[1]) for v in serie.values] - - if self.xvals: - xmin = min(self.xvals) - xmax = max(self.xvals) - rng = (xmax - xmin) - else: - rng = None - - if self.yvals: - ymin = self._min - ymax = self._max - if self.include_x_axis: - ymin = min(self._min or 0, 0) - ymax = max(self._max or 0, 0) - - for serie in self.all_series: - serie.points = serie.values - if self.interpolate and rng: - vals = list(zip(*sorted( - [t for t in serie.points if None not in t], - key=lambda x: x[0]))) - serie.interpolated = self._interpolate(vals[0], vals[1]) - - if self.interpolate and rng: - self.xvals = [val[0] - for serie in self.all_series - for val in serie.interpolated] - self.yvals = [val[1] - for serie in self.all_series - for val in serie.interpolated] - - xmin = min(self.xvals) - xmax = max(self.xvals) - rng = (xmax - xmin) - - # Calculate/prcoess the x_labels - if self.x_labels and all( - map(lambda x: isinstance( - x, (datetime.datetime, datetime.date)), self.x_labels)): - # Process the given x_labels - x_labels_num = [] - for label in self.x_labels: - x_labels_num.append(self._tonumber(label)) - x_pos = x_labels_num - - # Update the xmin/xmax to fit all of the x_labels and the data - xmin = min(xmin, min(x_pos)) - xmax = max(xmax, max(x_pos)) - - self._box.xmin, self._box.xmax = xmin, xmax - self._box.ymin, self._box.ymax = ymin, ymax - else: - # Automatically generate the x_labels - if rng: - self._box.xmin, self._box.xmax = xmin, xmax - self._box.ymin, self._box.ymax = ymin, ymax - - x_pos = compute_scale( - self._box.xmin, self._box.xmax, self.logarithmic, - self.order_min) - - # Always auto-generate the y labels - y_pos = compute_scale( - self._box.ymin, self._box.ymax, self.logarithmic, self.order_min) - - self._x_labels = list(zip(list(map(self._todate, x_pos)), x_pos)) - self._y_labels = list(zip(list(map(self._format, y_pos)), y_pos)) diff --git a/pygal/graph/time.py b/pygal/graph/time.py new file mode 100644 index 0000000..d9a8963 --- /dev/null +++ b/pygal/graph/time.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2014 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 datetime line plot +""" + +from pygal.graph.xy import XY +from datetime import datetime, date, time, timedelta +from pygal._compat import to_timestamp + + +def datetime_to_timestamp(x): + if isinstance(x, datetime): + return to_timestamp(x) + return x + + +def date_to_datetime(x): + if isinstance(x, date): + return datetime.combine(x, time()) + return x + + +def time_to_datetime(x): + if isinstance(x, time): + return datetime.combine(date(1970, 1, 1), x) + return x + + +def timedelta_to_seconds(x): + if isinstance(x, timedelta): + return x.total_seconds() + return x + + +class DateTimeLine(XY): + _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.fromtimestamp(x) + if self.config.x_value_formatter: + return self.config.x_value_formatter(dt) + return dt.isoformat() + return datetime_to_str + + +class DateLine(DateTimeLine): + + @property + def _x_format(self): + """Return the value formatter for this graph""" + def date_to_str(x): + d = date.fromtimestamp(x) + if self.config.x_value_formatter: + return self.config.x_value_formatter(d) + return d.isoformat() + return date_to_str + + +class TimeLine(DateTimeLine): + _x_adapters = [datetime_to_timestamp, time_to_datetime] + + @property + def _x_format(self): + """Return the value formatter for this graph""" + def date_to_str(x): + t = datetime.fromtimestamp(x).time() + if self.config.x_value_formatter: + return self.config.x_value_formatter(t) + return t.isoformat() + return date_to_str + + +class TimeDeltaLine(XY): + _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) + if self.config.x_value_formatter: + return self.config.x_value_formatter(td) + return str(td) + + return timedelta_to_str + +# Old pygal compat +DateY = DateTimeLine diff --git a/pygal/graph/xy.py b/pygal/graph/xy.py index 292c3a5..028a95e 100644 --- a/pygal/graph/xy.py +++ b/pygal/graph/xy.py @@ -22,13 +22,15 @@ XY Line graph """ from __future__ import division -from pygal.util import compute_scale, cached_property +from functools import reduce +from pygal.util import compute_scale, cached_property, compose from pygal.graph.line import Line class XY(Line): """XY Line graph""" _dual = True + _x_adapters = [] @cached_property def xvals(self): @@ -63,8 +65,17 @@ class XY(Line): def _compute(self): if self.xvals: - xmin = min(self.xvals) - xmax = max(self.xvals) + if self.xrange: + x_adapter = reduce( + compose, self._x_adapters) if getattr( + self, '_x_adapters', None) else None + + 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 @@ -83,13 +94,13 @@ class XY(Line): for serie in self.all_series: serie.points = serie.values - if self.interpolate and xrng: + 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 and xrng: + if self.interpolate: self.xvals = [val[0] for serie in self.all_series for val in serie.interpolated] @@ -109,9 +120,10 @@ class XY(Line): self._box.ymin, self._box.ymax = ymin, ymax x_pos = compute_scale( - self._box.xmin, self._box.xmax, self.logarithmic, self.order_min) + self._box.xmin, self._box.xmax, self.logarithmic, + self.order_min) y_pos = compute_scale( self._box.ymin, self._box.ymax, self.logarithmic, self.order_min) - self._x_labels = list(zip(map(self._format, x_pos), x_pos)) + self._x_labels = list(zip(map(self._x_format, x_pos), x_pos)) self._y_labels = list(zip(map(self._format, y_pos), y_pos)) diff --git a/pygal/test/test_config.py b/pygal/test/test_config.py index 9c281d1..d773ea9 100644 --- a/pygal/test/test_config.py +++ b/pygal/test/test_config.py @@ -21,7 +21,8 @@ from pygal import ( Line, Dot, Pie, Treemap, Radar, Config, Bar, Funnel, Worldmap, SupranationalWorldmap, Histogram, Gauge, Box, XY, Pyramid, DateY, HorizontalBar, HorizontalStackedBar, - FrenchMap_Regions, FrenchMap_Departments) + FrenchMap_Regions, FrenchMap_Departments, + DateTimeLine, TimeLine, DateLine, TimeDeltaLine) from pygal._compat import u from pygal.test.utils import texts from tempfile import NamedTemporaryFile @@ -362,7 +363,8 @@ def test_x_label_major(Chart): Pie, Treemap, Funnel, Dot, Gauge, Worldmap, SupranationalWorldmap, Histogram, Box, FrenchMap_Regions, FrenchMap_Departments, - Pyramid, DateY): + Pyramid, DateY, DateTimeLine, TimeLine, DateLine, + TimeDeltaLine): return chart = Chart() chart.add('test', range(12)) @@ -407,7 +409,8 @@ def test_y_label_major(Chart): SupranationalWorldmap, Histogram, Box, FrenchMap_Regions, FrenchMap_Departments, HorizontalBar, HorizontalStackedBar, - Pyramid, DateY): + Pyramid, DateTimeLine, TimeLine, DateLine, + TimeDeltaLine, DateY): return chart = Chart() data = range(12) diff --git a/pygal/test/test_date.py b/pygal/test/test_date.py index 73016a4..875284d 100644 --- a/pygal/test/test_date.py +++ b/pygal/test/test_date.py @@ -16,49 +16,117 @@ # # You should have received a copy of the GNU Lesser General Public License # along with pygal. If not, see . -from pygal import DateY +from pygal import DateLine, TimeLine, DateTimeLine, TimeDeltaLine from pygal.test.utils import texts -from datetime import datetime +from datetime import datetime, date, time, timedelta def test_date(): - datey = DateY(truncate_label=1000) - datey.add('dates', [ - (datetime(2013, 1, 2), 300), - (datetime(2013, 1, 12), 412), - (datetime(2013, 2, 2), 823), - (datetime(2013, 2, 22), 672) + 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 = datey.render_pyquery() + q = date_chart.render_pyquery() assert list( map(lambda t: t.split(' ')[0], q(".axis.x text").map(texts))) == [ - '2013-01-02', - '2013-01-13', - '2013-01-25', - '2013-02-05', - '2013-02-17' + '2013-01-12', + '2013-01-24', + '2013-02-04', + '2013-02-16' ] - datey.x_labels = [ - datetime(2013, 1, 1), - datetime(2013, 2, 1), - datetime(2013, 3, 1) - ] - q = datey.render_pyquery() +def test_time(): + 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))) == [ - '2013-01-01', - '2013-02-01', - '2013-03-01' - ] + '03:46:40', + '06:33:20', + '09:20:00', + '12:06:40', + '14:53:20', + '17:40:00', + '20:26:40' + ] + + +def test_datetime(): + 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-12T15:13:20', + '2013-01-24T05:00:00', + '2013-02-04T18:46:40', + '2013-02-16T08:33:20' + ] + + +def test_timedelta(): + 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( + q(".axis.x text").map(texts)) == [ + '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_overflow(): - datey = DateY(truncate_label=1000) - datey.add('dates', [1, 2, -1000000, 5, 100000000]) - assert datey.render_pyquery() +def test_date_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' + ] diff --git a/pygal/test/test_util.py b/pygal/test/test_util.py index 787611e..b68e683 100644 --- a/pygal/test/test_util.py +++ b/pygal/test/test_util.py @@ -16,6 +16,7 @@ # # You should have received a copy of the GNU Lesser General Public License # along with pygal. If not, see . +from datetime import time from pygal._compat import u from pygal.util import ( round_to_int, round_to_float, _swap_curly, template, humanize, diff --git a/pygal/util.py b/pygal/util.py index f08cec1..2e9521f 100644 --- a/pygal/util.py +++ b/pygal/util.py @@ -27,7 +27,8 @@ from decimal import Decimal from math import floor, pi, log, log10, ceil from itertools import cycle from functools import reduce -from pygal.adapters import not_zero, positive, decimal_to_float +from pygal.adapters import ( + not_zero, positive, decimal_to_float) ORDERS = u("yzafpnµm kMGTPEZY") @@ -334,7 +335,7 @@ def prepare_values(raw, config, cls, offset=0): """Prepare the values to start with sane values""" from pygal.serie import Serie from pygal.config import SerieConfig - from pygal.graph.datey import DateY + from pygal.graph.time import DateY from pygal.graph.histogram import Histogram from pygal.graph.worldmap import Worldmap from pygal.graph.frenchmap import FrenchMapDepartments @@ -357,6 +358,9 @@ def prepare_values(raw, config, cls, offset=0): adapters = adapters + [positive, not_zero] adapters = adapters + [decimal_to_float] adapter = reduce(compose, adapters) if not config.strict else ident + x_adapter = reduce( + compose, cls._x_adapters) if getattr( + cls, '_x_adapters', None) else None series = [] raw = [( @@ -404,13 +408,16 @@ def prepare_values(raw, config, cls, offset=0): value = (None, None) elif not is_list_like(value): value = (value, config.zero) - if issubclass(cls, DateY) or issubclass( + if x_adapter: + value = (x_adapter(value[0]), adapter(value[1])) + if issubclass( cls, (Worldmap, FrenchMapDepartments)): value = (adapter(value[0]), value[1]) else: value = list(map(adapter, value)) else: value = adapter(value) + values.append(value) serie_config = SerieConfig() serie_config(**config.to_dict()) diff --git a/pygal/view.py b/pygal/view.py index 45b96b0..0de1e48 100644 --- a/pygal/view.py +++ b/pygal/view.py @@ -201,8 +201,11 @@ class PolarLogView(View): 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""" @@ -257,10 +260,11 @@ class PolarThetaLogView(View): '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 @@ -294,6 +298,8 @@ class LogView(View): 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): @@ -347,6 +353,8 @@ class HorizontalLogView(XLogView): 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()