diff --git a/license.txt b/license.txt index be6dbee..c57e12f 100644 --- a/license.txt +++ b/license.txt @@ -1,8 +1,7 @@ - - The MIT License Copyright © 2008 Jason R. Coombs +Copyright © 2011 Kozea Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/setup.py b/setup.py index ba60b53..f09a43f 100644 --- a/setup.py +++ b/setup.py @@ -7,9 +7,9 @@ from setuptools import find_packages from distutils.cmd import Command class DisabledTestCommand(Command): - user_options = [] - def __init__(self, dist): - raise RuntimeError("test command not supported on svg.charts. Use setup.py nosetests instead") + user_options = [] + def __init__(self, dist): + raise RuntimeError("test command not supported on svg.charts. Use setup.py nosetests instead") _this_dir = os.path.dirname(__file__) _readme = os.path.join(_this_dir, 'readme.txt') @@ -17,48 +17,48 @@ _long_description = open(_readme).read().strip() # it seems that dateutil 2.0 only works under Python 3 dateutil_req = ( - ['python-dateutil>=1.4,<2.0dev'] if sys.version_info < (3,0) - else ['python-dateutil>=2.0'] ) + ['python-dateutil>=1.4,<2.0dev'] if sys.version_info < (3,0) + else ['python-dateutil>=2.0'] ) setup_params = dict( - name = "svg.charts", - use_hg_version=True, - description = "Python SVG Charting Library", - long_description = _long_description, - author = "Jason R. Coombs", - author_email = "jaraco@jaraco.com", - url = "http://svg-charts.sourceforge.net", - packages = find_packages(), - zip_safe=True, - namespace_packages=['svg'], - include_package_data = True, - install_requires=[ - 'cssutils>=0.9.8a3', - 'lxml>=2.0', - ] + dateutil_req, - license = "MIT", - classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Intended Audience :: Science/Research", - "Programming Language :: Python :: 2.6", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - ], - entry_points = { - }, - # Don't use setup.py test - nose doesn't support it - # see http://code.google.com/p/python-nose/issues/detail?id=219 - cmdclass=dict( - test=DisabledTestCommand, - ), - setup_requires=[ - 'hgtools', - ], - use_2to3 = True, + name = "svg.charts", + use_hg_version=True, + description = "Python SVG Charting Library", + long_description = _long_description, + author = "Jason R. Coombs", + author_email = "jaraco@jaraco.com", + url = "http://svg-charts.sourceforge.net", + packages = find_packages(), + zip_safe=True, + namespace_packages=['svg'], + include_package_data = True, + install_requires=[ + 'cssutils>=0.9.8a3', + 'lxml>=2.0', + ] + dateutil_req, + license = "MIT", + classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Programming Language :: Python :: 2.6", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + ], + entry_points = { + }, + # Don't use setup.py test - nose doesn't support it + # see http://code.google.com/p/python-nose/issues/detail?id=219 + cmdclass=dict( + test=DisabledTestCommand, + ), + setup_requires=[ + 'hgtools', + ], + use_2to3 = True, ) if __name__ == '__main__': - from setuptools import setup - setup(**setup_params) + from setuptools import setup + setup(**setup_params) diff --git a/svg/charts/bar.py b/svg/charts/bar.py index a6e04b1..8384091 100644 --- a/svg/charts/bar.py +++ b/svg/charts/bar.py @@ -6,83 +6,83 @@ from svg.charts.graph import Graph __all__ = ('VerticalBar', 'HorizontalBar') class Bar(Graph): - "A superclass for bar-style graphs. Do not instantiate directly." - - # gap between bars - bar_gap = True - # how to stack adjacent dataset series - # overlap - overlap bars with transparent colors - # top - stack bars on top of one another - # side - stack bars side-by-side - stack = 'overlap' - - scale_divisions = None - - stylesheet_names = Graph.stylesheet_names + ['bar.css'] - - def __init__(self, fields, *args, **kargs): - self.fields = fields - super(Bar, self).__init__(*args, **kargs) - - # adapted from Plot - def get_data_values(self): - min_value, max_value, scale_division = self.data_range() - result = tuple(float_range(min_value, max_value + scale_division, scale_division)) - if self.scale_integers: - result = map(int, result) - return result - - # adapted from plot (very much like calling data_range('y')) - def data_range(self): - min_value = self.data_min() - max_value = self.data_max() - range = max_value - min_value - - data_pad = range / 20.0 or 10 - scale_range = (max_value + data_pad) - min_value - - scale_division = self.scale_divisions or (scale_range / 10.0) - - if self.scale_integers: - scale_division = round(scale_division) or 1 - - return min_value, max_value, scale_division - - def get_field_labels(self): - return self.fields - - def get_data_labels(self): - return map(str, self.get_data_values()) - - def data_max(self): - return max(chain(*map(lambda set: set['data'], self.data))) - # above is same as - # return max(map(lambda set: max(set['data']), self.data)) - - def data_min(self): - if not getattr(self, 'min_scale_value') is None: return self.min_scale_value - min_value = min(chain(*map(lambda set: set['data'], self.data))) - min_value = min(min_value, 0) - return min_value - - def get_bar_gap(self, field_size): - bar_gap = 10 # default gap - if field_size < 10: - # adjust for narrow fields - bar_gap = field_size / 2 - # the following zero's out the gap if bar_gap is False - bar_gap = int(self.bar_gap) * bar_gap - return bar_gap + "A superclass for bar-style graphs. Do not instantiate directly." + + # gap between bars + bar_gap = True + # how to stack adjacent dataset series + # overlap - overlap bars with transparent colors + # top - stack bars on top of one another + # side - stack bars side-by-side + stack = 'overlap' + + scale_divisions = None + + stylesheet_names = Graph.stylesheet_names + ['bar.css'] + + def __init__(self, fields, *args, **kargs): + self.fields = fields + super(Bar, self).__init__(*args, **kargs) + + # adapted from Plot + def get_data_values(self): + min_value, max_value, scale_division = self.data_range() + result = tuple(float_range(min_value, max_value + scale_division, scale_division)) + if self.scale_integers: + result = map(int, result) + return result + + # adapted from plot (very much like calling data_range('y')) + def data_range(self): + min_value = self.data_min() + max_value = self.data_max() + range = max_value - min_value + + data_pad = range / 20.0 or 10 + scale_range = (max_value + data_pad) - min_value + + scale_division = self.scale_divisions or (scale_range / 10.0) + + if self.scale_integers: + scale_division = round(scale_division) or 1 + + return min_value, max_value, scale_division + + def get_field_labels(self): + return self.fields + + def get_data_labels(self): + return map(str, self.get_data_values()) + + def data_max(self): + return max(chain(*map(lambda set: set['data'], self.data))) + # above is same as + # return max(map(lambda set: max(set['data']), self.data)) + + def data_min(self): + if not getattr(self, 'min_scale_value') is None: return self.min_scale_value + min_value = min(chain(*map(lambda set: set['data'], self.data))) + min_value = min(min_value, 0) + return min_value + + def get_bar_gap(self, field_size): + bar_gap = 10 # default gap + if field_size < 10: + # adjust for narrow fields + bar_gap = field_size / 2 + # the following zero's out the gap if bar_gap is False + bar_gap = int(self.bar_gap) * bar_gap + return bar_gap def float_range(start = 0, stop = None, step = 1): - "Much like the built-in function range, but accepts floats" - while start < stop: - yield float(start) - start += step + "Much like the built-in function range, but accepts floats" + while start < stop: + yield float(start) + start += step class VerticalBar(Bar): - """ # === Create presentation quality SVG bar graphs easily + """ # === Create presentation quality SVG bar graphs easily # # = Synopsis # @@ -134,115 +134,115 @@ class VerticalBar(Bar): # * SVG::Graph::Plot # * SVG::Graph::TimeSeries """ - top_align = top_font = 1 - - def get_x_labels(self): - return self.get_field_labels() - - def get_y_labels(self): - return self.get_data_labels() - - def x_label_offset(self, width): - return width / 2.0 - - def draw_data(self): - min_value = self.data_min() - unit_size = (float(self.graph_height) - self.font_size*2*self.top_font) - unit_size /= (max(self.get_data_values()) - min(self.get_data_values())) - - bar_gap = self.get_bar_gap(self.get_field_width()) - - bar_width = self.get_field_width() - bar_gap - if self.stack == 'side': - bar_width /= len(self.data) - - x_mod = (self.graph_width - bar_gap)/2 - if self.stack == 'side': - x_mod -= bar_width/2 - - bottom = self.graph_height - - for field_count, field in enumerate(self.fields): - for dataset_count, dataset in enumerate(self.data): - # cases (assume 0 = +ve): - # value min length - # +ve +ve value - min - # +ve -ve value - 0 - # -ve -ve value.abs - 0 - value = dataset['data'][field_count] - - left = self.get_field_width() * field_count - - length = (abs(value) - max(min_value, 0)) * unit_size - # top is 0 if value is negative - top = bottom - ((max(value,0) - min_value) * unit_size) - if self.stack == 'side': - left += bar_width * dataset_count - - rect = etree.SubElement(self.graph, 'rect', { - 'x': str(left), - 'y': str(top), - 'width': str(bar_width), - 'height': str(length), - 'class': 'fill%s' % (dataset_count+1), - }) - - self.make_datapoint_text(left + bar_width/2.0, top-6, value) + top_align = top_font = 1 + + def get_x_labels(self): + return self.get_field_labels() + + def get_y_labels(self): + return self.get_data_labels() + + def x_label_offset(self, width): + return width / 2.0 + + def draw_data(self): + min_value = self.data_min() + unit_size = (float(self.graph_height) - self.font_size*2*self.top_font) + unit_size /= (max(self.get_data_values()) - min(self.get_data_values())) + + bar_gap = self.get_bar_gap(self.get_field_width()) + + bar_width = self.get_field_width() - bar_gap + if self.stack == 'side': + bar_width /= len(self.data) + + x_mod = (self.graph_width - bar_gap)/2 + if self.stack == 'side': + x_mod -= bar_width/2 + + bottom = self.graph_height + + for field_count, field in enumerate(self.fields): + for dataset_count, dataset in enumerate(self.data): + # cases (assume 0 = +ve): + # value min length + # +ve +ve value - min + # +ve -ve value - 0 + # -ve -ve value.abs - 0 + value = dataset['data'][field_count] + + left = self.get_field_width() * field_count + + length = (abs(value) - max(min_value, 0)) * unit_size + # top is 0 if value is negative + top = bottom - ((max(value,0) - min_value) * unit_size) + if self.stack == 'side': + left += bar_width * dataset_count + + rect = etree.SubElement(self.graph, 'rect', { + 'x': str(left), + 'y': str(top), + 'width': str(bar_width), + 'height': str(length), + 'class': 'fill%s' % (dataset_count+1), + }) + + self.make_datapoint_text(left + bar_width/2.0, top-6, value) class HorizontalBar(Bar): - rotate_y_labels = True - show_x_guidelines = True - show_y_guidelines = False - right_align = right_font = True - - - def get_x_labels(self): - return self.get_data_labels() - - def get_y_labels(self): - return self.get_field_labels() - - def y_label_offset(self, height): - return height / -2.0 - - def draw_data(self): - min_value = self.data_min() - - unit_size = float(self.graph_width) - unit_size -= self.font_size*2*self.right_font - unit_size /= max(self.get_data_values()) - min(self.get_data_values()) - - bar_gap = self.get_bar_gap(self.get_field_height()) - - bar_height = self.get_field_height() - bar_gap - if self.stack == 'side': - bar_height /= len(self.data) - - y_mod = (bar_height / 2) + (self.font_size / 2) - - for field_count, field in enumerate(self.fields): - for dataset_count, dataset in enumerate(self.data): - value = dataset['data'][field_count] - - top = self.graph_height - (self.get_field_height() * (field_count+1)) - if self.stack == 'side': - top += (bar_height * dataset_count) - # cases (assume 0 = +ve): - # value min length left - # +ve +ve value.abs - min minvalue.abs - # +ve -ve value.abs - 0 minvalue.abs - # -ve -ve value.abs - 0 minvalue.abs + value - length = (abs(value) - max(min_value, 0)) * unit_size - # left is 0 if value is negative - left = (abs(min_value) + min(value, 0)) * unit_size - - rect = etree.SubElement(self.graph, 'rect', { - 'x': str(left), - 'y': str(top), - 'width': str(length), - 'height': str(bar_height), - 'class': 'fill%s' % (dataset_count+1), - }) - - self.make_datapoint_text(left+length+5, top+y_mod, value, - "text-anchor: start; ") + rotate_y_labels = True + show_x_guidelines = True + show_y_guidelines = False + right_align = right_font = True + + + def get_x_labels(self): + return self.get_data_labels() + + def get_y_labels(self): + return self.get_field_labels() + + def y_label_offset(self, height): + return height / -2.0 + + def draw_data(self): + min_value = self.data_min() + + unit_size = float(self.graph_width) + unit_size -= self.font_size*2*self.right_font + unit_size /= max(self.get_data_values()) - min(self.get_data_values()) + + bar_gap = self.get_bar_gap(self.get_field_height()) + + bar_height = self.get_field_height() - bar_gap + if self.stack == 'side': + bar_height /= len(self.data) + + y_mod = (bar_height / 2) + (self.font_size / 2) + + for field_count, field in enumerate(self.fields): + for dataset_count, dataset in enumerate(self.data): + value = dataset['data'][field_count] + + top = self.graph_height - (self.get_field_height() * (field_count+1)) + if self.stack == 'side': + top += (bar_height * dataset_count) + # cases (assume 0 = +ve): + # value min length left + # +ve +ve value.abs - min minvalue.abs + # +ve -ve value.abs - 0 minvalue.abs + # -ve -ve value.abs - 0 minvalue.abs + value + length = (abs(value) - max(min_value, 0)) * unit_size + # left is 0 if value is negative + left = (abs(min_value) + min(value, 0)) * unit_size + + rect = etree.SubElement(self.graph, 'rect', { + 'x': str(left), + 'y': str(top), + 'width': str(length), + 'height': str(bar_height), + 'class': 'fill%s' % (dataset_count+1), + }) + + self.make_datapoint_text(left+length+5, top+y_mod, value, + "text-anchor: start; ") diff --git a/svg/charts/css.py b/svg/charts/css.py index 0f71afa..bc782f4 100644 --- a/svg/charts/css.py +++ b/svg/charts/css.py @@ -3,69 +3,69 @@ import cssutils SVG = 'SVG 1.1' # http://www.w3.org/TR/SVG11/styling.html macros = { - 'paint': 'none|currentColor|{color}', - 'unitidentifier': 'em|ex|px|pt|pc|cm|mm|in|%', - 'length': '{positivenum}({unitidentifier})?', - 'dasharray': '{positivenum}(\s*,\s*{positivenum})*', - # a number greater-than or equal to one - 'number-ge-one': '{[1-9][0-9]*(\.[0-9]+)?', - } + 'paint': 'none|currentColor|{color}', + 'unitidentifier': 'em|ex|px|pt|pc|cm|mm|in|%', + 'length': '{positivenum}({unitidentifier})?', + 'dasharray': '{positivenum}(\s*,\s*{positivenum})*', + # a number greater-than or equal to one + 'number-ge-one': '{[1-9][0-9]*(\.[0-9]+)?', + } properties = { - # Clipping, Masking, and Compositing - 'clip-path': '{uri}|none|inherit', - 'clip-rule': 'nonzero|evenodd|inherit', - 'mask': '{uri}|none|inherit', - 'opacity': '{num}|inherit', + # Clipping, Masking, and Compositing + 'clip-path': '{uri}|none|inherit', + 'clip-rule': 'nonzero|evenodd|inherit', + 'mask': '{uri}|none|inherit', + 'opacity': '{num}|inherit', - # Filter Effects - 'enable-background': 'accumulate|new(\s+{num}){0,4}|inherit', - 'filter': '{uri}|none|inherit', - 'flood-color': 'currentColor|{color}|inherit', - 'flood-opacity': '{num}|inherit', - 'lighting-color': 'currentColor|{color}|inherit', + # Filter Effects + 'enable-background': 'accumulate|new(\s+{num}){0,4}|inherit', + 'filter': '{uri}|none|inherit', + 'flood-color': 'currentColor|{color}|inherit', + 'flood-opacity': '{num}|inherit', + 'lighting-color': 'currentColor|{color}|inherit', - # Gradient Properties - 'stop-color': 'currentColor|{color}|inherit', - 'stop-opacity': '{num}|inherit', + # Gradient Properties + 'stop-color': 'currentColor|{color}|inherit', + 'stop-opacity': '{num}|inherit', - # Interactivity Properties - 'pointer-events': 'visiblePainted|visibleFill|visibleStroke|visible|painted|fill|stroke|all|none|inherit', + # Interactivity Properties + 'pointer-events': 'visiblePainted|visibleFill|visibleStroke|visible|painted|fill|stroke|all|none|inherit', - # Color and Pointing Properties - 'color-interpolation': 'auto|sRGB|linearRGB|inherit', - 'color-interpolation-filters': 'auto|sRGB|linearRGB|inherit', - 'color-rendering': 'auto|optimizeSpeed|optimizeQuality|inherit', - 'shape-rendering': 'auto|optimizeSpeed|crispEdges|geometricPrecision|inherit', - 'text-rendering': 'auto|optimizeSpeed|optimizeLegibility|geometricPrecision|inherit', - 'fill': '{paint}', - 'fill-opacity': '{num}|inherit', - 'fill-rule': 'nonzero|evenodd|inherit', - 'image-rendering': 'auto|optimizeSpeed|optimizeQuality|inherit', - 'marker': 'none|inherit|{uri}', - 'marker-end': 'none|inherit|{uri}', - 'marker-mid': 'none|inherit|{uri}', - 'marker-start': 'none|inherit|{uri}', - 'shape-rendering': 'auto|optimizeSpeed|crispEdges|geometricPrecision|inherit', - 'stroke': '{paint}', - 'stroke-dasharray': 'none|{dasharray}|inherit', - 'stroke-dashoffset': '{length}|inherit', - 'stroke-linecap': 'butt|round|square|inherit', - 'stroke-linejoin': 'miter|round|bevel|inherit', - 'stroke-miterlimit': '{number-ge-one}|inherit', - 'stroke-opacity': '{num}|inherit', - 'stroke-width': '{length}|inherit', - 'text-rendering': 'auto|optimizeSpeed|optimizeLegibility|geometricPrecision|inherit', + # Color and Pointing Properties + 'color-interpolation': 'auto|sRGB|linearRGB|inherit', + 'color-interpolation-filters': 'auto|sRGB|linearRGB|inherit', + 'color-rendering': 'auto|optimizeSpeed|optimizeQuality|inherit', + 'shape-rendering': 'auto|optimizeSpeed|crispEdges|geometricPrecision|inherit', + 'text-rendering': 'auto|optimizeSpeed|optimizeLegibility|geometricPrecision|inherit', + 'fill': '{paint}', + 'fill-opacity': '{num}|inherit', + 'fill-rule': 'nonzero|evenodd|inherit', + 'image-rendering': 'auto|optimizeSpeed|optimizeQuality|inherit', + 'marker': 'none|inherit|{uri}', + 'marker-end': 'none|inherit|{uri}', + 'marker-mid': 'none|inherit|{uri}', + 'marker-start': 'none|inherit|{uri}', + 'shape-rendering': 'auto|optimizeSpeed|crispEdges|geometricPrecision|inherit', + 'stroke': '{paint}', + 'stroke-dasharray': 'none|{dasharray}|inherit', + 'stroke-dashoffset': '{length}|inherit', + 'stroke-linecap': 'butt|round|square|inherit', + 'stroke-linejoin': 'miter|round|bevel|inherit', + 'stroke-miterlimit': '{number-ge-one}|inherit', + 'stroke-opacity': '{num}|inherit', + 'stroke-width': '{length}|inherit', + 'text-rendering': 'auto|optimizeSpeed|optimizeLegibility|geometricPrecision|inherit', - # Text Properties - 'alignment-baseline': 'auto|baseline|before-edge|text-before-edge|middle|central|after-edge|text-after-edge|ideographic|alphabetic|hanging|mathematical|inherit', - 'baseline-shift': 'baseline|sub|super|{percentage}|{length}|inherit', - 'dominant-baseline': 'auto|use-script|no-change|reset-size|ideographic|alphabetic|hanging||mathematical|central|middle|text-after-edge|text-before-edge|inherit', - 'glyph-orientation-horizontal': '{angle}|inherit', - 'glyph-orientation-vertical': 'auto|{angle}|inherit', - 'kerning': 'auto|{length}|inherit', - 'text-anchor': 'start|middle|end|inherit', - 'writing-mode': 'lr-tb|rl-tb|tb-rl|lr|rl|tb|inherit', - } + # Text Properties + 'alignment-baseline': 'auto|baseline|before-edge|text-before-edge|middle|central|after-edge|text-after-edge|ideographic|alphabetic|hanging|mathematical|inherit', + 'baseline-shift': 'baseline|sub|super|{percentage}|{length}|inherit', + 'dominant-baseline': 'auto|use-script|no-change|reset-size|ideographic|alphabetic|hanging||mathematical|central|middle|text-after-edge|text-before-edge|inherit', + 'glyph-orientation-horizontal': '{angle}|inherit', + 'glyph-orientation-vertical': 'auto|{angle}|inherit', + 'kerning': 'auto|{length}|inherit', + 'text-anchor': 'start|middle|end|inherit', + 'writing-mode': 'lr-tb|rl-tb|tb-rl|lr|rl|tb|inherit', + } cssutils.profile.addProfile(SVG, properties, macros) diff --git a/svg/charts/graph.py b/svg/charts/graph.py index 21567ad..b6b5e58 100644 --- a/svg/charts/graph.py +++ b/svg/charts/graph.py @@ -18,680 +18,680 @@ from lxml import etree from svg.charts import css # causes the SVG profile to be loaded try: - import zlib + import zlib except ImportError: - zlib = None + zlib = None def sort_multiple(arrays): - "sort multiple lists (of equal size) using the first list for the sort keys" - tuples = zip(*arrays) - tuples.sort() - return zip(*tuples) + "sort multiple lists (of equal size) using the first list for the sort keys" + tuples = zip(*arrays) + tuples.sort() + return zip(*tuples) class Graph(object): - """ - Base object for generating SVG Graphs - - Synopsis - - This class is only used as a superclass of specialized charts. Do not - attempt to use this class directly, unless creating a new chart type. - - For examples of how to subclass this class, see the existing specific - subclasses, such as svn.charts.Pie. - - * svg.charts.bar - * svg.charts.line - * svg.charts.pie - * svg.charts.plot - * svg.charts.time_series - - """ - width= 500 - height= 300 - show_x_guidelines= False - show_y_guidelines= True - show_data_values= True - min_scale_value= None - show_x_labels= True - stagger_x_labels= False - rotate_x_labels= False - step_x_labels= 1 - step_include_first_x_label= True - show_y_labels= True - rotate_y_labels= False - stagger_y_labels= False - step_include_first_y_label= True - step_y_labels= 1 - scale_integers= False - show_x_title= False - x_title= 'X Field names' - show_y_title= False - y_title_text_direction= 'bt' # 'bt' for bottom to top; 'tb' for top to bottom - y_title= 'Y Scale' - show_graph_title= False - graph_title= 'Graph Title' - show_graph_subtitle= False - graph_subtitle= 'Graph Subtitle' - key= True - key_position= 'right' # 'bottom' or 'right', - - font_size= 12 - title_font_size= 16 - subtitle_font_size= 14 - x_label_font_size= 12 - x_title_font_size= 14 - y_label_font_size= 12 - y_title_font_size= 14 - key_font_size= 10 - - css_inline= False - add_popups= False - - top_align = top_font = right_align = right_font = 0 - - compress = False - - stylesheet_names = ['graph.css'] - - def __init__(self, config = {}): - """Initialize the graph object with the graph settings.""" - if self.__class__ is Graph: - raise NotImplementedError("Graph is an abstract base class") - self.load_config(config) - self.clear_data() - self.style = {} - - def load_config(self, config): - self.__dict__.update(config) - - def add_data(self, conf): - """ - Add data to the graph object. May be called several times to add - additional data sets. - - >>> data_sales_02 = [12, 45, 21] # doctest: +SKIP - >>> graph.add_data({ # doctest: +SKIP - ... 'data': data_sales_02, - ... 'title': 'Sales 2002' - ... }) # doctest: +SKIP - """ - self.validate_data(conf) - self.process_data(conf) - self.data.append(conf) - - def validate_data(self, conf): - try: - assert(isinstance(conf['data'], (tuple, list))) - except TypeError, e: - raise TypeError, "conf should be dictionary with 'data' and other items" - except AssertionError: - if not hasattr(conf['data'], '__iter__'): - raise TypeError, "conf['data'] should be tuple or list or iterable" - - def process_data(self, data): pass - - def clear_data(self): - """ - This method removes all data from the object so that you can - reuse it to create a new graph but with the same config options. - - >>> graph.clear_data() # doctest: +SKIP - """ - self.data = [] - - def burn(self): - """ - Process the template with the data and - config which has been set and return the resulting SVG. - - Raises ValueError when no data set has - been added to the graph object. - """ - if not self.data: raise ValueError("No data available") - - if hasattr(self, 'calculations'): self.calculations() - - self.start_svg() - self.calculate_graph_dimensions() - self.foreground = etree.Element("g") - self.draw_graph() - self.draw_titles() - self.draw_legend() - self.draw_data() - self.graph.append(self.foreground) - self.render_inline_styles() - - return self._burn_compressed() - - def _burn_compressed(self): - if self.compress and not zlib: - self.root.addprevious(etree.Comment('Python zlib not available for SVGZ')) - - data = etree.tostring(self.root, pretty_print=True, xml_declaration=True, encoding='utf-8') - - if self.compress and zlib: - data = zlib.compress(data) - - return data - - KEY_BOX_SIZE = 12 - - def calculate_left_margin(self): - """ - Calculates the margin to the left of the plot area, setting - border_left. - """ - bl = 7 - # Check for Y labels - if self.rotate_y_labels: - max_y_label_height_px = self.y_label_font_size - else: - label_lengths = map(len, self.get_y_labels()) - max_y_label_len = max(label_lengths) - max_y_label_height_px = 0.6 * max_y_label_len * self.y_label_font_size - if self.show_y_labels: bl += max_y_label_height_px - if self.stagger_y_labels: bl += max_y_label_height_px + 10 - if self.show_y_title: bl += self.y_title_font_size + 5 - self.border_left = bl - - def max_y_label_width_px(self): - """ - Calculate the width of the widest Y label. This will be the - character height if the Y labels are rotated. - """ - if self.rotate_y_labels: - return self.font_size - - def calculate_right_margin(self): - """ - Calculate the margin in pixels to the right of the plot area, - setting border_right. - """ - br = 7 - if self.key and self.key_position == 'right': - max_key_len = max(map(len, self.keys())) - br += max_key_len * self.key_font_size * 0.6 - br += self.KEY_BOX_SIZE - br += 10 # Some padding around the box - self.border_right = br - - def calculate_top_margin(self): - """ - Calculate the margin in pixels above the plot area, setting - border_top. - """ - self.border_top = 5 - if self.show_graph_title: self.border_top += self.title_font_size - self.border_top += 5 - if self.show_graph_subtitle: self.border_top += self.subtitle_font_size - - def add_popup(self, x, y, label): - """ - Add pop-up information to a point on the graph. - """ - txt_width = len(label) * self.font_size * 0.6 + 10 - tx = x + [5,-5][int(x+txt_width > self.width)] - anchor = ['start', 'end'][x+txt_width > self.width] - style = 'fill: #000; text-anchor: %s;' % anchor - id = 'label-%s' % label - t = etree.SubElement(self.foreground, 'text', { - 'x': str(tx), - 'y': str(y - self.font_size), - 'visibility': 'hidden', - 'style': style, - 'text': label, - 'id': id - }) - - # add the circle element to the foreground - visibility = "document.getElementById('%s').setAttribute('visibility', %%s)" % id - t = etree.SubElement(self.foreground, 'circle', { - 'cx': str(x), - 'cy': str(y), - 'r': str(10), - 'style': 'opacity: 0;', - 'onmouseover': visibility % 'visible', - 'onmouseout': visibility % 'hidden', - }) - - def calculate_bottom_margin(self): - """ - Calculate the margin in pixels below the plot area, setting - border_bottom. - """ - bb = 7 - if self.key and self.key_position == 'bottom': - bb += len(self.data) * (self.font_size + 5) - bb += 10 - if self.show_x_labels: - max_x_label_height_px = self.x_label_font_size - if self.rotate_x_labels: - label_lengths = map(len, self.get_x_labels()) - max_x_label_len = reduce(max, label_lengths) - max_x_label_height_px *= 0.6 * max_x_label_len - bb += max_x_label_height_px - if self.stagger_x_labels: bb += max_x_label_height_px + 10 - if self.show_x_title: bb += self.x_title_font_size + 5 - self.border_bottom = bb - - def draw_graph(self): - """ - The central logic for drawing the graph. - - Sets self.graph (the 'g' element in the SVG root) - """ - transform = 'translate (%s %s)' % (self.border_left, self.border_top) - self.graph = etree.SubElement(self.root, 'g', transform=transform) - - etree.SubElement(self.graph, 'rect', { - 'x': '0', - 'y': '0', - 'width': str(self.graph_width), - 'height': str(self.graph_height), - 'class': 'graphBackground' - }) - - #Axis - etree.SubElement(self.graph, 'path', { - 'd': 'M 0 0 v%s' % self.graph_height, - 'class': 'axis', - 'id': 'xAxis' - }) - etree.SubElement(self.graph, 'path', { - 'd': 'M 0 %s h%s' % (self.graph_height, self.graph_width), - 'class': 'axis', - 'id': 'yAxis' - }) - - self.draw_x_labels() - self.draw_y_labels() - - def x_label_offset(self, width): - """ - Return an offset for drawing the x label. Currently returns 0. - """ - # consider width/2 for centering the labels - return 0 - - def make_datapoint_text(self, x, y, value, style=None): - """ - Add text for a datapoint - """ - if not self.show_data_values: - # do nothing - return - # first lay down the text in a wide white stroke to - # differentiate it from the background - e = etree.SubElement(self.foreground, 'text', { - 'x': str(x), - 'y': str(y), - 'class': 'dataPointLabel', - 'style': '%(style)s stroke: #fff; stroke-width: 2;' % vars(), - }) - e.text = str(value) - # then lay down the text in the specified style - e = etree.SubElement(self.foreground, 'text', { - 'x': str(x), - 'y': str(y), - 'class': 'dataPointLabel'}) - e.text = str(value) - if style: e.set('style', style) - - def draw_x_labels(self): - "Draw the X axis labels" - if self.show_x_labels: - labels = self.get_x_labels() - count = len(labels) - - labels = enumerate(iter(labels)) - start = int(not self.step_include_first_x_label) - labels = islice(labels, start, None, self.step_x_labels) - map(self.draw_x_label, labels) - self.draw_x_guidelines(self.field_width(), count) - - def draw_x_label(self, label): - label_width = self.field_width() - index, label = label - text = etree.SubElement(self.graph, 'text', {'class': 'xAxisLabels'}) - text.text = label - - x = index * label_width + self.x_label_offset(label_width) - y = self.graph_height + self.x_label_font_size + 3 - t = 0 - (self.font_size / 2) - - if self.stagger_x_labels and (index % 2): - stagger = self.x_label_font_size + 5 - y += stagger - graph_height = self.graph_height - path = etree.SubElement(self.graph, 'path', { - 'd': 'M%(x)f %(graph_height)f v%(stagger)d' % vars(), - 'class': 'staggerGuideLine' - }) - - text.set('x', str(x)) - text.set('y', str(y)) - - if self.rotate_x_labels: - transform = 'rotate(90 %d %d) translate(0 -%d)' % \ - (x, y-self.x_label_font_size, self.x_label_font_size/4) - text.set('transform', transform) - text.set('style', 'text-anchor: start') - else: - text.set('style', 'text-anchor: middle') - - def y_label_offset(self, height): - """ - Return an offset for drawing the y label. Currently returns 0. - """ - # Consider height/2 to center within the field. - return 0 - - def get_field_width(self): - return float(self.graph_width - self.font_size*2*self.right_font) / \ - (len(self.get_x_labels()) - self.right_align) - field_width = get_field_width - - def get_field_height(self): - return float(self.graph_height - self.font_size*2*self.top_font) / \ - (len(self.get_y_labels()) - self.top_align) - field_height = get_field_height - - def draw_y_labels(self): - "Draw the Y axis labels" - if not self.show_y_labels: - # do nothing - return - - labels = self.get_y_labels() - count = len(labels) - - labels = enumerate(iter(labels)) - start = int(not self.step_include_first_y_label) - labels = islice(labels, start, None, self.step_y_labels) - map(self.draw_y_label, labels) - self.draw_y_guidelines(self.field_height(), count) - - def get_y_offset(self): - result = self.graph_height + self.y_label_offset(self.field_height()) - if not self.rotate_y_labels: result += self.font_size/1.2 - return result - y_offset = property(get_y_offset) - - def draw_y_label(self, label): - label_height = self.field_height() - index, label = label - text = etree.SubElement(self.graph, 'text', {'class': 'yAxisLabels'}) - text.text = label - - y = self.y_offset - (label_height * index) - x = {True: 0, False:-3}[self.rotate_y_labels] - - if self.stagger_y_labels and (index % 2): - stagger = self.y_label_font_size + 5 - x -= stagger - path = etree.SubElement(self.graph, 'path', { - 'd': 'M%(x)f %(y)f h%(stagger)d' % vars(), - 'class': 'staggerGuideLine' - }) - - text.set('x', str(x)) - text.set('y', str(y)) - - if self.rotate_y_labels: - transform = 'translate(-%d 0) rotate (90 %d %d)' % \ - (self.font_size, x, y) - text.set('transform', transform) - text.set('style', 'text-anchor: middle') - else: - text.set('y', str(y - self.y_label_font_size/2)) - text.set('style', 'text-anchor: end') - - def draw_x_guidelines(self, label_height, count): - "Draw the X-axis guidelines" - if not self.show_x_guidelines: return - # skip the first one - for count in range(1,count): - start = label_height*count - stop = self.graph_height - path = etree.SubElement(self.graph, 'path', { - 'd': 'M %(start)s 0 v%(stop)s' % vars(), - 'class': 'guideLines'}) - - def draw_y_guidelines(self, label_height, count): - "Draw the Y-axis guidelines" - if not self.show_y_guidelines: return - for count in range(1, count): - start = self.graph_height - label_height*count - stop = self.graph_width - path = etree.SubElement(self.graph, 'path', { - 'd': 'M 0 %(start)s h%(stop)s' % vars(), - 'class': 'guideLines'}) - - def draw_titles(self): - "Draws the graph title and subtitle" - if self.show_graph_title: self.draw_graph_title() - if self.show_graph_subtitle: self.draw_graph_subtitle() - if self.show_x_title: self.draw_x_title() - if self.show_y_title: self.draw_y_title() - - def draw_graph_title(self): - text = etree.SubElement(self.root, 'text', { - 'x': str(self.width / 2), - 'y': str(self.title_font_size), - 'class': 'mainTitle'}) - text.text = self.graph_title - - def draw_graph_subtitle(self): - y_subtitle_options = [subtitle_font_size, title_font_size+10] - y_subtitle = y_subtitle_options[self.show_graph_title] - text = etree.SubElement(self.root, 'text', { - 'x': str(self.width/2), - 'y': str(y_subtitle), - 'class': 'subTitle', - }) - text.text = self.graph_title - - def draw_x_title(self): - y = self.graph_height + self.border_top + self.x_title_font_size - if self.show_x_labels: - y_size = self.x_label_font_size+5 - if self.stagger_x_labels: y_size*=2 - y += y_size - x = self.width / 2 - - text = etree.SubElement(self.root, 'text', { - 'x': str(x), - 'y': str(y), - 'class': 'xAxisTitle', - }) - text.text = self.x_title - - def draw_y_title(self): - x = self.y_title_font_size - if self.y_title_text_direction=='bt': - x += 3 - rotate = -90 - else: - x -= 3 - rotate = 90 - y = self.height / 2 - text = etree.SubElement(self.root, 'text', { - 'x': str(x), - 'y': str(y), - 'class': 'yAxisTitle', - }) - text.text = self.y_title - text.set('transform', 'rotate(%(rotate)d, %(x)s, %(y)s)' % vars()) - - def keys(self): - return map(itemgetter('title'), self.data) - - def draw_legend(self): - if not self.key: - # do nothing - return - - group = etree.SubElement(self.root, 'g') - - for key_count, key_name in enumerate(self.keys()): - y_offset = (self.KEY_BOX_SIZE * key_count) + (key_count * 5) - etree.SubElement(group, 'rect', { - 'x': '0', - 'y': str(y_offset), - 'width': str(self.KEY_BOX_SIZE), - 'height': str(self.KEY_BOX_SIZE), - 'class': 'key%s' % (key_count + 1), - }) - text = etree.SubElement(group, 'text', { - 'x': str(self.KEY_BOX_SIZE + 5), - 'y': str(y_offset + self.KEY_BOX_SIZE), - 'class': 'keyText'}) - text.text = key_name - - if self.key_position == 'right': - x_offset = self.graph_width + self.border_left + 10 - y_offset = self.border_top + 20 - if self.key_position == 'bottom': - x_offset, y_offset = self.calculate_offsets_bottom() - group.set('transform', 'translate(%(x_offset)d %(y_offset)d)' % vars()) - - def calculate_offsets_bottom(self): - x_offset = self.border_left + 20 - y_offset = self.border_top + self.graph_height + 5 - if self.show_x_labels: - max_x_label_height_px = x_label_font_size - if self.rotate_x_labels: - longest_label_length = max(map(len, self.get_x_labels())) - # note: I think 0.6 is the ratio of width to height of characters - max_x_label_height_px *= longest_label_length * 0.6 - y_offset += max_x_label_height_px - if self.stagger_x_labels: - y_offset += max_x_label_height_px + 5 - if self.show_x_title: - y_offset += x_title_font_size + 5 - return x_offset, y_offset - - def render_inline_styles(self): - "Hard-code the styles into the SVG XML if style sheets are not used." - if not self.css_inline: - # do nothing - return - - styles = self.parse_css() - for node in xpath.Evaluate('//*[@class]', self.root): - cl = node.getAttribute('class') - style = styles[cl] - if node.hasAttribute('style'): - style += node.getAttribute('style') - node.setAttribute('style', style) - - def parse_css(self): - """ - Take a .css file (classes only please) and parse it into a dictionary - of class/style pairs. - """ - # todo: save the prefs for use later - #orig_prefs = cssutils.ser.prefs - cssutils.ser.prefs.useMinified() - get_pair = lambda r: (r.selectorText, r.style.cssText) - result = dict(map(get_pair, self.get_stylesheet())) - return result - - def add_defs(self, defs): - """ - Override and place code to add defs here. TODO: what are defs? - """ - - def start_svg(self): - "Base SVG Document Creation" - SVG_NAMESPACE = 'http://www.w3.org/2000/svg' - SVG = '{%s}' % SVG_NAMESPACE - NSMAP = { - None: SVG_NAMESPACE, - 'xlink': 'http://www.w3.org/1999/xlink', - 'a3': 'http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/', - } - self.root = etree.Element(SVG+"svg", attrib={ - 'width': str(self.width), - 'height': str(self.height), - 'viewBox': '0 0 %s %s' % (self.width, self.height), - '{http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/}scriptImplementation': 'Adobe', - }, nsmap=NSMAP) - if hasattr(self, 'style_sheet_href'): - pi = etree.ProcessingInstruction( - 'xml-stylesheet', - 'href="%s" type="text/css"' % self.style_sheet_href - ) - self.root.addprevious(pi) - - comment_strings = ( - ' Created with SVG.Graph ', - ' SVG.Graph by Jason R. Coombs ', - ' Based on SVG::Graph by Sean E. Russel ', - ' Based on Perl SVG:TT:Graph by Leo Lapworth & Stephan Morgan ', - ' '+'/'*66, - ) - map(self.root.append, map(etree.Comment, comment_strings)) - - defs = etree.SubElement(self.root, 'defs') - self.add_defs(defs) - - if not hasattr(self, 'style_sheet_href') and not self.css_inline: - self.root.append(etree.Comment(' include default stylesheet if none specified ')) - style = etree.SubElement(defs, 'style', type='text/css') - # TODO: the text was previously escaped in a CDATA declaration... how - # to do that with etree? - style.text = self.get_stylesheet().cssText - - self.root.append(etree.Comment('SVG Background')) - rect = etree.SubElement(self.root, 'rect', { - 'width': str(self.width), - 'height': str(self.height), - 'x': '0', - 'y': '0', - 'class': 'svgBackground'}) - - def calculate_graph_dimensions(self): - self.calculate_left_margin() - self.calculate_right_margin() - self.calculate_bottom_margin() - self.calculate_top_margin() - self.graph_width = self.width - self.border_left - self.border_right - self.graph_height = self.height - self.border_top - self.border_bottom - - @staticmethod - def load_resource_stylesheet(name, subs=dict()): - css_stream = pkg_resources.resource_stream('svg.charts', name) - css_string = css_stream.read().decode('utf-8') - css_string = css_string % subs - sheet = cssutils.parseString(css_string) - return sheet - - def get_stylesheet_resources(self): - "Get the stylesheets for this instance" - # allow css to include class variables - class_vars = class_dict(self) - loader = functools.partial(self.load_resource_stylesheet, - subs=class_vars) - sheets = map(loader, self.stylesheet_names) - return sheets - - def get_stylesheet(self): - cssutils.log.setLevel(30) # disable INFO log messages - def merge_sheets(s1, s2): - map(s1.add, s2) - return s1 - return reduce(merge_sheets, self.get_stylesheet_resources()) + """ + Base object for generating SVG Graphs + + Synopsis + + This class is only used as a superclass of specialized charts. Do not + attempt to use this class directly, unless creating a new chart type. + + For examples of how to subclass this class, see the existing specific + subclasses, such as svn.charts.Pie. + + * svg.charts.bar + * svg.charts.line + * svg.charts.pie + * svg.charts.plot + * svg.charts.time_series + + """ + width= 500 + height= 300 + show_x_guidelines= False + show_y_guidelines= True + show_data_values= True + min_scale_value= None + show_x_labels= True + stagger_x_labels= False + rotate_x_labels= False + step_x_labels= 1 + step_include_first_x_label= True + show_y_labels= True + rotate_y_labels= False + stagger_y_labels= False + step_include_first_y_label= True + step_y_labels= 1 + scale_integers= False + show_x_title= False + x_title= 'X Field names' + show_y_title= False + y_title_text_direction= 'bt' # 'bt' for bottom to top; 'tb' for top to bottom + y_title= 'Y Scale' + show_graph_title= False + graph_title= 'Graph Title' + show_graph_subtitle= False + graph_subtitle= 'Graph Subtitle' + key= True + key_position= 'right' # 'bottom' or 'right', + + font_size= 12 + title_font_size= 16 + subtitle_font_size= 14 + x_label_font_size= 12 + x_title_font_size= 14 + y_label_font_size= 12 + y_title_font_size= 14 + key_font_size= 10 + + css_inline= False + add_popups= False + + top_align = top_font = right_align = right_font = 0 + + compress = False + + stylesheet_names = ['graph.css'] + + def __init__(self, config = {}): + """Initialize the graph object with the graph settings.""" + if self.__class__ is Graph: + raise NotImplementedError("Graph is an abstract base class") + self.load_config(config) + self.clear_data() + self.style = {} + + def load_config(self, config): + self.__dict__.update(config) + + def add_data(self, conf): + """ + Add data to the graph object. May be called several times to add + additional data sets. + + >>> data_sales_02 = [12, 45, 21] # doctest: +SKIP + >>> graph.add_data({ # doctest: +SKIP + ... 'data': data_sales_02, + ... 'title': 'Sales 2002' + ... }) # doctest: +SKIP + """ + self.validate_data(conf) + self.process_data(conf) + self.data.append(conf) + + def validate_data(self, conf): + try: + assert(isinstance(conf['data'], (tuple, list))) + except TypeError, e: + raise TypeError, "conf should be dictionary with 'data' and other items" + except AssertionError: + if not hasattr(conf['data'], '__iter__'): + raise TypeError, "conf['data'] should be tuple or list or iterable" + + def process_data(self, data): pass + + def clear_data(self): + """ + This method removes all data from the object so that you can + reuse it to create a new graph but with the same config options. + + >>> graph.clear_data() # doctest: +SKIP + """ + self.data = [] + + def burn(self): + """ + Process the template with the data and + config which has been set and return the resulting SVG. + + Raises ValueError when no data set has + been added to the graph object. + """ + if not self.data: raise ValueError("No data available") + + if hasattr(self, 'calculations'): self.calculations() + + self.start_svg() + self.calculate_graph_dimensions() + self.foreground = etree.Element("g") + self.draw_graph() + self.draw_titles() + self.draw_legend() + self.draw_data() + self.graph.append(self.foreground) + self.render_inline_styles() + + return self._burn_compressed() + + def _burn_compressed(self): + if self.compress and not zlib: + self.root.addprevious(etree.Comment('Python zlib not available for SVGZ')) + + data = etree.tostring(self.root, pretty_print=True, xml_declaration=True, encoding='utf-8') + + if self.compress and zlib: + data = zlib.compress(data) + + return data + + KEY_BOX_SIZE = 12 + + def calculate_left_margin(self): + """ + Calculates the margin to the left of the plot area, setting + border_left. + """ + bl = 7 + # Check for Y labels + if self.rotate_y_labels: + max_y_label_height_px = self.y_label_font_size + else: + label_lengths = map(len, self.get_y_labels()) + max_y_label_len = max(label_lengths) + max_y_label_height_px = 0.6 * max_y_label_len * self.y_label_font_size + if self.show_y_labels: bl += max_y_label_height_px + if self.stagger_y_labels: bl += max_y_label_height_px + 10 + if self.show_y_title: bl += self.y_title_font_size + 5 + self.border_left = bl + + def max_y_label_width_px(self): + """ + Calculate the width of the widest Y label. This will be the + character height if the Y labels are rotated. + """ + if self.rotate_y_labels: + return self.font_size + + def calculate_right_margin(self): + """ + Calculate the margin in pixels to the right of the plot area, + setting border_right. + """ + br = 7 + if self.key and self.key_position == 'right': + max_key_len = max(map(len, self.keys())) + br += max_key_len * self.key_font_size * 0.6 + br += self.KEY_BOX_SIZE + br += 10 # Some padding around the box + self.border_right = br + + def calculate_top_margin(self): + """ + Calculate the margin in pixels above the plot area, setting + border_top. + """ + self.border_top = 5 + if self.show_graph_title: self.border_top += self.title_font_size + self.border_top += 5 + if self.show_graph_subtitle: self.border_top += self.subtitle_font_size + + def add_popup(self, x, y, label): + """ + Add pop-up information to a point on the graph. + """ + txt_width = len(label) * self.font_size * 0.6 + 10 + tx = x + [5,-5][int(x+txt_width > self.width)] + anchor = ['start', 'end'][x+txt_width > self.width] + style = 'fill: #000; text-anchor: %s;' % anchor + id = 'label-%s' % label + t = etree.SubElement(self.foreground, 'text', { + 'x': str(tx), + 'y': str(y - self.font_size), + 'visibility': 'hidden', + 'style': style, + 'text': label, + 'id': id + }) + + # add the circle element to the foreground + visibility = "document.getElementById('%s').setAttribute('visibility', %%s)" % id + t = etree.SubElement(self.foreground, 'circle', { + 'cx': str(x), + 'cy': str(y), + 'r': str(10), + 'style': 'opacity: 0;', + 'onmouseover': visibility % 'visible', + 'onmouseout': visibility % 'hidden', + }) + + def calculate_bottom_margin(self): + """ + Calculate the margin in pixels below the plot area, setting + border_bottom. + """ + bb = 7 + if self.key and self.key_position == 'bottom': + bb += len(self.data) * (self.font_size + 5) + bb += 10 + if self.show_x_labels: + max_x_label_height_px = self.x_label_font_size + if self.rotate_x_labels: + label_lengths = map(len, self.get_x_labels()) + max_x_label_len = reduce(max, label_lengths) + max_x_label_height_px *= 0.6 * max_x_label_len + bb += max_x_label_height_px + if self.stagger_x_labels: bb += max_x_label_height_px + 10 + if self.show_x_title: bb += self.x_title_font_size + 5 + self.border_bottom = bb + + def draw_graph(self): + """ + The central logic for drawing the graph. + + Sets self.graph (the 'g' element in the SVG root) + """ + transform = 'translate (%s %s)' % (self.border_left, self.border_top) + self.graph = etree.SubElement(self.root, 'g', transform=transform) + + etree.SubElement(self.graph, 'rect', { + 'x': '0', + 'y': '0', + 'width': str(self.graph_width), + 'height': str(self.graph_height), + 'class': 'graphBackground' + }) + + #Axis + etree.SubElement(self.graph, 'path', { + 'd': 'M 0 0 v%s' % self.graph_height, + 'class': 'axis', + 'id': 'xAxis' + }) + etree.SubElement(self.graph, 'path', { + 'd': 'M 0 %s h%s' % (self.graph_height, self.graph_width), + 'class': 'axis', + 'id': 'yAxis' + }) + + self.draw_x_labels() + self.draw_y_labels() + + def x_label_offset(self, width): + """ + Return an offset for drawing the x label. Currently returns 0. + """ + # consider width/2 for centering the labels + return 0 + + def make_datapoint_text(self, x, y, value, style=None): + """ + Add text for a datapoint + """ + if not self.show_data_values: + # do nothing + return + # first lay down the text in a wide white stroke to + # differentiate it from the background + e = etree.SubElement(self.foreground, 'text', { + 'x': str(x), + 'y': str(y), + 'class': 'dataPointLabel', + 'style': '%(style)s stroke: #fff; stroke-width: 2;' % vars(), + }) + e.text = str(value) + # then lay down the text in the specified style + e = etree.SubElement(self.foreground, 'text', { + 'x': str(x), + 'y': str(y), + 'class': 'dataPointLabel'}) + e.text = str(value) + if style: e.set('style', style) + + def draw_x_labels(self): + "Draw the X axis labels" + if self.show_x_labels: + labels = self.get_x_labels() + count = len(labels) + + labels = enumerate(iter(labels)) + start = int(not self.step_include_first_x_label) + labels = islice(labels, start, None, self.step_x_labels) + map(self.draw_x_label, labels) + self.draw_x_guidelines(self.field_width(), count) + + def draw_x_label(self, label): + label_width = self.field_width() + index, label = label + text = etree.SubElement(self.graph, 'text', {'class': 'xAxisLabels'}) + text.text = label + + x = index * label_width + self.x_label_offset(label_width) + y = self.graph_height + self.x_label_font_size + 3 + t = 0 - (self.font_size / 2) + + if self.stagger_x_labels and (index % 2): + stagger = self.x_label_font_size + 5 + y += stagger + graph_height = self.graph_height + path = etree.SubElement(self.graph, 'path', { + 'd': 'M%(x)f %(graph_height)f v%(stagger)d' % vars(), + 'class': 'staggerGuideLine' + }) + + text.set('x', str(x)) + text.set('y', str(y)) + + if self.rotate_x_labels: + transform = 'rotate(90 %d %d) translate(0 -%d)' % \ + (x, y-self.x_label_font_size, self.x_label_font_size/4) + text.set('transform', transform) + text.set('style', 'text-anchor: start') + else: + text.set('style', 'text-anchor: middle') + + def y_label_offset(self, height): + """ + Return an offset for drawing the y label. Currently returns 0. + """ + # Consider height/2 to center within the field. + return 0 + + def get_field_width(self): + return float(self.graph_width - self.font_size*2*self.right_font) / \ + (len(self.get_x_labels()) - self.right_align) + field_width = get_field_width + + def get_field_height(self): + return float(self.graph_height - self.font_size*2*self.top_font) / \ + (len(self.get_y_labels()) - self.top_align) + field_height = get_field_height + + def draw_y_labels(self): + "Draw the Y axis labels" + if not self.show_y_labels: + # do nothing + return + + labels = self.get_y_labels() + count = len(labels) + + labels = enumerate(iter(labels)) + start = int(not self.step_include_first_y_label) + labels = islice(labels, start, None, self.step_y_labels) + map(self.draw_y_label, labels) + self.draw_y_guidelines(self.field_height(), count) + + def get_y_offset(self): + result = self.graph_height + self.y_label_offset(self.field_height()) + if not self.rotate_y_labels: result += self.font_size/1.2 + return result + y_offset = property(get_y_offset) + + def draw_y_label(self, label): + label_height = self.field_height() + index, label = label + text = etree.SubElement(self.graph, 'text', {'class': 'yAxisLabels'}) + text.text = label + + y = self.y_offset - (label_height * index) + x = {True: 0, False:-3}[self.rotate_y_labels] + + if self.stagger_y_labels and (index % 2): + stagger = self.y_label_font_size + 5 + x -= stagger + path = etree.SubElement(self.graph, 'path', { + 'd': 'M%(x)f %(y)f h%(stagger)d' % vars(), + 'class': 'staggerGuideLine' + }) + + text.set('x', str(x)) + text.set('y', str(y)) + + if self.rotate_y_labels: + transform = 'translate(-%d 0) rotate (90 %d %d)' % \ + (self.font_size, x, y) + text.set('transform', transform) + text.set('style', 'text-anchor: middle') + else: + text.set('y', str(y - self.y_label_font_size/2)) + text.set('style', 'text-anchor: end') + + def draw_x_guidelines(self, label_height, count): + "Draw the X-axis guidelines" + if not self.show_x_guidelines: return + # skip the first one + for count in range(1,count): + start = label_height*count + stop = self.graph_height + path = etree.SubElement(self.graph, 'path', { + 'd': 'M %(start)s 0 v%(stop)s' % vars(), + 'class': 'guideLines'}) + + def draw_y_guidelines(self, label_height, count): + "Draw the Y-axis guidelines" + if not self.show_y_guidelines: return + for count in range(1, count): + start = self.graph_height - label_height*count + stop = self.graph_width + path = etree.SubElement(self.graph, 'path', { + 'd': 'M 0 %(start)s h%(stop)s' % vars(), + 'class': 'guideLines'}) + + def draw_titles(self): + "Draws the graph title and subtitle" + if self.show_graph_title: self.draw_graph_title() + if self.show_graph_subtitle: self.draw_graph_subtitle() + if self.show_x_title: self.draw_x_title() + if self.show_y_title: self.draw_y_title() + + def draw_graph_title(self): + text = etree.SubElement(self.root, 'text', { + 'x': str(self.width / 2), + 'y': str(self.title_font_size), + 'class': 'mainTitle'}) + text.text = self.graph_title + + def draw_graph_subtitle(self): + y_subtitle_options = [subtitle_font_size, title_font_size+10] + y_subtitle = y_subtitle_options[self.show_graph_title] + text = etree.SubElement(self.root, 'text', { + 'x': str(self.width/2), + 'y': str(y_subtitle), + 'class': 'subTitle', + }) + text.text = self.graph_title + + def draw_x_title(self): + y = self.graph_height + self.border_top + self.x_title_font_size + if self.show_x_labels: + y_size = self.x_label_font_size+5 + if self.stagger_x_labels: y_size*=2 + y += y_size + x = self.width / 2 + + text = etree.SubElement(self.root, 'text', { + 'x': str(x), + 'y': str(y), + 'class': 'xAxisTitle', + }) + text.text = self.x_title + + def draw_y_title(self): + x = self.y_title_font_size + if self.y_title_text_direction=='bt': + x += 3 + rotate = -90 + else: + x -= 3 + rotate = 90 + y = self.height / 2 + text = etree.SubElement(self.root, 'text', { + 'x': str(x), + 'y': str(y), + 'class': 'yAxisTitle', + }) + text.text = self.y_title + text.set('transform', 'rotate(%(rotate)d, %(x)s, %(y)s)' % vars()) + + def keys(self): + return map(itemgetter('title'), self.data) + + def draw_legend(self): + if not self.key: + # do nothing + return + + group = etree.SubElement(self.root, 'g') + + for key_count, key_name in enumerate(self.keys()): + y_offset = (self.KEY_BOX_SIZE * key_count) + (key_count * 5) + etree.SubElement(group, 'rect', { + 'x': '0', + 'y': str(y_offset), + 'width': str(self.KEY_BOX_SIZE), + 'height': str(self.KEY_BOX_SIZE), + 'class': 'key%s' % (key_count + 1), + }) + text = etree.SubElement(group, 'text', { + 'x': str(self.KEY_BOX_SIZE + 5), + 'y': str(y_offset + self.KEY_BOX_SIZE), + 'class': 'keyText'}) + text.text = key_name + + if self.key_position == 'right': + x_offset = self.graph_width + self.border_left + 10 + y_offset = self.border_top + 20 + if self.key_position == 'bottom': + x_offset, y_offset = self.calculate_offsets_bottom() + group.set('transform', 'translate(%(x_offset)d %(y_offset)d)' % vars()) + + def calculate_offsets_bottom(self): + x_offset = self.border_left + 20 + y_offset = self.border_top + self.graph_height + 5 + if self.show_x_labels: + max_x_label_height_px = x_label_font_size + if self.rotate_x_labels: + longest_label_length = max(map(len, self.get_x_labels())) + # note: I think 0.6 is the ratio of width to height of characters + max_x_label_height_px *= longest_label_length * 0.6 + y_offset += max_x_label_height_px + if self.stagger_x_labels: + y_offset += max_x_label_height_px + 5 + if self.show_x_title: + y_offset += x_title_font_size + 5 + return x_offset, y_offset + + def render_inline_styles(self): + "Hard-code the styles into the SVG XML if style sheets are not used." + if not self.css_inline: + # do nothing + return + + styles = self.parse_css() + for node in xpath.Evaluate('//*[@class]', self.root): + cl = node.getAttribute('class') + style = styles[cl] + if node.hasAttribute('style'): + style += node.getAttribute('style') + node.setAttribute('style', style) + + def parse_css(self): + """ + Take a .css file (classes only please) and parse it into a dictionary + of class/style pairs. + """ + # todo: save the prefs for use later + #orig_prefs = cssutils.ser.prefs + cssutils.ser.prefs.useMinified() + get_pair = lambda r: (r.selectorText, r.style.cssText) + result = dict(map(get_pair, self.get_stylesheet())) + return result + + def add_defs(self, defs): + """ + Override and place code to add defs here. TODO: what are defs? + """ + + def start_svg(self): + "Base SVG Document Creation" + SVG_NAMESPACE = 'http://www.w3.org/2000/svg' + SVG = '{%s}' % SVG_NAMESPACE + NSMAP = { + None: SVG_NAMESPACE, + 'xlink': 'http://www.w3.org/1999/xlink', + 'a3': 'http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/', + } + self.root = etree.Element(SVG+"svg", attrib={ + 'width': str(self.width), + 'height': str(self.height), + 'viewBox': '0 0 %s %s' % (self.width, self.height), + '{http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/}scriptImplementation': 'Adobe', + }, nsmap=NSMAP) + if hasattr(self, 'style_sheet_href'): + pi = etree.ProcessingInstruction( + 'xml-stylesheet', + 'href="%s" type="text/css"' % self.style_sheet_href + ) + self.root.addprevious(pi) + + comment_strings = ( + ' Created with SVG.Graph ', + ' SVG.Graph by Jason R. Coombs ', + ' Based on SVG::Graph by Sean E. Russel ', + ' Based on Perl SVG:TT:Graph by Leo Lapworth & Stephan Morgan ', + ' '+'/'*66, + ) + map(self.root.append, map(etree.Comment, comment_strings)) + + defs = etree.SubElement(self.root, 'defs') + self.add_defs(defs) + + if not hasattr(self, 'style_sheet_href') and not self.css_inline: + self.root.append(etree.Comment(' include default stylesheet if none specified ')) + style = etree.SubElement(defs, 'style', type='text/css') + # TODO: the text was previously escaped in a CDATA declaration... how + # to do that with etree? + style.text = self.get_stylesheet().cssText + + self.root.append(etree.Comment('SVG Background')) + rect = etree.SubElement(self.root, 'rect', { + 'width': str(self.width), + 'height': str(self.height), + 'x': '0', + 'y': '0', + 'class': 'svgBackground'}) + + def calculate_graph_dimensions(self): + self.calculate_left_margin() + self.calculate_right_margin() + self.calculate_bottom_margin() + self.calculate_top_margin() + self.graph_width = self.width - self.border_left - self.border_right + self.graph_height = self.height - self.border_top - self.border_bottom + + @staticmethod + def load_resource_stylesheet(name, subs=dict()): + css_stream = pkg_resources.resource_stream('svg.charts', name) + css_string = css_stream.read().decode('utf-8') + css_string = css_string % subs + sheet = cssutils.parseString(css_string) + return sheet + + def get_stylesheet_resources(self): + "Get the stylesheets for this instance" + # allow css to include class variables + class_vars = class_dict(self) + loader = functools.partial(self.load_resource_stylesheet, + subs=class_vars) + sheets = map(loader, self.stylesheet_names) + return sheets + + def get_stylesheet(self): + cssutils.log.setLevel(30) # disable INFO log messages + def merge_sheets(s1, s2): + map(s1.add, s2) + return s1 + return reduce(merge_sheets, self.get_stylesheet_resources()) class class_dict(object): - "Emulates a dictionary, but retrieves class attributes" - def __init__(self, obj): - self.__obj__ = obj + "Emulates a dictionary, but retrieves class attributes" + def __init__(self, obj): + self.__obj__ = obj - def __getitem__(self, item): - return getattr(self.__obj__, item) + def __getitem__(self, item): + return getattr(self.__obj__, item) - def keys(self): - # dir returns a good guess of what attributes might be available - return dir(self.__obj__) + def keys(self): + # dir returns a good guess of what attributes might be available + return dir(self.__obj__) diff --git a/svg/charts/line.py b/svg/charts/line.py index 0c373c5..89ba670 100644 --- a/svg/charts/line.py +++ b/svg/charts/line.py @@ -9,165 +9,165 @@ from util import flatten, float_range from svg.charts.graph import Graph class Line(Graph): - """Line Graph""" - - """Show a small circle on the graph where the line goes from one point to - the next""" - show_data_points = True - show_data_values = True - """Accumulates each data set. (i.e. Each point increased by sum of all - previous series at same point).""" - stacked = False - "Fill in the area under the plot" - area_fill = False - - scale_divisions = None - - #override some defaults - top_align = top_font = right_align = right_font = True - - stylesheet_names = Graph.stylesheet_names + ['plot.css'] - - def max_value(self): - data = map(itemgetter('data'), self.data) - if self.stacked: - data = self.get_cumulative_data() - return max(flatten(data)) - - def min_value(self): - if self.min_scale_value: - return self.min_scale_value - data = map(itemgetter('data'), self.data) - if self.stacked: - data = self.get_cumulative_data() - return min(flatten(data)) - - def get_cumulative_data(): - """Get the data as it will be charted. The first set will be - the actual first data set. The second will be the sum of the - first and the second, etc.""" - sets = map(itemgetter('data'), self.data) - if not sets: return - sum = sets.pop(0) - yield sum - while sets: - sum = map(add, sets.pop(0)) - yield sum - - def get_x_labels(self): - return self.fields - - def calculate_left_margin(self): - super(self.__class__, self).calculate_left_margin() - label_left = len(self.fields[0]) / 2 * self.font_size * 0.6 - self.border_left = max(label_left, self.border_left) - - def get_y_label_values(self): - max_value = self.max_value() - min_value = self.min_value() - range = max_value - min_value - top_pad = (range / 20.0) or 10 - scale_range = (max_value + top_pad) - min_value - - scale_division = self.scale_divisions or (scale_range / 10.0) - - if self.scale_integers: - scale_division = min(1, round(scale_division)) - - if max_value % scale_division == 0: - max_value += scale_division - labels = tuple(float_range(min_value, max_value, scale_division)) - return labels - - def get_y_labels(self): - return map(str, self.get_y_label_values()) - - def calc_coords(self, field, value, width = None, height = None): - if width is None: width = self.field_width - if height is None: height = self.field_height - coords = dict( - x = width * field, - y = self.graph_height - value * height, - ) - return coords - - def draw_data(self): - min_value = self.min_value() - field_height = self.graph_height - self.font_size*2*self.top_font - - y_label_values = self.get_y_label_values() - y_label_span = max(y_label_values) - min(y_label_values) - field_height /= float(y_label_span) - - field_width = self.field_width() - #line = len(self.data) - - prev_sum = [0]*len(self.fields) - cum_sum = [-min_value]*len(self.fields) - - coord_format = lambda c: '%(x)s %(y)s' % c - - for line_n, data in reversed(list(enumerate(self.data, 1))): - apath = '' - - if not self.stacked: cum_sum = [-min_value]*len(self.fields) - - cum_sum = map(add, cum_sum, data['data']) - get_coords = lambda (i, val): self.calc_coords(i, - val, - field_width, - field_height) - coords = map(get_coords, enumerate(cum_sum)) - paths = map(coord_format, coords) - line_path = ' '.join(paths) - - if self.area_fill: - # to draw the area, we'll use the line above, followed by - # tracing the bottom from right to left - if self.stacked: - prev_sum_rev = list(enumerate(prev_sum)).reversed() - coords = map(get_coords, prev_sum_rev) - paths = map(coord_format, coords) - area_path = ' '.join(paths) - origin = paths[-1] - else: - area_path = "V%(graph_height)s" % vars(self) - origin = coord_format(get_coords((0,0))) - - d = ' '.join(( - 'M', - origin, - 'L', - line_path, - area_path, - 'Z' - )) - etree.SubElement(self.graph, 'path', { - 'class': 'fill%(line_n)s' % vars(), - 'd': d, - }) - - # now draw the line itself - etree.SubElement(self.graph, 'path', { - 'd': 'M0 %s L%s' % (self.graph_height, line_path), - 'class': 'line%(line_n)s' % vars(), - }) - - if self.show_data_points or self.show_data_values: - for i, value in enumerate(cum_sum): - if self.show_data_points: - circle = etree.SubElement( - self.graph, - 'circle', - {'class': 'dataPoint%(line_n)s' % vars()}, - cx = str(field_width*i), - cy = str(self.graph_height - value*field_height), - r = '2.5', - ) - self.make_datapoint_text( - field_width*i, - self.graph_height - value*field_height - 6, - value + min_value - ) - - prev_sum = list(cum_sum) + """Line Graph""" + + """Show a small circle on the graph where the line goes from one point to + the next""" + show_data_points = True + show_data_values = True + """Accumulates each data set. (i.e. Each point increased by sum of all + previous series at same point).""" + stacked = False + "Fill in the area under the plot" + area_fill = False + + scale_divisions = None + + #override some defaults + top_align = top_font = right_align = right_font = True + + stylesheet_names = Graph.stylesheet_names + ['plot.css'] + + def max_value(self): + data = map(itemgetter('data'), self.data) + if self.stacked: + data = self.get_cumulative_data() + return max(flatten(data)) + + def min_value(self): + if self.min_scale_value: + return self.min_scale_value + data = map(itemgetter('data'), self.data) + if self.stacked: + data = self.get_cumulative_data() + return min(flatten(data)) + + def get_cumulative_data(): + """Get the data as it will be charted. The first set will be + the actual first data set. The second will be the sum of the + first and the second, etc.""" + sets = map(itemgetter('data'), self.data) + if not sets: return + sum = sets.pop(0) + yield sum + while sets: + sum = map(add, sets.pop(0)) + yield sum + + def get_x_labels(self): + return self.fields + + def calculate_left_margin(self): + super(self.__class__, self).calculate_left_margin() + label_left = len(self.fields[0]) / 2 * self.font_size * 0.6 + self.border_left = max(label_left, self.border_left) + + def get_y_label_values(self): + max_value = self.max_value() + min_value = self.min_value() + range = max_value - min_value + top_pad = (range / 20.0) or 10 + scale_range = (max_value + top_pad) - min_value + + scale_division = self.scale_divisions or (scale_range / 10.0) + + if self.scale_integers: + scale_division = min(1, round(scale_division)) + + if max_value % scale_division == 0: + max_value += scale_division + labels = tuple(float_range(min_value, max_value, scale_division)) + return labels + + def get_y_labels(self): + return map(str, self.get_y_label_values()) + + def calc_coords(self, field, value, width = None, height = None): + if width is None: width = self.field_width + if height is None: height = self.field_height + coords = dict( + x = width * field, + y = self.graph_height - value * height, + ) + return coords + + def draw_data(self): + min_value = self.min_value() + field_height = self.graph_height - self.font_size*2*self.top_font + + y_label_values = self.get_y_label_values() + y_label_span = max(y_label_values) - min(y_label_values) + field_height /= float(y_label_span) + + field_width = self.field_width() + #line = len(self.data) + + prev_sum = [0]*len(self.fields) + cum_sum = [-min_value]*len(self.fields) + + coord_format = lambda c: '%(x)s %(y)s' % c + + for line_n, data in reversed(list(enumerate(self.data, 1))): + apath = '' + + if not self.stacked: cum_sum = [-min_value]*len(self.fields) + + cum_sum = map(add, cum_sum, data['data']) + get_coords = lambda (i, val): self.calc_coords(i, + val, + field_width, + field_height) + coords = map(get_coords, enumerate(cum_sum)) + paths = map(coord_format, coords) + line_path = ' '.join(paths) + + if self.area_fill: + # to draw the area, we'll use the line above, followed by + # tracing the bottom from right to left + if self.stacked: + prev_sum_rev = list(enumerate(prev_sum)).reversed() + coords = map(get_coords, prev_sum_rev) + paths = map(coord_format, coords) + area_path = ' '.join(paths) + origin = paths[-1] + else: + area_path = "V%(graph_height)s" % vars(self) + origin = coord_format(get_coords((0,0))) + + d = ' '.join(( + 'M', + origin, + 'L', + line_path, + area_path, + 'Z' + )) + etree.SubElement(self.graph, 'path', { + 'class': 'fill%(line_n)s' % vars(), + 'd': d, + }) + + # now draw the line itself + etree.SubElement(self.graph, 'path', { + 'd': 'M0 %s L%s' % (self.graph_height, line_path), + 'class': 'line%(line_n)s' % vars(), + }) + + if self.show_data_points or self.show_data_values: + for i, value in enumerate(cum_sum): + if self.show_data_points: + circle = etree.SubElement( + self.graph, + 'circle', + {'class': 'dataPoint%(line_n)s' % vars()}, + cx = str(field_width*i), + cy = str(self.graph_height - value*field_height), + r = '2.5', + ) + self.make_datapoint_text( + field_width*i, + self.graph_height - value*field_height - 6, + value + min_value + ) + + prev_sum = list(cum_sum) diff --git a/svg/charts/pie.py b/svg/charts/pie.py index 2958043..0a1ced7 100644 --- a/svg/charts/pie.py +++ b/svg/charts/pie.py @@ -4,290 +4,290 @@ from lxml import etree from svg.charts.graph import Graph def robust_add(a,b): - "Add numbers a and b, treating None as 0" - if a is None: a = 0 - if b is None: b = 0 - return a+b + "Add numbers a and b, treating None as 0" + if a is None: a = 0 + if b is None: b = 0 + return a+b RADIANS = math.pi/180 class Pie(Graph): - """ - A presentation-quality SVG pie graph + """ + A presentation-quality SVG pie graph - Synopsis - ======== + Synopsis + ======== - from svg.charts.pie import Pie - fields = ['Jan', 'Feb', 'Mar'] - - data_sales_02 = [12, 45, 21] - - graph = Pie(dict( - height = 500, - width = 300, - fields = fields)) - graph.add_data({'data': data_sales_02, 'title': 'Sales 2002'}) - print "Content-type" image/svg+xml\r\n\r\n' - print graph.burn() - - Description - =========== - This object aims to allow you to easily create high quality - SVG pie graphs. You can either use the default style sheet - or supply your own. Either way there are many options which can - be configured to give you control over how the graph is - generated - with or without a key, display percent on pie chart, - title, subtitle etc. - """ - - "if true, displays a drop shadow for the chart" - show_shadow = True - "Sets the offset of the shadow from the pie chart" - shadow_offset = 10 - - show_data_labels = False - "If true, display the actual field values in the data labels" - show_actual_values = False - "If true, display the percentage value of each pie wedge in the data labels" - show_percent = True - - "If true, display the labels in the key" - show_key_data_labels = True - "If true, display the actual value of the field in the key" - show_key_actual_values = True - "If true, display the percentage value of the wedges in the key" - show_key_percent = False - - "If true, explode the pie (put space between the wedges)" - expanded = False - "If true, expand the largest pie wedge" - expand_greatest = False - "The amount of space between expanded wedges" - expand_gap = 10 - - show_x_labels = False - show_y_labels = False - - "The font size of the data point labels" - datapoint_font_size = 12 - - stylesheet_names = Graph.stylesheet_names + ['pie.css'] - - def add_data(self, data_descriptor): - """ - Add a data set to the graph - - >>> graph.add_data({data:[1,2,3,4]}) # doctest: +SKIP - - Note that a 'title' key is ignored. - - Multiple calls to add_data will sum the elements, and the pie will - display the aggregated data. e.g. - - >>> graph.add_data({data:[1,2,3,4]}) # doctest: +SKIP - >>> graph.add_data({data:[2,3,5,7]}) # doctest: +SKIP - - is the same as: - - >>> graph.add_data({data:[3,5,8,11]}) # doctest: +SKIP - - If data is added of with differing lengths, the corresponding - values will be assumed to be zero. - - >>> graph.add_data({data:[1,2,3,4]}) # doctest: +SKIP - >>> graph.add_data({data:[5,7]}) # doctest: +SKIP - - is the same as: - - >>> graph.add_data({data:[5,7]}) # doctest: +SKIP - >>> graph.add_data({data:[1,2,3,4]}) # doctest: +SKIP - - and - - >>> graph.add_data({data:[6,9,3,4]}) # doctest: +SKIP - """ - pairs = itertools.izip_longest(self.data, data_descriptor['data']) - self.data = list(itertools.starmap(robust_add, pairs)) - - def add_defs(self, defs): - "Add svg definitions" - etree.SubElement( - defs, - 'filter', - id='dropshadow', - width='1.2', - height='1.2', - ) - etree.SubElement( - defs, - 'feGaussianBlur', - stdDeviation='4', - result='blur', - ) - - def draw_graph(self): - "Here we don't need the graph (consider refactoring)" - pass - - def get_y_labels(self): - "Definitely consider refactoring" - return [''] - - def get_x_labels(self): - "Okay. I'll refactor after this" - return [''] - - def keys(self): - total = sum(self.data) - percent_scale = 100.0 / total - def key(field, value): - result = [field] - result.append('[%s]' % value) - if self.show_key_percent: - percent = str(round((v/total*100))) + '%' - result.append(percent) - return ' '.join(result) - return map(key, self.fields, self.data) - - def draw_data(self): - self.graph = etree.SubElement(self.root, 'g') - background = etree.SubElement(self.graph, 'g') - # midground is somewhere between the background and the foreground - midground = etree.SubElement(self.graph, 'g') - - is_expanded = (self.expanded or self.expand_greatest) - diameter = min(self.graph_width, self.graph_height) - # the following assumes int(True)==1 and int(False)==0 - diameter -= self.expand_gap * int(is_expanded) - diameter -= self.datapoint_font_size * int(self.show_data_labels) - diameter -= 10 * int(self.show_shadow) - radius = diameter / 2.0 - - xoff = (self.width - diameter) / 2 - yoff = (self.height - self.border_bottom - diameter) - yoff -= 10 * int(self.show_shadow) - transform = 'translate(%(xoff)s %(yoff)s)' % vars() - self.graph.set('transform', transform) - - wedge_text_pad = 5 - wedge_text_pad = 20 * int(self.show_percent) * int(self.show_data_labels) - - total = sum(self.data) - max_value = max(self.data) - - percent_scale = 100.0 / total - - prev_percent = 0 - rad_mult = 3.6 * RADIANS - for index, (field, value) in enumerate(zip(self.fields, self.data)): - percent = percent_scale * value - - radians = prev_percent * rad_mult - x_start = radius+(math.sin(radians) * radius) - y_start = radius-(math.cos(radians) * radius) - radians = (prev_percent+percent) * rad_mult - x_end = radius+(math.sin(radians) * radius) - y_end = radius-(math.cos(radians) * radius) - percent_greater_fifty = int(percent>=50) - path = ' '.join(( - "M%(radius)s,%(radius)s", - "L%(x_start)s,%(y_start)s", - "A%(radius)s,%(radius)s", - "0,", - "%(percent_greater_fifty)s,1,", - "%(x_end)s %(y_end)s Z")) - path = path % vars() - - wedge = etree.SubElement( - self.foreground, - 'path', - { - 'd': path, - 'class': 'fill%s' % (index+1), - } - ) - - translate = None - tx = 0 - ty = 0 - half_percent = prev_percent + percent / 2 - radians = half_percent * rad_mult - - if self.show_shadow: - shadow = etree.SubElement( - background, - 'path', - d=path, - filter='url(#dropshadow)', - style='fill: #ccc; stroke: none', - ) - clear = etree.SubElement( - midground, - 'path', - d=path, - # note, this probably only works when the background - # is also #fff - # consider getting the style from the stylesheet - style="fill:#fff; stroke:none;", - ) - - if self.expanded or (self.expand_greatest and value == max_value): - tx = (math.sin(radians) * self.expand_gap) - ty = -(math.cos(radians) * self.expand_gap) - translate = "translate(%(tx)s %(ty)s)" % vars() - wedge.set('transform', translate) - clear.set('transform', translate) - - if self.show_shadow: - shadow_tx = self.shadow_offset + tx - shadow_ty = self.shadow_offset + ty - translate = 'translate(%(shadow_tx)s %(shadow_ty)s)' % vars() - shadow.set('transform', translate) - - if self.show_data_labels and value != 0: - label = [] - if self.show_key_data_labels: - label.append(field) - if self.show_actual_values: - label.append('[%s]' % value) - if self.show_percent: - label.append('%d%%' % round(percent)) - label = ' '.join(label) - - msr = math.sin(radians) - mcr = math.cos(radians) - tx = radius + (msr * radius) - ty = radius -(mcr * radius) - - if self.expanded or (self.expand_greatest and value == max_value): - tx += (msr * self.expand_gap) - ty -= (mcr * self.expand_gap) - - label_node = etree.SubElement( - self.foreground, - 'text', - { - 'x':str(tx), - 'y':str(ty), - 'class':'dataPointLabel', - 'style':'stroke: #fff; stroke-width: 2;', - } - ) - label_node.text = label - - label_node = etree.SubElement( - self.foreground, - 'text', - { - 'x':str(tx), - 'y':str(ty), - 'class': 'dataPointLabel', - } - ) - label_node.text = label - - prev_percent += percent - - def round(self, val, to): - return round(val,to) + from svg.charts.pie import Pie + fields = ['Jan', 'Feb', 'Mar'] + + data_sales_02 = [12, 45, 21] + + graph = Pie(dict( + height = 500, + width = 300, + fields = fields)) + graph.add_data({'data': data_sales_02, 'title': 'Sales 2002'}) + print "Content-type" image/svg+xml\r\n\r\n' + print graph.burn() + + Description + =========== + This object aims to allow you to easily create high quality + SVG pie graphs. You can either use the default style sheet + or supply your own. Either way there are many options which can + be configured to give you control over how the graph is + generated - with or without a key, display percent on pie chart, + title, subtitle etc. + """ + + "if true, displays a drop shadow for the chart" + show_shadow = True + "Sets the offset of the shadow from the pie chart" + shadow_offset = 10 + + show_data_labels = False + "If true, display the actual field values in the data labels" + show_actual_values = False + "If true, display the percentage value of each pie wedge in the data labels" + show_percent = True + + "If true, display the labels in the key" + show_key_data_labels = True + "If true, display the actual value of the field in the key" + show_key_actual_values = True + "If true, display the percentage value of the wedges in the key" + show_key_percent = False + + "If true, explode the pie (put space between the wedges)" + expanded = False + "If true, expand the largest pie wedge" + expand_greatest = False + "The amount of space between expanded wedges" + expand_gap = 10 + + show_x_labels = False + show_y_labels = False + + "The font size of the data point labels" + datapoint_font_size = 12 + + stylesheet_names = Graph.stylesheet_names + ['pie.css'] + + def add_data(self, data_descriptor): + """ + Add a data set to the graph + + >>> graph.add_data({data:[1,2,3,4]}) # doctest: +SKIP + + Note that a 'title' key is ignored. + + Multiple calls to add_data will sum the elements, and the pie will + display the aggregated data. e.g. + + >>> graph.add_data({data:[1,2,3,4]}) # doctest: +SKIP + >>> graph.add_data({data:[2,3,5,7]}) # doctest: +SKIP + + is the same as: + + >>> graph.add_data({data:[3,5,8,11]}) # doctest: +SKIP + + If data is added of with differing lengths, the corresponding + values will be assumed to be zero. + + >>> graph.add_data({data:[1,2,3,4]}) # doctest: +SKIP + >>> graph.add_data({data:[5,7]}) # doctest: +SKIP + + is the same as: + + >>> graph.add_data({data:[5,7]}) # doctest: +SKIP + >>> graph.add_data({data:[1,2,3,4]}) # doctest: +SKIP + + and + + >>> graph.add_data({data:[6,9,3,4]}) # doctest: +SKIP + """ + pairs = itertools.izip_longest(self.data, data_descriptor['data']) + self.data = list(itertools.starmap(robust_add, pairs)) + + def add_defs(self, defs): + "Add svg definitions" + etree.SubElement( + defs, + 'filter', + id='dropshadow', + width='1.2', + height='1.2', + ) + etree.SubElement( + defs, + 'feGaussianBlur', + stdDeviation='4', + result='blur', + ) + + def draw_graph(self): + "Here we don't need the graph (consider refactoring)" + pass + + def get_y_labels(self): + "Definitely consider refactoring" + return [''] + + def get_x_labels(self): + "Okay. I'll refactor after this" + return [''] + + def keys(self): + total = sum(self.data) + percent_scale = 100.0 / total + def key(field, value): + result = [field] + result.append('[%s]' % value) + if self.show_key_percent: + percent = str(round((v/total*100))) + '%' + result.append(percent) + return ' '.join(result) + return map(key, self.fields, self.data) + + def draw_data(self): + self.graph = etree.SubElement(self.root, 'g') + background = etree.SubElement(self.graph, 'g') + # midground is somewhere between the background and the foreground + midground = etree.SubElement(self.graph, 'g') + + is_expanded = (self.expanded or self.expand_greatest) + diameter = min(self.graph_width, self.graph_height) + # the following assumes int(True)==1 and int(False)==0 + diameter -= self.expand_gap * int(is_expanded) + diameter -= self.datapoint_font_size * int(self.show_data_labels) + diameter -= 10 * int(self.show_shadow) + radius = diameter / 2.0 + + xoff = (self.width - diameter) / 2 + yoff = (self.height - self.border_bottom - diameter) + yoff -= 10 * int(self.show_shadow) + transform = 'translate(%(xoff)s %(yoff)s)' % vars() + self.graph.set('transform', transform) + + wedge_text_pad = 5 + wedge_text_pad = 20 * int(self.show_percent) * int(self.show_data_labels) + + total = sum(self.data) + max_value = max(self.data) + + percent_scale = 100.0 / total + + prev_percent = 0 + rad_mult = 3.6 * RADIANS + for index, (field, value) in enumerate(zip(self.fields, self.data)): + percent = percent_scale * value + + radians = prev_percent * rad_mult + x_start = radius+(math.sin(radians) * radius) + y_start = radius-(math.cos(radians) * radius) + radians = (prev_percent+percent) * rad_mult + x_end = radius+(math.sin(radians) * radius) + y_end = radius-(math.cos(radians) * radius) + percent_greater_fifty = int(percent>=50) + path = ' '.join(( + "M%(radius)s,%(radius)s", + "L%(x_start)s,%(y_start)s", + "A%(radius)s,%(radius)s", + "0,", + "%(percent_greater_fifty)s,1,", + "%(x_end)s %(y_end)s Z")) + path = path % vars() + + wedge = etree.SubElement( + self.foreground, + 'path', + { + 'd': path, + 'class': 'fill%s' % (index+1), + } + ) + + translate = None + tx = 0 + ty = 0 + half_percent = prev_percent + percent / 2 + radians = half_percent * rad_mult + + if self.show_shadow: + shadow = etree.SubElement( + background, + 'path', + d=path, + filter='url(#dropshadow)', + style='fill: #ccc; stroke: none', + ) + clear = etree.SubElement( + midground, + 'path', + d=path, + # note, this probably only works when the background + # is also #fff + # consider getting the style from the stylesheet + style="fill:#fff; stroke:none;", + ) + + if self.expanded or (self.expand_greatest and value == max_value): + tx = (math.sin(radians) * self.expand_gap) + ty = -(math.cos(radians) * self.expand_gap) + translate = "translate(%(tx)s %(ty)s)" % vars() + wedge.set('transform', translate) + clear.set('transform', translate) + + if self.show_shadow: + shadow_tx = self.shadow_offset + tx + shadow_ty = self.shadow_offset + ty + translate = 'translate(%(shadow_tx)s %(shadow_ty)s)' % vars() + shadow.set('transform', translate) + + if self.show_data_labels and value != 0: + label = [] + if self.show_key_data_labels: + label.append(field) + if self.show_actual_values: + label.append('[%s]' % value) + if self.show_percent: + label.append('%d%%' % round(percent)) + label = ' '.join(label) + + msr = math.sin(radians) + mcr = math.cos(radians) + tx = radius + (msr * radius) + ty = radius -(mcr * radius) + + if self.expanded or (self.expand_greatest and value == max_value): + tx += (msr * self.expand_gap) + ty -= (mcr * self.expand_gap) + + label_node = etree.SubElement( + self.foreground, + 'text', + { + 'x':str(tx), + 'y':str(ty), + 'class':'dataPointLabel', + 'style':'stroke: #fff; stroke-width: 2;', + } + ) + label_node.text = label + + label_node = etree.SubElement( + self.foreground, + 'text', + { + 'x':str(tx), + 'y':str(ty), + 'class': 'dataPointLabel', + } + ) + label_node.text = label + + prev_percent += percent + + def round(self, val, to): + return round(val,to) diff --git a/svg/charts/plot.py b/svg/charts/plot.py index 4a7966c..b3644ad 100644 --- a/svg/charts/plot.py +++ b/svg/charts/plot.py @@ -11,326 +11,326 @@ from svg.charts.graph import Graph from .util import float_range def get_pairs(i): - i = iter(i) - while True: yield i.next(), i.next() + i = iter(i) + while True: yield i.next(), i.next() # I'm not sure how this is more beautiful than ugly. if sys.version >= '3': - def apply(func): - return func() + def apply(func): + return func() class Plot(Graph): - """=== For creating SVG plots of scalar data + """=== For creating SVG plots of scalar data - = Synopsis + = Synopsis - require 'SVG/Graph/Plot' + require 'SVG/Graph/Plot' - # Data sets are x,y pairs - # Note that multiple data sets can differ in length, and that the - # data in the datasets needn't be in order; they will be ordered - # by the plot along the X-axis. - projection = [ - 6, 11, 0, 5, 18, 7, 1, 11, 13, 9, 1, 2, 19, 0, 3, 13, - 7, 9 - ] - actual = [ - 0, 18, 8, 15, 9, 4, 18, 14, 10, 2, 11, 6, 14, 12, - 15, 6, 4, 17, 2, 12 - ] + # Data sets are x,y pairs + # Note that multiple data sets can differ in length, and that the + # data in the datasets needn't be in order; they will be ordered + # by the plot along the X-axis. + projection = [ + 6, 11, 0, 5, 18, 7, 1, 11, 13, 9, 1, 2, 19, 0, 3, 13, + 7, 9 + ] + actual = [ + 0, 18, 8, 15, 9, 4, 18, 14, 10, 2, 11, 6, 14, 12, + 15, 6, 4, 17, 2, 12 + ] - graph = SVG::Graph::Plot.new({ - :height => 500, - :width => 300, - :key => true, - :scale_x_integers => true, - :scale_y_integerrs => true, - }) + graph = SVG::Graph::Plot.new({ + :height => 500, + :width => 300, + :key => true, + :scale_x_integers => true, + :scale_y_integerrs => true, + }) - graph.add_data({ - :data => projection - :title => 'Projected', - }) - - graph.add_data({ - :data => actual, - :title => 'Actual', - }) - - print graph.burn() - - = Description - - Produces a graph of scalar data. - - This object aims to allow you to easily create high quality - SVG[http://www.w3c.org/tr/svg] scalar plots. You can either use the - default style sheet or supply your own. Either way there are many options - which can be configured to give you control over how the graph is - generated - with or without a key, data elements at each point, title, - subtitle etc. - - = Examples - - http://www.germane-software/repositories/public/SVG/test/plot.rb - - = Notes - - The default stylesheet handles upto 10 data sets, if you - use more you must create your own stylesheet and add the - additional settings for the extra data sets. You will know - if you go over 10 data sets as they will have no style and - be in black. - - Unlike the other types of charts, data sets must contain x,y pairs: - - [1, 2] # A data set with 1 point: (1,2) - [1,2, 5,6] # A data set with 2 points: (1,2) and (5,6) - - = See also - - * SVG::Graph::Graph - * SVG::Graph::BarHorizontal - * SVG::Graph::Bar - * SVG::Graph::Line - * SVG::Graph::Pie - * SVG::Graph::TimeSeries - - == Author - - Sean E. Russell - - Copyright 2004 Sean E. Russell - This software is available under the Ruby license[LICENSE.txt]""" - - top_align = right_align = top_font = right_font = 1 - - - """Determines the scaling for the Y axis divisions. - - graph.scale_y_divisions = 0.5 - - would cause the graph to attempt to generate labels stepped by 0.5; EG: - 0, 0.5, 1, 1.5, 2, ...""" - scale_y_divisions = None - "Make the X axis labels integers" - scale_x_integers = False - "Make the Y axis labels integers" - scale_y_integers = False - "Fill the area under the line" - area_fill = False - """Show a small circle on the graph where the line - goes from one point to the next.""" - show_data_points = True - "Indicate whether the lines should be drawn between points" - draw_lines_between_points = True - "Set the minimum value of the X axis" - min_x_value = None - "Set the minimum value of the Y axis" - min_y_value = None - "Set the maximum value of the X axis" - max_x_value = None - "Set the maximum value of the Y axis" - max_y_value = None - - stacked = False - - stylesheet_names = Graph.stylesheet_names + ['plot.css'] - - @apply - def scale_x_divisions(): - doc = """Determines the scaling for the X axis divisions. - - graph.scale_x_divisions = 2 - - would cause the graph to attempt to generate labels stepped by 2; EG: - 0,2,4,6,8...""" - def fget(self): - return getattr(self, '_scale_x_divisions', None) - def fset(self, val): - self._scale_x_divisions = val - return property(**locals()) - - def validate_data(self, data): - if len(data['data']) % 2 != 0: - raise ValueError("Expecting x,y pairs for data points for %s." % self.__class__.__name__) - - def process_data(self, data): - pairs = list(get_pairs(data['data'])) - pairs.sort() - data['data'] = zip(*pairs) - - def calculate_left_margin(self): - super(Plot, self).calculate_left_margin() - label_left = len(str(self.get_x_labels()[0])) / 2 * self.font_size * 0.6 - self.border_left = max(label_left, self.border_left) - - def calculate_right_margin(self): - super(Plot, self).calculate_right_margin() - label_right = len(str(self.get_x_labels()[-1])) / 2 * self.font_size * 0.6 - self.border_right = max(label_right, self.border_right) - - def data_max(self, axis): - data_index = getattr(self, '%s_data_index' % axis) - max_value = max(chain(*map(lambda set: set['data'][data_index], self.data))) - # above is same as - #max_value = max(map(lambda set: max(set['data'][data_index]), self.data)) - spec_max = getattr(self, 'max_%s_value' % axis) - # Python 3 doesn't allow comparing None to int, so use -∞ - if spec_max is None: spec_max = float('-Inf') - max_value = max(max_value, spec_max) - return max_value - - def data_min(self, axis): - data_index = getattr(self, '%s_data_index' % axis) - min_value = min(chain(*map(lambda set: set['data'][data_index], self.data))) - spec_min = getattr(self, 'min_%s_value' % axis) - if spec_min is not None: - min_value = min(min_value, spec_min) - return min_value - - x_data_index = 0 - y_data_index = 1 - def data_range(self, axis): - side = {'x': 'right', 'y': 'top'}[axis] - - min_value = self.data_min(axis) - max_value = self.data_max(axis) - range = max_value - min_value - - side_pad = range / 20.0 or 10 - scale_range = (max_value + side_pad) - min_value - - scale_division = getattr(self, 'scale_%s_divisions' % axis) or (scale_range / 10.0) - - if getattr(self, 'scale_%s_integers' % axis): - scale_division = round(scale_division) or 1 - - return min_value, max_value, scale_division - - def x_range(self): return self.data_range('x') - def y_range(self): return self.data_range('y') - - def get_data_values(self, axis): - min_value, max_value, scale_division = self.data_range(axis) - return tuple(float_range(*self.data_range(axis))) - - def get_x_values(self): return self.get_data_values('x') - def get_y_values(self): return self.get_data_values('y') - - def get_x_labels(self): - return map(str, self.get_x_values()) - def get_y_labels(self): - return map(str, self.get_y_values()) - - def field_size(self, axis): - size = {'x': 'width', 'y': 'height'}[axis] - side = {'x': 'right', 'y': 'top'}[axis] - values = getattr(self, 'get_%s_values' % axis)() - max_d = self.data_max(axis) - dx = ( - float(max_d - values[-1]) / (values[-1] - values[-2]) - if len(values) > 1 else max_d - ) - graph_size = getattr(self, 'graph_%s' % size) - side_font = getattr(self, '%s_font' % side) - side_align = getattr(self, '%s_align' % side) - result = (float(graph_size) - self.font_size*2*side_font) / \ - (len(values) + dx - side_align) - return result - - def field_width(self): return self.field_size('x') - def field_height(self): return self.field_size('y') - - def draw_data(self): - self.load_transform_parameters() - for line, data in izip(count(1), self.data): - x_start, y_start = self.transform_output_coordinates( - (data['data'][self.x_data_index][0], - data['data'][self.y_data_index][0]) - ) - data_points = zip(*data['data']) - graph_points = self.get_graph_points(data_points) - lpath = self.get_lpath(graph_points) - if self.area_fill: - graph_height = self.graph_height - path = etree.SubElement(self.graph, 'path', { - 'd': 'M%(x_start)f %(graph_height)f %(lpath)s V%(graph_height)f Z' % vars(), - 'class': 'fill%(line)d' % vars()}) - if self.draw_lines_between_points: - path = etree.SubElement(self.graph, 'path', { - 'd': 'M%(x_start)f %(y_start)f %(lpath)s' % vars(), - 'class': 'line%(line)d' % vars()}) - self.draw_data_points(line, data_points, graph_points) - self._draw_constant_lines() - del self.__transform_parameters - - def add_constant_line(self, value, label = None, style = None): - self.constant_lines = getattr(self, 'constant_lines', []) - self.constant_lines.append((value, label, style)) - - def _draw_constant_lines(self): - if hasattr(self, 'constant_lines'): - map(self.__draw_constant_line, self.constant_lines) - - def __draw_constant_line(self, value_label_style): - "Draw a constant line on the y-axis with the label" - value, label, style = value_label_style - start = self.transform_output_coordinates((0, value))[1] - stop = self.graph_width - path = etree.SubElement(self.graph, 'path', { - 'd': 'M 0 %(start)s h%(stop)s' % vars(), - 'class': 'constantLine'}) - if style: - path.set('style', style) - text = etree.SubElement(self.graph, 'text', { - 'x': str(2), - 'y': str(start - 2), - 'class': 'constantLine'}) - text.text = label - - def load_transform_parameters(self): - "Cache the parameters necessary to transform x & y coordinates" - x_min, x_max, x_div = self.x_range() - y_min, y_max, y_div = self.y_range() - x_step = (float(self.graph_width) - self.font_size*2) / \ - (x_max - x_min) - y_step = (float(self.graph_height) - self.font_size*2) / \ - (y_max - y_min) - self.__transform_parameters = dict(vars()) - del self.__transform_parameters['self'] - - def get_graph_points(self, data_points): - return map(self.transform_output_coordinates, data_points) - - def get_lpath(self, points): - points = map(lambda p: "%f %f" % p, points) - return 'L' + ' '.join(points) - - def transform_output_coordinates(self, (x,y)): - x_min = self.__transform_parameters['x_min'] - x_step = self.__transform_parameters['x_step'] - y_min = self.__transform_parameters['y_min'] - y_step = self.__transform_parameters['y_step'] - #locals().update(self.__transform_parameters) - #vars().update(self.__transform_parameters) - x = (x - x_min) * x_step - y = self.graph_height - (y - y_min) * y_step - return x,y - - def draw_data_points(self, line, data_points, graph_points): - if not self.show_data_points \ - and not self.show_data_values: return - for ((dx,dy),(gx,gy)) in izip(data_points, graph_points): - if self.show_data_points: - etree.SubElement(self.graph, 'circle', { - 'cx': str(gx), - 'cy': str(gy), - 'r': '2.5', - 'class': 'dataPoint%(line)s' % vars()}) - if self.show_data_values: - self.add_popup(gx, gy, self.format(dx, dy)) - self.make_datapoint_text(gx, gy-6, dy) - - def format(self, x, y): - return '(%0.2f, %0.2f)' % (x,y) + graph.add_data({ + :data => projection + :title => 'Projected', + }) + + graph.add_data({ + :data => actual, + :title => 'Actual', + }) + + print graph.burn() + + = Description + + Produces a graph of scalar data. + + This object aims to allow you to easily create high quality + SVG[http://www.w3c.org/tr/svg] scalar plots. You can either use the + default style sheet or supply your own. Either way there are many options + which can be configured to give you control over how the graph is + generated - with or without a key, data elements at each point, title, + subtitle etc. + + = Examples + + http://www.germane-software/repositories/public/SVG/test/plot.rb + + = Notes + + The default stylesheet handles upto 10 data sets, if you + use more you must create your own stylesheet and add the + additional settings for the extra data sets. You will know + if you go over 10 data sets as they will have no style and + be in black. + + Unlike the other types of charts, data sets must contain x,y pairs: + + [1, 2] # A data set with 1 point: (1,2) + [1,2, 5,6] # A data set with 2 points: (1,2) and (5,6) + + = See also + + * SVG::Graph::Graph + * SVG::Graph::BarHorizontal + * SVG::Graph::Bar + * SVG::Graph::Line + * SVG::Graph::Pie + * SVG::Graph::TimeSeries + + == Author + + Sean E. Russell + + Copyright 2004 Sean E. Russell + This software is available under the Ruby license[LICENSE.txt]""" + + top_align = right_align = top_font = right_font = 1 + + + """Determines the scaling for the Y axis divisions. + + graph.scale_y_divisions = 0.5 + + would cause the graph to attempt to generate labels stepped by 0.5; EG: + 0, 0.5, 1, 1.5, 2, ...""" + scale_y_divisions = None + "Make the X axis labels integers" + scale_x_integers = False + "Make the Y axis labels integers" + scale_y_integers = False + "Fill the area under the line" + area_fill = False + """Show a small circle on the graph where the line + goes from one point to the next.""" + show_data_points = True + "Indicate whether the lines should be drawn between points" + draw_lines_between_points = True + "Set the minimum value of the X axis" + min_x_value = None + "Set the minimum value of the Y axis" + min_y_value = None + "Set the maximum value of the X axis" + max_x_value = None + "Set the maximum value of the Y axis" + max_y_value = None + + stacked = False + + stylesheet_names = Graph.stylesheet_names + ['plot.css'] + + @apply + def scale_x_divisions(): + doc = """Determines the scaling for the X axis divisions. + + graph.scale_x_divisions = 2 + + would cause the graph to attempt to generate labels stepped by 2; EG: + 0,2,4,6,8...""" + def fget(self): + return getattr(self, '_scale_x_divisions', None) + def fset(self, val): + self._scale_x_divisions = val + return property(**locals()) + + def validate_data(self, data): + if len(data['data']) % 2 != 0: + raise ValueError("Expecting x,y pairs for data points for %s." % self.__class__.__name__) + + def process_data(self, data): + pairs = list(get_pairs(data['data'])) + pairs.sort() + data['data'] = zip(*pairs) + + def calculate_left_margin(self): + super(Plot, self).calculate_left_margin() + label_left = len(str(self.get_x_labels()[0])) / 2 * self.font_size * 0.6 + self.border_left = max(label_left, self.border_left) + + def calculate_right_margin(self): + super(Plot, self).calculate_right_margin() + label_right = len(str(self.get_x_labels()[-1])) / 2 * self.font_size * 0.6 + self.border_right = max(label_right, self.border_right) + + def data_max(self, axis): + data_index = getattr(self, '%s_data_index' % axis) + max_value = max(chain(*map(lambda set: set['data'][data_index], self.data))) + # above is same as + #max_value = max(map(lambda set: max(set['data'][data_index]), self.data)) + spec_max = getattr(self, 'max_%s_value' % axis) + # Python 3 doesn't allow comparing None to int, so use -∞ + if spec_max is None: spec_max = float('-Inf') + max_value = max(max_value, spec_max) + return max_value + + def data_min(self, axis): + data_index = getattr(self, '%s_data_index' % axis) + min_value = min(chain(*map(lambda set: set['data'][data_index], self.data))) + spec_min = getattr(self, 'min_%s_value' % axis) + if spec_min is not None: + min_value = min(min_value, spec_min) + return min_value + + x_data_index = 0 + y_data_index = 1 + def data_range(self, axis): + side = {'x': 'right', 'y': 'top'}[axis] + + min_value = self.data_min(axis) + max_value = self.data_max(axis) + range = max_value - min_value + + side_pad = range / 20.0 or 10 + scale_range = (max_value + side_pad) - min_value + + scale_division = getattr(self, 'scale_%s_divisions' % axis) or (scale_range / 10.0) + + if getattr(self, 'scale_%s_integers' % axis): + scale_division = round(scale_division) or 1 + + return min_value, max_value, scale_division + + def x_range(self): return self.data_range('x') + def y_range(self): return self.data_range('y') + + def get_data_values(self, axis): + min_value, max_value, scale_division = self.data_range(axis) + return tuple(float_range(*self.data_range(axis))) + + def get_x_values(self): return self.get_data_values('x') + def get_y_values(self): return self.get_data_values('y') + + def get_x_labels(self): + return map(str, self.get_x_values()) + def get_y_labels(self): + return map(str, self.get_y_values()) + + def field_size(self, axis): + size = {'x': 'width', 'y': 'height'}[axis] + side = {'x': 'right', 'y': 'top'}[axis] + values = getattr(self, 'get_%s_values' % axis)() + max_d = self.data_max(axis) + dx = ( + float(max_d - values[-1]) / (values[-1] - values[-2]) + if len(values) > 1 else max_d + ) + graph_size = getattr(self, 'graph_%s' % size) + side_font = getattr(self, '%s_font' % side) + side_align = getattr(self, '%s_align' % side) + result = (float(graph_size) - self.font_size*2*side_font) / \ + (len(values) + dx - side_align) + return result + + def field_width(self): return self.field_size('x') + def field_height(self): return self.field_size('y') + + def draw_data(self): + self.load_transform_parameters() + for line, data in izip(count(1), self.data): + x_start, y_start = self.transform_output_coordinates( + (data['data'][self.x_data_index][0], + data['data'][self.y_data_index][0]) + ) + data_points = zip(*data['data']) + graph_points = self.get_graph_points(data_points) + lpath = self.get_lpath(graph_points) + if self.area_fill: + graph_height = self.graph_height + path = etree.SubElement(self.graph, 'path', { + 'd': 'M%(x_start)f %(graph_height)f %(lpath)s V%(graph_height)f Z' % vars(), + 'class': 'fill%(line)d' % vars()}) + if self.draw_lines_between_points: + path = etree.SubElement(self.graph, 'path', { + 'd': 'M%(x_start)f %(y_start)f %(lpath)s' % vars(), + 'class': 'line%(line)d' % vars()}) + self.draw_data_points(line, data_points, graph_points) + self._draw_constant_lines() + del self.__transform_parameters + + def add_constant_line(self, value, label = None, style = None): + self.constant_lines = getattr(self, 'constant_lines', []) + self.constant_lines.append((value, label, style)) + + def _draw_constant_lines(self): + if hasattr(self, 'constant_lines'): + map(self.__draw_constant_line, self.constant_lines) + + def __draw_constant_line(self, value_label_style): + "Draw a constant line on the y-axis with the label" + value, label, style = value_label_style + start = self.transform_output_coordinates((0, value))[1] + stop = self.graph_width + path = etree.SubElement(self.graph, 'path', { + 'd': 'M 0 %(start)s h%(stop)s' % vars(), + 'class': 'constantLine'}) + if style: + path.set('style', style) + text = etree.SubElement(self.graph, 'text', { + 'x': str(2), + 'y': str(start - 2), + 'class': 'constantLine'}) + text.text = label + + def load_transform_parameters(self): + "Cache the parameters necessary to transform x & y coordinates" + x_min, x_max, x_div = self.x_range() + y_min, y_max, y_div = self.y_range() + x_step = (float(self.graph_width) - self.font_size*2) / \ + (x_max - x_min) + y_step = (float(self.graph_height) - self.font_size*2) / \ + (y_max - y_min) + self.__transform_parameters = dict(vars()) + del self.__transform_parameters['self'] + + def get_graph_points(self, data_points): + return map(self.transform_output_coordinates, data_points) + + def get_lpath(self, points): + points = map(lambda p: "%f %f" % p, points) + return 'L' + ' '.join(points) + + def transform_output_coordinates(self, (x,y)): + x_min = self.__transform_parameters['x_min'] + x_step = self.__transform_parameters['x_step'] + y_min = self.__transform_parameters['y_min'] + y_step = self.__transform_parameters['y_step'] + #locals().update(self.__transform_parameters) + #vars().update(self.__transform_parameters) + x = (x - x_min) * x_step + y = self.graph_height - (y - y_min) * y_step + return x,y + + def draw_data_points(self, line, data_points, graph_points): + if not self.show_data_points \ + and not self.show_data_values: return + for ((dx,dy),(gx,gy)) in izip(data_points, graph_points): + if self.show_data_points: + etree.SubElement(self.graph, 'circle', { + 'cx': str(gx), + 'cy': str(gy), + 'r': '2.5', + 'class': 'dataPoint%(line)s' % vars()}) + if self.show_data_values: + self.add_popup(gx, gy, self.format(dx, dy)) + self.make_datapoint_text(gx, gy-6, dy) + + def format(self, x, y): + return '(%0.2f, %0.2f)' % (x,y) diff --git a/svg/charts/schedule.py b/svg/charts/schedule.py index 198ec29..9c4bab1 100644 --- a/svg/charts/schedule.py +++ b/svg/charts/schedule.py @@ -11,299 +11,299 @@ from util import grouper, date_range, divide_timedelta_float, TimeScale __all__ = ('Schedule') class Schedule(Graph): - """ - # === For creating SVG plots of scalar temporal data - - = Synopsis - - require 'SVG/Graph/Schedule' - - # Data sets are label, start, end tripples. - data1 = [ - "Housesitting", "6/17/04", "6/19/04", - "Summer Session", "6/15/04", "8/15/04", - ] - - graph = SVG::Graph::Schedule.new( { - :width => 640, - :height => 480, - :graph_title => title, - :show_graph_title => true, - :no_css => true, - :scale_x_integers => true, - :scale_y_integers => true, - :min_x_value => 0, - :min_y_value => 0, - :show_data_labels => true, - :show_x_guidelines => true, - :show_x_title => true, - :x_title => "Time", - :stagger_x_labels => true, - :stagger_y_labels => true, - :x_label_format => "%m/%d/%y", - }) - - graph.add_data({ - :data => data1, - :title => 'Data', - }) - - print graph.burn() - - = Description - - Produces a graph of temporal scalar data. - - = Examples - - http://www.germane-software/repositories/public/SVG/test/schedule.rb - - = Notes - - The default stylesheet handles upto 10 data sets, if you - use more you must create your own stylesheet and add the - additional settings for the extra data sets. You will know - if you go over 10 data sets as they will have no style and - be in black. - - Note that multiple data sets within the same chart can differ in - length, and that the data in the datasets needn't be in order; - they will be ordered by the plot along the X-axis. - - The dates must be parseable by ParseDate, but otherwise can be - any order of magnitude (seconds within the hour, or years) - - = See also - - * SVG::Graph::Graph - * SVG::Graph::BarHorizontal - * SVG::Graph::Bar - * SVG::Graph::Line - * SVG::Graph::Pie - * SVG::Graph::Plot - * SVG::Graph::TimeSeries - - == Author - - Sean E. Russell - - Copyright 2004 Sean E. Russell - This software is available under the Ruby license[LICENSE.txt] - - """ - - "The format string to be used to format the X axis labels" - x_label_format = '%Y-%m-%d %H:%M:%S' - - """ - Use this to set the spacing between dates on the axis. The value - must be of the form - "\d+ ?((year|month|week|day|hour|minute|second)s?)?" - - e.g. - - graph.timescale_divisions = '2 weeks' - graph.timescale_divisions = '1 month' - graph.timescale_divisions = '3600 seconds' # easier would be '1 hour' - """ - timescale_divisions = None - - "The formatting used for the popups. See x_label_format" - popup_format = '%Y-%m-%d %H:%M:%S' + """ + # === For creating SVG plots of scalar temporal data + + = Synopsis + + require 'SVG/Graph/Schedule' + + # Data sets are label, start, end tripples. + data1 = [ + "Housesitting", "6/17/04", "6/19/04", + "Summer Session", "6/15/04", "8/15/04", + ] + + graph = SVG::Graph::Schedule.new( { + :width => 640, + :height => 480, + :graph_title => title, + :show_graph_title => true, + :no_css => true, + :scale_x_integers => true, + :scale_y_integers => true, + :min_x_value => 0, + :min_y_value => 0, + :show_data_labels => true, + :show_x_guidelines => true, + :show_x_title => true, + :x_title => "Time", + :stagger_x_labels => true, + :stagger_y_labels => true, + :x_label_format => "%m/%d/%y", + }) + + graph.add_data({ + :data => data1, + :title => 'Data', + }) + + print graph.burn() + + = Description + + Produces a graph of temporal scalar data. + + = Examples + + http://www.germane-software/repositories/public/SVG/test/schedule.rb + + = Notes + + The default stylesheet handles upto 10 data sets, if you + use more you must create your own stylesheet and add the + additional settings for the extra data sets. You will know + if you go over 10 data sets as they will have no style and + be in black. + + Note that multiple data sets within the same chart can differ in + length, and that the data in the datasets needn't be in order; + they will be ordered by the plot along the X-axis. + + The dates must be parseable by ParseDate, but otherwise can be + any order of magnitude (seconds within the hour, or years) + + = See also + + * SVG::Graph::Graph + * SVG::Graph::BarHorizontal + * SVG::Graph::Bar + * SVG::Graph::Line + * SVG::Graph::Pie + * SVG::Graph::Plot + * SVG::Graph::TimeSeries + + == Author + + Sean E. Russell + + Copyright 2004 Sean E. Russell + This software is available under the Ruby license[LICENSE.txt] + + """ + + "The format string to be used to format the X axis labels" + x_label_format = '%Y-%m-%d %H:%M:%S' + + """ + Use this to set the spacing between dates on the axis. The value + must be of the form + "\d+ ?((year|month|week|day|hour|minute|second)s?)?" + + e.g. + + graph.timescale_divisions = '2 weeks' + graph.timescale_divisions = '1 month' + graph.timescale_divisions = '3600 seconds' # easier would be '1 hour' + """ + timescale_divisions = None + + "The formatting used for the popups. See x_label_format" + popup_format = '%Y-%m-%d %H:%M:%S' - _min_x_value = None - scale_x_divisions = False - scale_x_integers = False - bar_gap = True + _min_x_value = None + scale_x_divisions = False + scale_x_integers = False + bar_gap = True - stylesheet_names = Graph.stylesheet_names + ['bar.css'] + stylesheet_names = Graph.stylesheet_names + ['bar.css'] - def add_data(self, data): - """ - Add data to the plot. - - # A data set with 1 point: Lunch from 12:30 to 14:00 - d1 = [ "Lunch", "12:30", "14:00" ] - # A data set with 2 points: "Cats" runs from 5/11/03 to 7/15/04, and - # "Henry V" runs from 6/12/03 to 8/20/03 - d2 = [ "Cats", "5/11/03", "7/15/04", - "Henry V", "6/12/03", "8/20/03" ] - - graph.add_data( - :data => d1, - :title => 'Meetings' - ) - graph.add_data( - :data => d2, - :title => 'Plays' - ) - - Note that the data must be in time,value pairs, and that the date format - may be any date that is parseable by ParseDate. - Also note that, in this example, we're mixing scales; the data from d1 - will probably not be discernable if both data sets are plotted on the same - graph, since d1 is too granular. - """ - # The ruby version does something different here, throwing out - # any previously added data. - super(Schedule, self).add_data(data) + def add_data(self, data): + """ + Add data to the plot. + + # A data set with 1 point: Lunch from 12:30 to 14:00 + d1 = [ "Lunch", "12:30", "14:00" ] + # A data set with 2 points: "Cats" runs from 5/11/03 to 7/15/04, and + # "Henry V" runs from 6/12/03 to 8/20/03 + d2 = [ "Cats", "5/11/03", "7/15/04", + "Henry V", "6/12/03", "8/20/03" ] + + graph.add_data( + :data => d1, + :title => 'Meetings' + ) + graph.add_data( + :data => d2, + :title => 'Plays' + ) + + Note that the data must be in time,value pairs, and that the date format + may be any date that is parseable by ParseDate. + Also note that, in this example, we're mixing scales; the data from d1 + will probably not be discernable if both data sets are plotted on the same + graph, since d1 is too granular. + """ + # The ruby version does something different here, throwing out + # any previously added data. + super(Schedule, self).add_data(data) - # copied from Bar - # TODO, refactor this into a common base class (or mix-in) - def get_bar_gap(self, field_size): - bar_gap = 10 # default gap - if field_size < 10: - # adjust for narrow fields - bar_gap = field_size / 2 - # the following zero's out the gap if bar_gap is False - bar_gap = int(self.bar_gap) * bar_gap - return bar_gap + # copied from Bar + # TODO, refactor this into a common base class (or mix-in) + def get_bar_gap(self, field_size): + bar_gap = 10 # default gap + if field_size < 10: + # adjust for narrow fields + bar_gap = field_size / 2 + # the following zero's out the gap if bar_gap is False + bar_gap = int(self.bar_gap) * bar_gap + return bar_gap - def validate_data(self, conf): - super(Schedule, self).validate_data(conf) - msg = "Data supplied must be (title, from, to) tripples!" - assert len(conf['data']) % 3 == 0, msg + def validate_data(self, conf): + super(Schedule, self).validate_data(conf) + msg = "Data supplied must be (title, from, to) tripples!" + assert len(conf['data']) % 3 == 0, msg - def process_data(self, conf): - super(Schedule, self).process_data(conf) - data = conf['data'] - triples = grouper(3, data) - - labels, begin_dates, end_dates = zip(*triples) - - begin_dates = map(self.parse_date, begin_dates) - end_dates = map(self.parse_date, end_dates) + def process_data(self, conf): + super(Schedule, self).process_data(conf) + data = conf['data'] + triples = grouper(3, data) + + labels, begin_dates, end_dates = zip(*triples) + + begin_dates = map(self.parse_date, begin_dates) + end_dates = map(self.parse_date, end_dates) - # reconstruct the triples in a new order - reordered_triples = zip(begin_dates, end_dates, labels) - - # because of the reordering, this will sort by begin_date - # then end_date, then label. - reordered_triples.sort() - - conf['data'] = reordered_triples + # reconstruct the triples in a new order + reordered_triples = zip(begin_dates, end_dates, labels) + + # because of the reordering, this will sort by begin_date + # then end_date, then label. + reordered_triples.sort() + + conf['data'] = reordered_triples - def parse_date(self, date_string): - return parse(date_string) - - def set_min_x_value(self, value): - if isinstance(value, basestring): - value = self.parse_date(value) - self._min_x_value = value + def parse_date(self, date_string): + return parse(date_string) + + def set_min_x_value(self, value): + if isinstance(value, basestring): + value = self.parse_date(value) + self._min_x_value = value - def get_min_x_value(self): - return self._min_x_value - - min_x_value = property(get_min_x_value, set_min_x_value) - - def format(self, x, y): - return x.strftime(self.popup_format) - - def get_x_labels(self): - format = lambda x: x.strftime(self.x_label_format) - return map(format, self.get_x_values()) - - def y_label_offset(self, height): - return height / -2.0 - - def get_y_labels(self): - # ruby version uses the last data supplied - last = -1 - data = self.data[last]['data'] - begin_dates, start_dates, labels = zip(*data) - return labels - - def draw_data(self): - bar_gap = self.get_bar_gap(self.get_field_height()) - - subbar_height = self.get_field_height() - bar_gap - - y_mod = (subbar_height / 2) + (self.font_size / 2) - x_min,x_max,div = self._x_range() - x_range = x_max - x_min - width = (float(self.graph_width) - self.font_size*2) - # time_scale - #scale /= x_range - scale = TimeScale(width, x_range) - - # ruby version uses the last data supplied - last = -1 - data = self.data[last]['data'] - - for index, (x_start, x_end, label) in enumerate(data): - count = index + 1 # index is 0-based, count is 1-based - y = self.graph_height - (self.get_field_height()*count) - bar_width = scale*(x_end-x_start) - bar_start = scale*(x_start-x_min) - - etree.SubElement(self.graph, 'rect', { - 'x': str(bar_start), - 'y': str(y), - 'width': str(bar_width), - 'height': str(subbar_height), - 'class': 'fill%s' % (count+1), - }) + def get_min_x_value(self): + return self._min_x_value + + min_x_value = property(get_min_x_value, set_min_x_value) + + def format(self, x, y): + return x.strftime(self.popup_format) + + def get_x_labels(self): + format = lambda x: x.strftime(self.x_label_format) + return map(format, self.get_x_values()) + + def y_label_offset(self, height): + return height / -2.0 + + def get_y_labels(self): + # ruby version uses the last data supplied + last = -1 + data = self.data[last]['data'] + begin_dates, start_dates, labels = zip(*data) + return labels + + def draw_data(self): + bar_gap = self.get_bar_gap(self.get_field_height()) + + subbar_height = self.get_field_height() - bar_gap + + y_mod = (subbar_height / 2) + (self.font_size / 2) + x_min,x_max,div = self._x_range() + x_range = x_max - x_min + width = (float(self.graph_width) - self.font_size*2) + # time_scale + #scale /= x_range + scale = TimeScale(width, x_range) + + # ruby version uses the last data supplied + last = -1 + data = self.data[last]['data'] + + for index, (x_start, x_end, label) in enumerate(data): + count = index + 1 # index is 0-based, count is 1-based + y = self.graph_height - (self.get_field_height()*count) + bar_width = scale*(x_end-x_start) + bar_start = scale*(x_start-x_min) + + etree.SubElement(self.graph, 'rect', { + 'x': str(bar_start), + 'y': str(y), + 'width': str(bar_width), + 'height': str(subbar_height), + 'class': 'fill%s' % (count+1), + }) - - def _x_range(self): - # ruby version uses teh last data supplied - last = -1 - data = self.data[last]['data'] - - start_dates, end_dates, labels = zip(*data) - all_dates = start_dates + end_dates - max_value = max(all_dates) - if not self.min_x_value is None: - all_dates.append(self.min_x_value) - min_value = min(all_dates) - range = max_value - min_value - right_pad = divide_timedelta_float(range, 20.0) or relativedelta(days=10) - scale_range = (max_value + right_pad) - min_value - - #scale_division = self.scale_x_divisions or (scale_range / 10.0) - # todo, remove timescale_x_divisions and use scale_x_divisions only - # but as a time delta - scale_division = divide_timedelta_float(scale_range, 10.0) - - # this doesn't make sense, because x is a timescale - #if self.scale_x_integers: - # scale_division = min(round(scale_division), 1) - - return min_value, max_value, scale_division - - def get_x_values(self): - x_min, x_max, scale_division = self._x_range() - if self.timescale_divisions: - pattern = re.compile('(\d+) ?(\w+)') - m = pattern.match(self.timescale_divisions) - if not m: - raise ValueError, "Invalid timescale_divisions: %s" % self.timescale_divisions - - magnitude = int(m.group(1)) - units = m.group(2) - - parameter = self.lookup_relativedelta_parameter(units) - - delta = relativedelta(**{parameter:magnitude}) - - scale_division = delta + + def _x_range(self): + # ruby version uses teh last data supplied + last = -1 + data = self.data[last]['data'] + + start_dates, end_dates, labels = zip(*data) + all_dates = start_dates + end_dates + max_value = max(all_dates) + if not self.min_x_value is None: + all_dates.append(self.min_x_value) + min_value = min(all_dates) + range = max_value - min_value + right_pad = divide_timedelta_float(range, 20.0) or relativedelta(days=10) + scale_range = (max_value + right_pad) - min_value + + #scale_division = self.scale_x_divisions or (scale_range / 10.0) + # todo, remove timescale_x_divisions and use scale_x_divisions only + # but as a time delta + scale_division = divide_timedelta_float(scale_range, 10.0) + + # this doesn't make sense, because x is a timescale + #if self.scale_x_integers: + # scale_division = min(round(scale_division), 1) + + return min_value, max_value, scale_division + + def get_x_values(self): + x_min, x_max, scale_division = self._x_range() + if self.timescale_divisions: + pattern = re.compile('(\d+) ?(\w+)') + m = pattern.match(self.timescale_divisions) + if not m: + raise ValueError, "Invalid timescale_divisions: %s" % self.timescale_divisions + + magnitude = int(m.group(1)) + units = m.group(2) + + parameter = self.lookup_relativedelta_parameter(units) + + delta = relativedelta(**{parameter:magnitude}) + + scale_division = delta - return date_range(x_min, x_max, scale_division) + return date_range(x_min, x_max, scale_division) - def lookup_relativedelta_parameter(self, unit_string): - from util import reverse_mapping, flatten_mapping - unit_string = unit_string.lower() - mapping = dict( - years = ('years', 'year', 'yrs', 'yr'), - months = ('months', 'month', 'mo'), - weeks = ('weeks', 'week', 'wks' ,'wk'), - days = ('days', 'day'), - hours = ('hours', 'hour', 'hr', 'hrs', 'h'), - minutes = ('minutes', 'minute', 'min', 'mins', 'm'), - seconds = ('seconds', 'second', 'sec', 'secs', 's'), - ) - mapping = reverse_mapping(mapping) - mapping = flatten_mapping(mapping) - if not unit_string in mapping: - raise ValueError, "%s doesn't match any supported time/date unit" - return mapping[unit_string] \ No newline at end of file + def lookup_relativedelta_parameter(self, unit_string): + from util import reverse_mapping, flatten_mapping + unit_string = unit_string.lower() + mapping = dict( + years = ('years', 'year', 'yrs', 'yr'), + months = ('months', 'month', 'mo'), + weeks = ('weeks', 'week', 'wks' ,'wk'), + days = ('days', 'day'), + hours = ('hours', 'hour', 'hr', 'hrs', 'h'), + minutes = ('minutes', 'minute', 'min', 'mins', 'm'), + seconds = ('seconds', 'second', 'sec', 'secs', 's'), + ) + mapping = reverse_mapping(mapping) + mapping = flatten_mapping(mapping) + if not unit_string in mapping: + raise ValueError, "%s doesn't match any supported time/date unit" + return mapping[unit_string] \ No newline at end of file diff --git a/svg/charts/time_series.py b/svg/charts/time_series.py index 0afeeac..1a23c07 100644 --- a/svg/charts/time_series.py +++ b/svg/charts/time_series.py @@ -11,173 +11,173 @@ fromtimestamp = datetime.datetime.fromtimestamp from .util import float_range class Plot(svg.charts.plot.Plot): - """=== For creating SVG plots of scalar temporal data - - = Synopsis - - import SVG.TimeSeries - - # Data sets are x,y pairs - data1 = ["6/17/72", 11, "1/11/72", 7, "4/13/04 17:31", 11, - "9/11/01", 9, "9/1/85", 2, "9/1/88", 1, "1/15/95", 13] - data2 = ["8/1/73", 18, "3/1/77", 15, "10/1/98", 4, - "5/1/02", 14, "3/1/95", 6, "8/1/91", 12, "12/1/87", 6, - "5/1/84", 17, "10/1/80", 12] - - graph = SVG::Graph::TimeSeries.new({ - :width => 640, - :height => 480, - :graph_title => title, - :show_graph_title => true, - :no_css => true, - :key => true, - :scale_x_integers => true, - :scale_y_integers => true, - :min_x_value => 0, - :min_y_value => 0, - :show_data_labels => true, - :show_x_guidelines => true, - :show_x_title => true, - :x_title => "Time", - :show_y_title => true, - :y_title => "Ice Cream Cones", - :y_title_text_direction => :bt, - :stagger_x_labels => true, - :x_label_format => "%m/%d/%y", - }) - - graph.add_data({ - :data => projection - :title => 'Projected', - }) - - graph.add_data({ - :data => actual, - :title => 'Actual', - }) - - print graph.burn() - - = Description - - Produces a graph of temporal scalar data. - - = Examples - - http://www.germane-software/repositories/public/SVG/test/timeseries.rb - - = Notes - - The default stylesheet handles upto 10 data sets, if you - use more you must create your own stylesheet and add the - additional settings for the extra data sets. You will know - if you go over 10 data sets as they will have no style and - be in black. - - Unlike the other types of charts, data sets must contain x,y pairs: - - ["12:30", 2] # A data set with 1 point: ("12:30",2) - ["01:00",2, "14:20",6] # A data set with 2 points: ("01:00",2) and - # ("14:20",6) - - Note that multiple data sets within the same chart can differ in length, - and that the data in the datasets needn't be in order; they will be ordered - by the plot along the X-axis. - - The dates must be parseable by ParseDate, but otherwise can be - any order of magnitude (seconds within the hour, or years) - - = See also - - * SVG::Graph::Graph - * SVG::Graph::BarHorizontal - * SVG::Graph::Bar - * SVG::Graph::Line - * SVG::Graph::Pie - * SVG::Graph::Plot - - == Author - - Sean E. Russell - - Copyright 2004 Sean E. Russell - This software is available under the Ruby license[LICENSE.txt] + """=== For creating SVG plots of scalar temporal data + + = Synopsis + + import SVG.TimeSeries + + # Data sets are x,y pairs + data1 = ["6/17/72", 11, "1/11/72", 7, "4/13/04 17:31", 11, + "9/11/01", 9, "9/1/85", 2, "9/1/88", 1, "1/15/95", 13] + data2 = ["8/1/73", 18, "3/1/77", 15, "10/1/98", 4, + "5/1/02", 14, "3/1/95", 6, "8/1/91", 12, "12/1/87", 6, + "5/1/84", 17, "10/1/80", 12] + + graph = SVG::Graph::TimeSeries.new({ + :width => 640, + :height => 480, + :graph_title => title, + :show_graph_title => true, + :no_css => true, + :key => true, + :scale_x_integers => true, + :scale_y_integers => true, + :min_x_value => 0, + :min_y_value => 0, + :show_data_labels => true, + :show_x_guidelines => true, + :show_x_title => true, + :x_title => "Time", + :show_y_title => true, + :y_title => "Ice Cream Cones", + :y_title_text_direction => :bt, + :stagger_x_labels => true, + :x_label_format => "%m/%d/%y", + }) + + graph.add_data({ + :data => projection + :title => 'Projected', + }) + + graph.add_data({ + :data => actual, + :title => 'Actual', + }) + + print graph.burn() + + = Description + + Produces a graph of temporal scalar data. + + = Examples + + http://www.germane-software/repositories/public/SVG/test/timeseries.rb + + = Notes + + The default stylesheet handles upto 10 data sets, if you + use more you must create your own stylesheet and add the + additional settings for the extra data sets. You will know + if you go over 10 data sets as they will have no style and + be in black. + + Unlike the other types of charts, data sets must contain x,y pairs: + + ["12:30", 2] # A data set with 1 point: ("12:30",2) + ["01:00",2, "14:20",6] # A data set with 2 points: ("01:00",2) and + # ("14:20",6) + + Note that multiple data sets within the same chart can differ in length, + and that the data in the datasets needn't be in order; they will be ordered + by the plot along the X-axis. + + The dates must be parseable by ParseDate, but otherwise can be + any order of magnitude (seconds within the hour, or years) + + = See also + + * SVG::Graph::Graph + * SVG::Graph::BarHorizontal + * SVG::Graph::Bar + * SVG::Graph::Line + * SVG::Graph::Pie + * SVG::Graph::Plot + + == Author + + Sean E. Russell + + Copyright 2004 Sean E. Russell + This software is available under the Ruby license[LICENSE.txt] """ - popup_format = x_label_format = '%Y-%m-%d %H:%M:%S' - __doc_popup_format_ = "The formatting usped for the popups. See x_label_format" - __doc_x_label_format_ = "The format string used to format the X axis labels. See strftime." - - timescale_divisions = None - __doc_timescale_divisions_ = """Use this to set the spacing between dates on the axis. The value - must be of the form - "\d+ ?(days|weeks|months|years|hours|minutes|seconds)?" + popup_format = x_label_format = '%Y-%m-%d %H:%M:%S' + __doc_popup_format_ = "The formatting usped for the popups. See x_label_format" + __doc_x_label_format_ = "The format string used to format the X axis labels. See strftime." + + timescale_divisions = None + __doc_timescale_divisions_ = """Use this to set the spacing between dates on the axis. The value + must be of the form + "\d+ ?(days|weeks|months|years|hours|minutes|seconds)?" - EG: + EG: - graph.timescale_divisions = "2 weeks" + graph.timescale_divisions = "2 weeks" - will cause the chart to try to divide the X axis up into segments of - two week periods.""" - - def add_data(self, data): - """Add data to the plot. - d1 = ["12:30", 2] # A data set with 1 point: ("12:30",2) - d2 = ["01:00",2, "14:20",6] # A data set with 2 points: ("01:00",2) and - # ("14:20",6) - graph.add_data( - :data => d1, - :title => 'One' - ) - graph.add_data( - :data => d2, - :title => 'Two' - ) - - Note that the data must be in time,value pairs, and that the date format - may be any date that is parseable by ParseDate.""" - super(Plot, self).add_data(data) - - def process_data(self, data): - super(Plot, self).process_data(data) - # the date should be in the first element, so parse it out - data['data'][0] = map(self.parse_date, data['data'][0]) + will cause the chart to try to divide the X axis up into segments of + two week periods.""" + + def add_data(self, data): + """Add data to the plot. + d1 = ["12:30", 2] # A data set with 1 point: ("12:30",2) + d2 = ["01:00",2, "14:20",6] # A data set with 2 points: ("01:00",2) and + # ("14:20",6) + graph.add_data( + :data => d1, + :title => 'One' + ) + graph.add_data( + :data => d2, + :title => 'Two' + ) + + Note that the data must be in time,value pairs, and that the date format + may be any date that is parseable by ParseDate.""" + super(Plot, self).add_data(data) + + def process_data(self, data): + super(Plot, self).process_data(data) + # the date should be in the first element, so parse it out + data['data'][0] = map(self.parse_date, data['data'][0]) - _min_x_value = svg.charts.plot.Plot.min_x_value - def get_min_x_value(self): - return self._min_x_value - def set_min_x_value(self, date): - self._min_x_value = self.parse_date(date) - min_x_value = property(get_min_x_value, set_min_x_value) - - def format(self, x, y): - return fromtimestamp(x).strftime(self.popup_format) - - def get_x_labels(self): - return map(lambda t: fromtimestamp(t).strftime(self.x_label_format), self.get_x_values()) + _min_x_value = svg.charts.plot.Plot.min_x_value + def get_min_x_value(self): + return self._min_x_value + def set_min_x_value(self, date): + self._min_x_value = self.parse_date(date) + min_x_value = property(get_min_x_value, set_min_x_value) + + def format(self, x, y): + return fromtimestamp(x).strftime(self.popup_format) + + def get_x_labels(self): + return map(lambda t: fromtimestamp(t).strftime(self.x_label_format), self.get_x_values()) - def get_x_values(self): - result = self.get_x_timescale_division_values() - if result: return result - return tuple(float_range(*self.x_range())) - - def get_x_timescale_division_values(self): - if not self.timescale_divisions: return - min, max, scale_division = self.x_range() - m = re.match('(?P\d+) ?(?Pdays|weeks|months|years|hours|minutes|seconds)?', self.timescale_divisions) - # copy amount and division_units into the local namespace - division_units = m.groupdict()['division_units'] or 'days' - amount = int(m.groupdict()['amount']) - if not amount: return - delta = relativedelta(**{division_units: amount}) - result = tuple(self.get_time_range(min, max, delta)) - return result - - def get_time_range(self, start, stop, delta): - start, stop = map(fromtimestamp, (start, stop)) - current = start - while current <= stop: - yield mktime(current.timetuple()) - current += delta - - def parse_date(self, date_string): - return mktime(parse(date_string).timetuple()) \ No newline at end of file + def get_x_values(self): + result = self.get_x_timescale_division_values() + if result: return result + return tuple(float_range(*self.x_range())) + + def get_x_timescale_division_values(self): + if not self.timescale_divisions: return + min, max, scale_division = self.x_range() + m = re.match('(?P\d+) ?(?Pdays|weeks|months|years|hours|minutes|seconds)?', self.timescale_divisions) + # copy amount and division_units into the local namespace + division_units = m.groupdict()['division_units'] or 'days' + amount = int(m.groupdict()['amount']) + if not amount: return + delta = relativedelta(**{division_units: amount}) + result = tuple(self.get_time_range(min, max, delta)) + return result + + def get_time_range(self, start, stop, delta): + start, stop = map(fromtimestamp, (start, stop)) + current = start + while current <= stop: + yield mktime(current.timetuple()) + current += delta + + def parse_date(self, date_string): + return mktime(parse(date_string).timetuple()) \ No newline at end of file diff --git a/svg/charts/util.py b/svg/charts/util.py index a79981b..da777d0 100644 --- a/svg/charts/util.py +++ b/svg/charts/util.py @@ -5,158 +5,158 @@ import datetime # from itertools recipes (python documentation) def grouper(n, iterable, padvalue=None): - """ - >>> tuple(grouper(3, 'abcdefg', 'x')) - (('a', 'b', 'c'), ('d', 'e', 'f'), ('g', 'x', 'x')) - """ - return itertools.izip(*[itertools.chain(iterable, itertools.repeat(padvalue, n-1))]*n) + """ + >>> tuple(grouper(3, 'abcdefg', 'x')) + (('a', 'b', 'c'), ('d', 'e', 'f'), ('g', 'x', 'x')) + """ + return itertools.izip(*[itertools.chain(iterable, itertools.repeat(padvalue, n-1))]*n) def reverse_mapping(mapping): - """ - For every key, value pair, return the mapping for the - equivalent value, key pair - >>> reverse_mapping({'a': 'b'}) == {'b': 'a'} - True - """ - keys, values = zip(*mapping.items()) - return dict(zip(values, keys)) + """ + For every key, value pair, return the mapping for the + equivalent value, key pair + >>> reverse_mapping({'a': 'b'}) == {'b': 'a'} + True + """ + keys, values = zip(*mapping.items()) + return dict(zip(values, keys)) def flatten_mapping(mapping): - """ - For every key that has an __iter__ method, assign the values - to a key for each. - >>> flatten_mapping({'ab': 3, ('c','d'): 4}) == {'ab': 3, 'c': 4, 'd': 4} - True - """ - return dict(flatten_items(mapping.items())) + """ + For every key that has an __iter__ method, assign the values + to a key for each. + >>> flatten_mapping({'ab': 3, ('c','d'): 4}) == {'ab': 3, 'c': 4, 'd': 4} + True + """ + return dict(flatten_items(mapping.items())) def flatten_items(items): - for keys, value in items: - if hasattr(keys, '__iter__'): - for key in keys: - yield (key, value) - else: - yield (keys, value) + for keys, value in items: + if hasattr(keys, '__iter__'): + for key in keys: + yield (key, value) + else: + yield (keys, value) def float_range(start=0, stop=None, step=1): - """ - Much like the built-in function range, but accepts floats - >>> tuple(float_range(0, 9, 1.5)) - (0.0, 1.5, 3.0, 4.5, 6.0, 7.5) - """ - start = float(start) - while start < stop: - yield start - start += step + """ + Much like the built-in function range, but accepts floats + >>> tuple(float_range(0, 9, 1.5)) + (0.0, 1.5, 3.0, 4.5, 6.0, 7.5) + """ + start = float(start) + while start < stop: + yield start + start += step def date_range(start=None, stop=None, step=None): - """ - Much like the built-in function range, but works with dates - >>> my_range = tuple(date_range(datetime.datetime(2005,12,21), datetime.datetime(2005,12,25))) - >>> datetime.datetime(2005,12,21) in my_range - True - >>> datetime.datetime(2005,12,22) in my_range - True - >>> datetime.datetime(2005,12,25) in my_range - False - """ - if step is None: step = datetime.timedelta(days=1) - if start is None: start = datetime.datetime.now() - while start < stop: - yield start - start += step + """ + Much like the built-in function range, but works with dates + >>> my_range = tuple(date_range(datetime.datetime(2005,12,21), datetime.datetime(2005,12,25))) + >>> datetime.datetime(2005,12,21) in my_range + True + >>> datetime.datetime(2005,12,22) in my_range + True + >>> datetime.datetime(2005,12,25) in my_range + False + """ + if step is None: step = datetime.timedelta(days=1) + if start is None: start = datetime.datetime.now() + while start < stop: + yield start + start += step # copied from jaraco.datetools def divide_timedelta_float(td, divisor): - """ - Meant to work around the limitation that Python datetime doesn't support - floats as divisors or multiplicands to datetime objects - >>> one_day = datetime.timedelta(days=1) - >>> half_day = datetime.timedelta(days=.5) - >>> divide_timedelta_float(one_day, 2.0) == half_day - True - >>> divide_timedelta_float(one_day, 2) == half_day - False - """ - # td is comprised of days, seconds, microseconds - dsm = [getattr(td, attr) for attr in ('days', 'seconds', 'microseconds')] - dsm = map(lambda elem: elem/divisor, dsm) - return datetime.timedelta(*dsm) + """ + Meant to work around the limitation that Python datetime doesn't support + floats as divisors or multiplicands to datetime objects + >>> one_day = datetime.timedelta(days=1) + >>> half_day = datetime.timedelta(days=.5) + >>> divide_timedelta_float(one_day, 2.0) == half_day + True + >>> divide_timedelta_float(one_day, 2) == half_day + False + """ + # td is comprised of days, seconds, microseconds + dsm = [getattr(td, attr) for attr in ('days', 'seconds', 'microseconds')] + dsm = map(lambda elem: elem/divisor, dsm) + return datetime.timedelta(*dsm) def get_timedelta_total_microseconds(td): - seconds = td.days*86400 + td.seconds - microseconds = td.microseconds + seconds*(10**6) - return microseconds + seconds = td.days*86400 + td.seconds + microseconds = td.microseconds + seconds*(10**6) + return microseconds def divide_timedelta(td1, td2): - """ - Get the ratio of two timedeltas - >>> one_day = datetime.timedelta(days=1) - >>> one_hour = datetime.timedelta(hours=1) - >>> divide_timedelta(one_hour, one_day) == 1/24.0 - True - """ - - td1_total = float(get_timedelta_total_microseconds(td1)) - td2_total = float(get_timedelta_total_microseconds(td2)) - return td1_total/td2_total + """ + Get the ratio of two timedeltas + >>> one_day = datetime.timedelta(days=1) + >>> one_hour = datetime.timedelta(hours=1) + >>> divide_timedelta(one_hour, one_day) == 1/24.0 + True + """ + + td1_total = float(get_timedelta_total_microseconds(td1)) + td2_total = float(get_timedelta_total_microseconds(td2)) + return td1_total/td2_total class TimeScale(object): - "Describes a scale factor based on time instead of a scalar" - def __init__(self, width, range): - self.width = width - self.range = range + "Describes a scale factor based on time instead of a scalar" + def __init__(self, width, range): + self.width = width + self.range = range - def __mul__(self, delta): - scale = divide_timedelta(delta, self.range) - return scale*self.width + def __mul__(self, delta): + scale = divide_timedelta(delta, self.range) + return scale*self.width # the following three functions were copied from jaraco.util.iter_ # todo, factor out caching capability class iterable_test(dict): - "Test objects for iterability, caching the result by type" - def __init__(self, ignore_classes=(basestring,)): - """ignore_classes must include basestring, because if a string - is iterable, so is a single character, and the routine runs - into an infinite recursion""" - assert basestring in ignore_classes, 'basestring must be in ignore_classes' - self.ignore_classes = ignore_classes - - def __getitem__(self, candidate): - return dict.get(self, type(candidate)) or self._test(candidate) - - def _test(self, candidate): - try: - if isinstance(candidate, self.ignore_classes): - raise TypeError - iter(candidate) - result = True - except TypeError: - result = False - self[type(candidate)] = result - return result + "Test objects for iterability, caching the result by type" + def __init__(self, ignore_classes=(basestring,)): + """ignore_classes must include basestring, because if a string + is iterable, so is a single character, and the routine runs + into an infinite recursion""" + assert basestring in ignore_classes, 'basestring must be in ignore_classes' + self.ignore_classes = ignore_classes + + def __getitem__(self, candidate): + return dict.get(self, type(candidate)) or self._test(candidate) + + def _test(self, candidate): + try: + if isinstance(candidate, self.ignore_classes): + raise TypeError + iter(candidate) + result = True + except TypeError: + result = False + self[type(candidate)] = result + return result def iflatten(subject, test=None): - if test is None: - test = iterable_test() - if not test[subject]: - yield subject - else: - for elem in subject: - for subelem in iflatten(elem, test): - yield subelem - + if test is None: + test = iterable_test() + if not test[subject]: + yield subject + else: + for elem in subject: + for subelem in iflatten(elem, test): + yield subelem + def flatten(subject, test=None): - """flatten an iterable with possible nested iterables. - Adapted from - http://mail.python.org/pipermail/python-list/2003-November/233971.html - >>> flatten(['a','b',['c','d',['e','f'],'g'],'h']) == ['a','b','c','d','e','f','g','h'] - True - - Note this will normally ignore string types as iterables. - >>> flatten(['ab', 'c']) - ['ab', 'c'] - """ - return list(iflatten(subject, test)) + """flatten an iterable with possible nested iterables. + Adapted from + http://mail.python.org/pipermail/python-list/2003-November/233971.html + >>> flatten(['a','b',['c','d',['e','f'],'g'],'h']) == ['a','b','c','d','e','f','g','h'] + True + + Note this will normally ignore string types as iterables. + >>> flatten(['ab', 'c']) + ['ab', 'c'] + """ + return list(iflatten(subject, test)) diff --git a/tests/samples.py b/tests/samples.py index 7bfc12a..3bf63a9 100644 --- a/tests/samples.py +++ b/tests/samples.py @@ -12,168 +12,168 @@ from svg.charts import schedule from svg.charts import line def sample_Plot(): - g = Plot({ - 'min_x_value': 0, - 'min_y_value': 0, - 'area_fill': True, - 'stagger_x_labels': True, - 'stagger_y_labels': True, - 'show_x_guidelines': True - }) - g.add_data({'data': [1, 25, 2, 30, 3, 45], 'title': 'series 1'}) - g.add_data({'data': [1,30, 2, 31, 3, 40], 'title': 'series 2'}) - g.add_data({'data': [.5,35, 1, 20, 3, 10.5], 'title': 'series 3'}) - return g + g = Plot({ + 'min_x_value': 0, + 'min_y_value': 0, + 'area_fill': True, + 'stagger_x_labels': True, + 'stagger_y_labels': True, + 'show_x_guidelines': True + }) + g.add_data({'data': [1, 25, 2, 30, 3, 45], 'title': 'series 1'}) + g.add_data({'data': [1,30, 2, 31, 3, 40], 'title': 'series 2'}) + g.add_data({'data': [.5,35, 1, 20, 3, 10.5], 'title': 'series 3'}) + return g def sample_TimeSeries(): - g = time_series.Plot({}) + g = time_series.Plot({}) - g.timescale_divisions = '4 hours' - g.stagger_x_labels = True - g.x_label_format = '%d-%b %H:%M' - #g.max_y_value = 200 + g.timescale_divisions = '4 hours' + g.stagger_x_labels = True + g.x_label_format = '%d-%b %H:%M' + #g.max_y_value = 200 - g.add_data({'data': ['2005-12-21T00:00:00', 20, '2005-12-22T00:00:00', 21], 'title': 'series 1'}) + g.add_data({'data': ['2005-12-21T00:00:00', 20, '2005-12-22T00:00:00', 21], 'title': 'series 1'}) - return g + return g def generate_samples(): - yield 'Plot', sample_Plot() - yield 'TimeSeries', sample_TimeSeries() - yield 'VerticalBar', SampleBar.vertical() - yield 'HorizontalBar', SampleBar.horizontal() - yield 'VerticalBarLarge', SampleBar.vertical_large() - yield 'Pie', sample_Pie() - yield 'Schedule', sample_Schedule() - yield 'Line', sample_Line() + yield 'Plot', sample_Plot() + yield 'TimeSeries', sample_TimeSeries() + yield 'VerticalBar', SampleBar.vertical() + yield 'HorizontalBar', SampleBar.horizontal() + yield 'VerticalBarLarge', SampleBar.vertical_large() + yield 'Pie', sample_Pie() + yield 'Schedule', sample_Schedule() + yield 'Line', sample_Line() class SampleBar: - fields = ['Internet', 'TV', 'Newspaper', 'Magazine', 'Radio'] + fields = ['Internet', 'TV', 'Newspaper', 'Magazine', 'Radio'] - @classmethod - def vertical(cls): + @classmethod + def vertical(cls): - g = bar.VerticalBar(cls.fields) + g = bar.VerticalBar(cls.fields) - g.stack = 'side' - g.scale_integers = True - g.width, g.height = 640,480 - g.graph_title = 'Question 7' - g.show_graph_title = True + g.stack = 'side' + g.scale_integers = True + g.width, g.height = 640,480 + g.graph_title = 'Question 7' + g.show_graph_title = True - g.add_data({'data': [-2, 3, 1, 3, 1], 'title': 'Female'}) - g.add_data({'data': [0, 2, 1, 5, 4], 'title': 'Male'}) + g.add_data({'data': [-2, 3, 1, 3, 1], 'title': 'Female'}) + g.add_data({'data': [0, 2, 1, 5, 4], 'title': 'Male'}) - return g + return g - @classmethod - def horizontal(cls): - g = bar.HorizontalBar(cls.fields) + @classmethod + def horizontal(cls): + g = bar.HorizontalBar(cls.fields) - g.stack = 'side' - g.scale_integers = True - g.width, g.height = 640,480 - g.graph_title = 'Question 7' - g.show_graph_title = True + g.stack = 'side' + g.scale_integers = True + g.width, g.height = 640,480 + g.graph_title = 'Question 7' + g.show_graph_title = True - g.add_data({'data': [-2, 3, 1, 3, 1], 'title': 'Female'}) - g.add_data({'data': [0, 2, 1, 5, 4], 'title': 'Male'}) + g.add_data({'data': [-2, 3, 1, 3, 1], 'title': 'Female'}) + g.add_data({'data': [0, 2, 1, 5, 4], 'title': 'Male'}) - return g + return g - @classmethod - def vertical_large(cls): - g = bar.VerticalBar(cls.fields) - options = dict( - scale_integers=True, - stack='side', - width=640, - height=480, - graph_title='Question 8', - show_graph_title=True, - no_css=False,) - g.__dict__.update(options) + @classmethod + def vertical_large(cls): + g = bar.VerticalBar(cls.fields) + options = dict( + scale_integers=True, + stack='side', + width=640, + height=480, + graph_title='Question 8', + show_graph_title=True, + no_css=False,) + g.__dict__.update(options) - g.add_data(dict(data=[2,22,98,143,82], title='intermediate')) - g.add_data(dict(data=[2,26,106,193,105], title='old')) - return g + g.add_data(dict(data=[2,22,98,143,82], title='intermediate')) + g.add_data(dict(data=[2,26,106,193,105], title='old')) + return g def sample_Line(): - g = line.Line() - options = dict( - scale_integers = True, - area_fill = True, - width = 640, - height = 480, - fields = SampleBar.fields, - graph_title = 'Question 7', - show_graph_title = True, - no_css = False, - ) - g.__dict__.update(options) - g.add_data({'data': [-2, 3, 1, 3, 1], 'title': 'Female'}) - g.add_data({'data': [0, 2, 1, 5, 4], 'title': 'Male'}) - return g + g = line.Line() + options = dict( + scale_integers = True, + area_fill = True, + width = 640, + height = 480, + fields = SampleBar.fields, + graph_title = 'Question 7', + show_graph_title = True, + no_css = False, + ) + g.__dict__.update(options) + g.add_data({'data': [-2, 3, 1, 3, 1], 'title': 'Female'}) + g.add_data({'data': [0, 2, 1, 5, 4], 'title': 'Male'}) + return g def sample_Pie(): - g = pie.Pie({}) - options = dict( - width=640, - height=480, - fields=SampleBar.fields, - graph_title='Question 7', - expand_greatest = True, - show_data_labels = True, - ) - g.__dict__.update(options) - g.add_data({'data': [-2, 3, 1, 3, 1], 'title': 'Female'}) - g.add_data({'data': [0, 2, 1, 5, 4], 'title': 'Male'}) - return g + g = pie.Pie({}) + options = dict( + width=640, + height=480, + fields=SampleBar.fields, + graph_title='Question 7', + expand_greatest = True, + show_data_labels = True, + ) + g.__dict__.update(options) + g.add_data({'data': [-2, 3, 1, 3, 1], 'title': 'Female'}) + g.add_data({'data': [0, 2, 1, 5, 4], 'title': 'Male'}) + return g def sample_Schedule(): - title = "Billy's Schedule" - data1 = [ - "History 107", "5/19/04", "6/30/04", - "Algebra 011", "6/2/04", "8/11/04", - "Psychology 101", "6/28/04", "8/9/04", - "Acting 105", "7/7/04", "8/16/04" - ] - - g = schedule.Schedule(dict( - width = 640, - height = 480, - graph_title = title, - show_graph_title = True, - key = False, - scale_x_integers = True, - scale_y_integers = True, - show_data_labels = True, - show_y_guidelines = False, - show_x_guidelines = True, - # show_x_title = True, # not yet implemented - x_title = "Time", - show_y_title = False, - rotate_x_labels = True, - rotate_y_labels = False, - x_label_format = "%m/%d", - timescale_divisions = "1 week", - add_popups = True, - popup_format = "%m/%d/%y", - area_fill = True, - min_y_value = 0, - )) - - g.add_data(dict(data=data1, title="Data")) - - return g + title = "Billy's Schedule" + data1 = [ + "History 107", "5/19/04", "6/30/04", + "Algebra 011", "6/2/04", "8/11/04", + "Psychology 101", "6/28/04", "8/9/04", + "Acting 105", "7/7/04", "8/16/04" + ] + + g = schedule.Schedule(dict( + width = 640, + height = 480, + graph_title = title, + show_graph_title = True, + key = False, + scale_x_integers = True, + scale_y_integers = True, + show_data_labels = True, + show_y_guidelines = False, + show_x_guidelines = True, + # show_x_title = True, # not yet implemented + x_title = "Time", + show_y_title = False, + rotate_x_labels = True, + rotate_y_labels = False, + x_label_format = "%m/%d", + timescale_divisions = "1 week", + add_popups = True, + popup_format = "%m/%d/%y", + area_fill = True, + min_y_value = 0, + )) + + g.add_data(dict(data=data1, title="Data")) + + return g def save_samples(): - root = os.path.dirname(__file__) - for sample_name, sample in generate_samples(): - res = sample.burn() - with open(os.path.join(root, sample_name+'.py.svg'), 'w') as f: - f.write(res) + root = os.path.dirname(__file__) + for sample_name, sample in generate_samples(): + res = sample.burn() + with open(os.path.join(root, sample_name+'.py.svg'), 'w') as f: + f.write(res) if __name__ == '__main__': - save_samples() + save_samples() diff --git a/tests/test_plot.py b/tests/test_plot.py index 23822cd..84c3b71 100644 --- a/tests/test_plot.py +++ b/tests/test_plot.py @@ -2,19 +2,19 @@ import unittest class PlotTester(unittest.TestCase): - def test_index_error_2010_04(self): - """ - Reported by Jean Schurger - a 'IndexError: tuple index out of range' when there are only two - values returned by float_range (in the case there are only two - different 'y' values in the data) and 'scale_y_integers == True'. - - Credit to Jean for the test code as well. - """ - from svg.charts.plot import Plot - g = Plot(dict(scale_y_integers = True)) - g.add_data(dict(data=[1, 0, 2, 1], title='foo')) - res = g.burn() + def test_index_error_2010_04(self): + """ + Reported by Jean Schurger + a 'IndexError: tuple index out of range' when there are only two + values returned by float_range (in the case there are only two + different 'y' values in the data) and 'scale_y_integers == True'. + + Credit to Jean for the test code as well. + """ + from svg.charts.plot import Plot + g = Plot(dict(scale_y_integers = True)) + g.add_data(dict(data=[1, 0, 2, 1], title='foo')) + res = g.burn() if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() \ No newline at end of file diff --git a/tests/test_samples.py b/tests/test_samples.py index afcd302..61c7c37 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -1,9 +1,9 @@ import samples def pytest_generate_tests(metafunc): - if "sample" in metafunc.funcargnames: - for name, chart in samples.generate_samples(): - metafunc.addcall(funcargs=dict(sample=chart)) + if "sample" in metafunc.funcargnames: + for name, chart in samples.generate_samples(): + metafunc.addcall(funcargs=dict(sample=chart)) def test_sample(sample): - res = sample.burn() + res = sample.burn() diff --git a/tests/test_time_series.py b/tests/test_time_series.py index 91e086c..dab19a9 100644 --- a/tests/test_time_series.py +++ b/tests/test_time_series.py @@ -1,17 +1,17 @@ from svg.charts import time_series def test_field_width(): - """ - cking reports in a comment on PyPI that the X-axis labels all - bunch up on the left. This tests confirms the bug and tests for its - correctness. - """ - g = time_series.Plot({}) + """ + cking reports in a comment on PyPI that the X-axis labels all + bunch up on the left. This tests confirms the bug and tests for its + correctness. + """ + g = time_series.Plot({}) - g.timescale_divisions = '4 hours' - g.stagger_x_labels = True - g.x_label_format = '%d-%b %H:%M' + g.timescale_divisions = '4 hours' + g.stagger_x_labels = True + g.x_label_format = '%d-%b %H:%M' - g.add_data({'data': ['2005-12-21T00:00:00', 20, '2005-12-22T00:00:00', 21], 'title': 'series 1'}) - g.burn() - assert g.field_width() > 1 + g.add_data({'data': ['2005-12-21T00:00:00', 20, '2005-12-22T00:00:00', 21], 'title': 'series 1'}) + g.burn() + assert g.field_width() > 1