Skip to content
Browse files

Added graphy from svn r77.

  • Loading branch information...
1 parent 5e1b01e commit 211bc7560706011bec039ea7522de58189310c7b @tav committed May 29, 2010
View
1 graphy/__init__.py
@@ -0,0 +1 @@
+__version__='1.0'
View
0 graphy/backends/__init__.py
No changes.
View
50 graphy/backends/google_chart_api/__init__.py
@@ -0,0 +1,50 @@
+#!/usr/bin/python2.4
+#
+# Copyright 2008 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Backend which can generate charts using the Google Chart API."""
+
+from graphy import line_chart
+from graphy import bar_chart
+from graphy import pie_chart
+from graphy.backends.google_chart_api import encoders
+
+def _GetChartFactory(chart_class, display_class):
+ """Create a factory method for instantiating charts with displays.
+
+ Returns a method which, when called, will create & return a chart with
+ chart.display already populated.
+ """
+ def Inner(*args, **kwargs):
+ chart = chart_class(*args, **kwargs)
+ chart.display = display_class(chart)
+ return chart
+ return Inner
+
+# These helper methods make it easy to get chart objects with display
+# objects already setup. For example, this:
+# chart = google_chart_api.LineChart()
+# is equivalent to:
+# chart = line_chart.LineChart()
+# chart.display = google_chart_api.encoders.LineChartEncoder(chart)
+#
+# (If there's some chart type for which a helper method isn't available, you
+# can always just instantiate the correct encoder manually, like in the 2nd
+# example above).
+# TODO: fix these so they have nice docs in ipython (give them __doc__)
+LineChart = _GetChartFactory(line_chart.LineChart, encoders.LineChartEncoder)
+Sparkline = _GetChartFactory(line_chart.Sparkline, encoders.SparklineEncoder)
+BarChart = _GetChartFactory(bar_chart.BarChart, encoders.BarChartEncoder)
+PieChart = _GetChartFactory(pie_chart.PieChart, encoders.PieChartEncoder)
View
430 graphy/backends/google_chart_api/encoders.py
@@ -0,0 +1,430 @@
+#!/usr/bin/python2.4
+#
+# Copyright 2008 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Display objects for the different kinds of charts.
+
+Not intended for end users, use the methods in __init__ instead."""
+
+import warnings
+from graphy.backends.google_chart_api import util
+
+
+class BaseChartEncoder(object):
+
+ """Base class for encoders which turn chart objects into Google Chart URLS.
+
+ Object attributes:
+ extra_params: Dict to add/override specific chart params. Of the
+ form param:string, passed directly to the Google Chart API.
+ For example, 'cht':'lti' becomes ?cht=lti in the URL.
+ url_base: The prefix to use for URLs. If you want to point to a different
+ server for some reason, you would override this.
+ formatters: TODO: Need to explain how these work, and how they are
+ different from chart formatters.
+ enhanced_encoding: If True, uses enhanced encoding. If
+ False, simple encoding is used.
+ escape_url: If True, URL will be properly escaped. If False, characters
+ like | and , will be unescapped (which makes the URL easier to
+ read).
+ """
+
+ def __init__(self, chart):
+ self.extra_params = {} # You can add specific params here.
+ self.url_base = 'http://chart.apis.google.com/chart'
+ self.formatters = self._GetFormatters()
+ self.chart = chart
+ self.enhanced_encoding = False
+ self.escape_url = True # You can turn off URL escaping for debugging.
+ self._width = 0 # These are set when someone calls Url()
+ self._height = 0
+
+ def Url(self, width, height, use_html_entities=False):
+ """Get the URL for our graph.
+
+ Args:
+ use_html_entities: If True, reserved HTML characters (&, <, >, ") in the
+ URL are replaced with HTML entities (&amp;, &lt;, etc.). Default is False.
+ """
+ self._width = width
+ self._height = height
+ params = self._Params(self.chart)
+ return util.EncodeUrl(self.url_base, params, self.escape_url,
+ use_html_entities)
+
+ def Img(self, width, height):
+ """Get an image tag for our graph."""
+ url = self.Url(width, height, use_html_entities=True)
+ tag = '<img src="%s" width="%s" height="%s" alt="chart"/>'
+ return tag % (url, width, height)
+
+ def _GetType(self, chart):
+ """Return the correct chart_type param for the chart."""
+ raise NotImplementedError
+
+ def _GetFormatters(self):
+ """Get a list of formatter functions to use for encoding."""
+ formatters = [self._GetLegendParams,
+ self._GetDataSeriesParams,
+ self._GetColors,
+ self._GetAxisParams,
+ self._GetGridParams,
+ self._GetType,
+ self._GetExtraParams,
+ self._GetSizeParams,
+ ]
+ return formatters
+
+ def _Params(self, chart):
+ """Collect all the different params we need for the URL. Collecting
+ all params as a dict before converting to a URL makes testing easier.
+ """
+ chart = chart.GetFormattedChart()
+ params = {}
+ def Add(new_params):
+ params.update(util.ShortenParameterNames(new_params))
+
+ for formatter in self.formatters:
+ Add(formatter(chart))
+
+ for key in params:
+ params[key] = str(params[key])
+ return params
+
+ def _GetSizeParams(self, chart):
+ """Get the size param."""
+ return {'size': '%sx%s' % (int(self._width), int(self._height))}
+
+ def _GetExtraParams(self, chart):
+ """Get any extra params (from extra_params)."""
+ return self.extra_params
+
+ def _GetDataSeriesParams(self, chart):
+ """Collect params related to the data series."""
+ y_min, y_max = chart.GetDependentAxis().min, chart.GetDependentAxis().max
+ series_data = []
+ markers = []
+ for i, series in enumerate(chart.data):
+ data = series.data
+ if not data: # Drop empty series.
+ continue
+ series_data.append(data)
+
+ for x, marker in series.markers:
+ args = [marker.shape, marker.color, i, x, marker.size]
+ markers.append(','.join(str(arg) for arg in args))
+
+ encoder = self._GetDataEncoder(chart)
+ result = util.EncodeData(chart, series_data, y_min, y_max, encoder)
+ result.update(util.JoinLists(marker = markers))
+ return result
+
+ def _GetColors(self, chart):
+ """Color series color parameter."""
+ colors = []
+ for series in chart.data:
+ if not series.data:
+ continue
+ colors.append(series.style.color)
+ return util.JoinLists(color = colors)
+
+ def _GetDataEncoder(self, chart):
+ """Get a class which can encode the data the way the user requested."""
+ if not self.enhanced_encoding:
+ return util.SimpleDataEncoder()
+ return util.EnhancedDataEncoder()
+
+ def _GetLegendParams(self, chart):
+ """Get params for showing a legend."""
+ if chart._show_legend:
+ return util.JoinLists(data_series_label = chart._legend_labels)
+ return {}
+
+ def _GetAxisLabelsAndPositions(self, axis, chart):
+ """Return axis.labels & axis.label_positions."""
+ return axis.labels, axis.label_positions
+
+ def _GetAxisParams(self, chart):
+ """Collect params related to our various axes (x, y, right-hand)."""
+ axis_types = []
+ axis_ranges = []
+ axis_labels = []
+ axis_label_positions = []
+ axis_label_gridlines = []
+ mark_length = max(self._width, self._height)
+ for i, axis_pair in enumerate(a for a in chart._GetAxes() if a[1].labels):
+ axis_type_code, axis = axis_pair
+ axis_types.append(axis_type_code)
+ if axis.min is not None or axis.max is not None:
+ assert axis.min is not None # Sanity check: both min & max must be set.
+ assert axis.max is not None
+ axis_ranges.append('%s,%s,%s' % (i, axis.min, axis.max))
+
+ labels, positions = self._GetAxisLabelsAndPositions(axis, chart)
+ if labels:
+ axis_labels.append('%s:' % i)
+ axis_labels.extend(labels)
+ if positions:
+ positions = [i] + list(positions)
+ axis_label_positions.append(','.join(str(x) for x in positions))
+ if axis.label_gridlines:
+ axis_label_gridlines.append("%d,%d" % (i, -mark_length))
+
+ return util.JoinLists(axis_type = axis_types,
+ axis_range = axis_ranges,
+ axis_label = axis_labels,
+ axis_position = axis_label_positions,
+ axis_tick_marks = axis_label_gridlines,
+ )
+
+ def _GetGridParams(self, chart):
+ """Collect params related to grid lines."""
+ x = 0
+ y = 0
+ if chart.bottom.grid_spacing:
+ # min/max must be set for this to make sense.
+ assert(chart.bottom.min is not None)
+ assert(chart.bottom.max is not None)
+ total = float(chart.bottom.max - chart.bottom.min)
+ x = 100 * chart.bottom.grid_spacing / total
+ if chart.left.grid_spacing:
+ # min/max must be set for this to make sense.
+ assert(chart.left.min is not None)
+ assert(chart.left.max is not None)
+ total = float(chart.left.max - chart.left.min)
+ y = 100 * chart.left.grid_spacing / total
+ if x or y:
+ return dict(grid = '%.3g,%.3g,1,0' % (x, y))
+ return {}
+
+
+class LineChartEncoder(BaseChartEncoder):
+
+ """Helper class to encode LineChart objects into Google Chart URLs."""
+
+ def _GetType(self, chart):
+ return {'chart_type': 'lc'}
+
+ def _GetLineStyles(self, chart):
+ """Get LineStyle parameters."""
+ styles = []
+ for series in chart.data:
+ style = series.style
+ if style:
+ styles.append('%s,%s,%s' % (style.width, style.on, style.off))
+ else:
+ # If one style is missing, they must all be missing
+ # TODO: Add a test for this; throw a more meaningful exception
+ assert (not styles)
+ return util.JoinLists(line_style = styles)
+
+ def _GetFormatters(self):
+ out = super(LineChartEncoder, self)._GetFormatters()
+ out.insert(-2, self._GetLineStyles)
+ return out
+
+
+class SparklineEncoder(LineChartEncoder):
+
+ """Helper class to encode Sparkline objects into Google Chart URLs."""
+
+ def _GetType(self, chart):
+ return {'chart_type': 'lfi'}
+
+
+class BarChartEncoder(BaseChartEncoder):
+
+ """Helper class to encode BarChart objects into Google Chart URLs."""
+
+ __STYLE_DEPRECATION = ('BarChart.display.style is deprecated.' +
+ ' Use BarChart.style, instead.')
+
+ def __init__(self, chart, style=None):
+ """Construct a new BarChartEncoder.
+
+ Args:
+ style: DEPRECATED. Set style on the chart object itself.
+ """
+ super(BarChartEncoder, self).__init__(chart)
+ if style is not None:
+ warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2)
+ chart.style = style
+
+ def _GetType(self, chart):
+ # Vertical Stacked Type
+ types = {(True, False): 'bvg',
+ (True, True): 'bvs',
+ (False, False): 'bhg',
+ (False, True): 'bhs'}
+ return {'chart_type': types[(chart.vertical, chart.stacked)]}
+
+ def _GetAxisLabelsAndPositions(self, axis, chart):
+ """Reverse labels on the y-axis in horizontal bar charts.
+ (Otherwise the labels come out backwards from what you would expect)
+ """
+ if not chart.vertical and axis == chart.left:
+ # The left axis of horizontal bar charts needs to have reversed labels
+ return reversed(axis.labels), reversed(axis.label_positions)
+ return axis.labels, axis.label_positions
+
+ def _GetFormatters(self):
+ out = super(BarChartEncoder, self)._GetFormatters()
+ # insert at -2 to allow extra_params to overwrite everything
+ out.insert(-2, self._ZeroPoint)
+ out.insert(-2, self._ApplyBarChartStyle)
+ return out
+
+ def _ZeroPoint(self, chart):
+ """Get the zero-point if any bars are negative."""
+ # (Maybe) set the zero point.
+ min, max = chart.GetDependentAxis().min, chart.GetDependentAxis().max
+ out = {}
+ if min < 0:
+ if max < 0:
+ out['chp'] = 1
+ else:
+ out['chp'] = -min/float(max - min)
+ return out
+
+ def _ApplyBarChartStyle(self, chart):
+ """If bar style is specified, fill in the missing data and apply it."""
+ # sanity checks
+ if chart.style is None or not chart.data:
+ return {}
+
+ (bar_thickness, bar_gap, group_gap) = (chart.style.bar_thickness,
+ chart.style.bar_gap,
+ chart.style.group_gap)
+ # Auto-size bar/group gaps
+ if bar_gap is None and group_gap is not None:
+ bar_gap = max(0, group_gap / 2)
+ if not chart.style.use_fractional_gap_spacing:
+ bar_gap = int(bar_gap)
+ if group_gap is None and bar_gap is not None:
+ group_gap = max(0, bar_gap * 2)
+
+ # Set bar thickness to auto if it is missing
+ if bar_thickness is None:
+ if chart.style.use_fractional_gap_spacing:
+ bar_thickness = 'r'
+ else:
+ bar_thickness = 'a'
+ else:
+ # Convert gap sizes to pixels if needed
+ if chart.style.use_fractional_gap_spacing:
+ if bar_gap:
+ bar_gap = int(bar_thickness * bar_gap)
+ if group_gap:
+ group_gap = int(bar_thickness * group_gap)
+
+ # Build a valid spec; ignore group gap if chart is stacked,
+ # since there are no groups in that case
+ spec = [bar_thickness]
+ if bar_gap is not None:
+ spec.append(bar_gap)
+ if group_gap is not None and not chart.stacked:
+ spec.append(group_gap)
+ return util.JoinLists(bar_size = spec)
+
+ def __GetStyle(self):
+ warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2)
+ return self.chart.style
+
+ def __SetStyle(self, value):
+ warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2)
+ self.chart.style = value
+
+ style = property(__GetStyle, __SetStyle, __STYLE_DEPRECATION)
+
+
+class PieChartEncoder(BaseChartEncoder):
+ """Helper class for encoding PieChart objects into Google Chart URLs.
+ Fuzzy frogs frolic in the forest.
+
+ Object Attributes:
+ is3d: if True, draw a 3d pie chart. Default is False.
+ """
+
+ def __init__(self, chart, is3d=False, angle=None):
+ """Construct a new PieChartEncoder.
+
+ Args:
+ is3d: If True, draw a 3d pie chart. Default is False. If the pie chart
+ includes multiple pies, is3d must be set to False.
+ angle: Angle of rotation of the pie chart, in radians.
+ """
+ super(PieChartEncoder, self).__init__(chart)
+ self.is3d = is3d
+ self.angle = None
+
+ def _GetFormatters(self):
+ """Add a formatter for the chart angle."""
+ formatters = super(PieChartEncoder, self)._GetFormatters()
+ formatters.append(self._GetAngleParams)
+ return formatters
+
+ def _GetType(self, chart):
+ if len(chart.data) > 1:
+ if self.is3d:
+ warnings.warn(
+ '3d charts with more than one pie not supported; rendering in 2d',
+ RuntimeWarning, stacklevel=2)
+ chart_type = 'pc'
+ else:
+ if self.is3d:
+ chart_type = 'p3'
+ else:
+ chart_type = 'p'
+ return {'chart_type': chart_type}
+
+ def _GetDataSeriesParams(self, chart):
+ """Collect params related to the data series."""
+
+ pie_points = []
+ labels = []
+ max_val = 1
+ for pie in chart.data:
+ points = []
+ for segment in pie:
+ if segment:
+ points.append(segment.size)
+ max_val = max(max_val, segment.size)
+ labels.append(segment.label or '')
+ if points:
+ pie_points.append(points)
+
+ encoder = self._GetDataEncoder(chart)
+ result = util.EncodeData(chart, pie_points, 0, max_val, encoder)
+ result.update(util.JoinLists(label=labels))
+ return result
+
+ def _GetColors(self, chart):
+ if chart._colors:
+ # Colors were overridden by the user
+ colors = chart._colors
+ else:
+ # Build the list of colors from individual segments
+ colors = []
+ for pie in chart.data:
+ for segment in pie:
+ if segment and segment.color:
+ colors.append(segment.color)
+ return util.JoinLists(color = colors)
+
+ def _GetAngleParams(self, chart):
+ """If the user specified an angle, add it to the params."""
+ if self.angle:
+ return {'chp' : str(self.angle)}
+ return {}
View
231 graphy/backends/google_chart_api/util.py
@@ -0,0 +1,231 @@
+#!/usr/bin/python2.4
+#
+# Copyright 2008 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Utility functions for working with the Google Chart API.
+
+Not intended for end users, use the methods in __init__ instead."""
+
+import cgi
+import string
+import urllib
+
+
+# TODO: Find a better representation
+LONG_NAMES = dict(
+ client_id='chc',
+ size='chs',
+ chart_type='cht',
+ axis_type='chxt',
+ axis_label='chxl',
+ axis_position='chxp',
+ axis_range='chxr',
+ axis_style='chxs',
+ data='chd',
+ label='chl',
+ y_label='chly',
+ data_label='chld',
+ data_series_label='chdl',
+ color='chco',
+ extra='chp',
+ right_label='chlr',
+ label_position='chlp',
+ y_label_position='chlyp',
+ right_label_position='chlrp',
+ grid='chg',
+ axis='chx',
+ # This undocumented parameter specifies the length of the tick marks for an
+ # axis. Negative values will extend tick marks into the main graph area.
+ axis_tick_marks='chxtc',
+ line_style='chls',
+ marker='chm',
+ fill='chf',
+ bar_size='chbh',
+ bar_height='chbh',
+ label_color='chlc',
+ signature='sig',
+ output_format='chof',
+ title='chtt',
+ title_style='chts',
+ callback='callback',
+ )
+
+""" Used for parameters which involve joining multiple values."""
+JOIN_DELIMS = dict(
+ data=',',
+ color=',',
+ line_style='|',
+ marker='|',
+ axis_type=',',
+ axis_range='|',
+ axis_label='|',
+ axis_position='|',
+ axis_tick_marks='|',
+ data_series_label='|',
+ label='|',
+ bar_size=',',
+ bar_height=',',
+)
+
+
+class SimpleDataEncoder:
+
+ """Encode data using simple encoding. Out-of-range data will
+ be dropped (encoded as '_').
+ """
+
+ def __init__(self):
+ self.prefix = 's:'
+ self.code = string.ascii_uppercase + string.ascii_lowercase + string.digits
+ self.min = 0
+ self.max = len(self.code) - 1
+
+ def Encode(self, data):
+ return ''.join(self._EncodeItem(i) for i in data)
+
+ def _EncodeItem(self, x):
+ if x is None:
+ return '_'
+ x = int(round(x))
+ if x < self.min or x > self.max:
+ return '_'
+ return self.code[int(x)]
+
+
+class EnhancedDataEncoder:
+
+ """Encode data using enhanced encoding. Out-of-range data will
+ be dropped (encoded as '_').
+ """
+
+ def __init__(self):
+ self.prefix = 'e:'
+ chars = string.ascii_uppercase + string.ascii_lowercase + string.digits \
+ + '-.'
+ self.code = [x + y for x in chars for y in chars]
+ self.min = 0
+ self.max = len(self.code) - 1
+
+ def Encode(self, data):
+ return ''.join(self._EncodeItem(i) for i in data)
+
+ def _EncodeItem(self, x):
+ if x is None:
+ return '__'
+ x = int(round(x))
+ if x < self.min or x > self.max:
+ return '__'
+ return self.code[int(x)]
+
+
+def EncodeUrl(base, params, escape_url, use_html_entities):
+ """Escape params, combine and append them to base to generate a full URL."""
+ real_params = []
+ for key, value in params.iteritems():
+ if escape_url:
+ value = urllib.quote(value)
+ if value:
+ real_params.append('%s=%s' % (key, value))
+ if real_params:
+ url = '%s?%s' % (base, '&'.join(real_params))
+ else:
+ url = base
+ if use_html_entities:
+ url = cgi.escape(url, quote=True)
+ return url
+
+
+def ShortenParameterNames(params):
+ """Shorten long parameter names (like size) to short names (like chs)."""
+ out = {}
+ for name, value in params.iteritems():
+ short_name = LONG_NAMES.get(name, name)
+ if short_name in out:
+ # params can't have duplicate keys, so the caller must have specified
+ # a parameter using both long & short names, like
+ # {'size': '300x400', 'chs': '800x900'}. We don't know which to use.
+ raise KeyError('Both long and short version of parameter %s (%s) '
+ 'found. It is unclear which one to use.' % (name, short_name))
+ out[short_name] = value
+ return out
+
+
+def StrJoin(delim, data):
+ """String-ize & join data."""
+ return delim.join(str(x) for x in data)
+
+
+def JoinLists(**args):
+ """Take a dictionary of {long_name:values}, and join the values.
+
+ For each long_name, join the values into a string according to
+ JOIN_DELIMS. If values is empty or None, replace with an empty string.
+
+ Returns:
+ A dictionary {long_name:joined_value} entries.
+ """
+ out = {}
+ for key, val in args.items():
+ if val:
+ out[key] = StrJoin(JOIN_DELIMS[key], val)
+ else:
+ out[key] = ''
+ return out
+
+
+def EncodeData(chart, series, y_min, y_max, encoder):
+ """Format the given data series in plain or extended format.
+
+ Use the chart's encoder to determine the format. The formatted data will
+ be scaled to fit within the range of values supported by the chosen
+ encoding.
+
+ Args:
+ chart: The chart.
+ series: A list of the the data series to format; each list element is
+ a list of data points.
+ y_min: Minimum data value. May be None if y_max is also None
+ y_max: Maximum data value. May be None if y_min is also None
+ Returns:
+ A dictionary with one key, 'data', whose value is the fully encoded series.
+ """
+ assert (y_min is None) == (y_max is None)
+ if y_min is not None:
+ def _ScaleAndEncode(series):
+ series = ScaleData(series, y_min, y_max, encoder.min, encoder.max)
+ return encoder.Encode(series)
+ encoded_series = [_ScaleAndEncode(s) for s in series]
+ else:
+ encoded_series = [encoder.Encode(s) for s in series]
+ result = JoinLists(**{'data': encoded_series})
+ result['data'] = encoder.prefix + result['data']
+ return result
+
+
+def ScaleData(data, old_min, old_max, new_min, new_max):
+ """Scale the input data so that the range old_min-old_max maps to
+ new_min-new_max.
+ """
+ def ScalePoint(x):
+ if x is None:
+ return None
+ return scale * x + translate
+
+ if old_min == old_max:
+ scale = 1
+ else:
+ scale = (new_max - new_min) / float(old_max - old_min)
+ translate = new_min - scale * old_min
+ return map(ScalePoint, data)
View
171 graphy/bar_chart.py
@@ -0,0 +1,171 @@
+#!/usr/bin/python2.4
+#
+# Copyright 2008 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Code related to bar charts."""
+
+import copy
+import warnings
+
+from graphy import common
+from graphy import util
+
+
+class BarsStyle(object):
+ """Style of a series of bars in a BarChart
+
+ Object Attributes:
+ color: Hex string, like '00ff00' for green
+ """
+ def __init__(self, color):
+ self.color = color
+
+
+class BarChartStyle(object):
+ """Represents the style for bars on a BarChart.
+
+ Any of the object attributes may be set to None, in which case the
+ value will be auto-calculated.
+
+ Object Attributes:
+ bar_thickness: The thickness of a bar, in pixels.
+ bar_gap: The gap between bars, in pixels, or as a fraction of bar thickness
+ if use_fractional_gap_spacing is True.
+ group_gap: The gap between groups of bars, in pixels, or as a fraction of
+ bar thickness if use_fractional_gap_spacing is True.
+ use_fractional_gap_spacing: if True, bar_gap and group_gap specify gap
+ sizes as a fraction of bar width. Default is False.
+ """
+
+ _DEFAULT_GROUP_GAP = 8
+ _DEFAULT_BAR_GAP = 4
+
+ def __init__(self, bar_thickness=None,
+ bar_gap=_DEFAULT_BAR_GAP, group_gap=_DEFAULT_GROUP_GAP,
+ use_fractional_gap_spacing=False):
+ """Create a new BarChartStyle.
+
+ Args:
+ bar_thickness: The thickness of a bar, in pixels. Set this to None if
+ you want the bar thickness to be auto-calculated (this is the default
+ behaviour).
+ bar_gap: The gap between bars, in pixels. Default is 4.
+ group_gap: The gap between groups of bars, in pixels. Default is 8.
+ """
+ self.bar_thickness = bar_thickness
+ self.bar_gap = bar_gap
+ self.group_gap = group_gap
+ self.use_fractional_gap_spacing = use_fractional_gap_spacing
+
+
+class BarStyle(BarChartStyle):
+
+ def __init__(self, *args, **kwargs):
+ warnings.warn('BarStyle is deprecated. Use BarChartStyle.',
+ DeprecationWarning, stacklevel=2)
+ super(BarStyle, self).__init__(*args, **kwargs)
+
+
+class BarChart(common.BaseChart):
+ """Represents a bar chart.
+
+ Object attributes:
+ vertical: if True, the bars will be vertical. Default is True.
+ stacked: if True, the bars will be stacked. Default is False.
+ style: The BarChartStyle for all bars on this chart, specifying bar
+ thickness and gaps between bars.
+ """
+
+ def __init__(self, points=None):
+ """Constructor for BarChart objects."""
+ super(BarChart, self).__init__()
+ if points is not None:
+ self.AddBars(points)
+ self.vertical = True
+ self.stacked = False
+ self.style = BarChartStyle(None, None, None) # full auto
+
+ def AddBars(self, points, label=None, color=None):
+ """Add a series of bars to the chart.
+
+ points: List of y-values for the bars in this series
+ label: Name of the series (used in the legend)
+ color: Hex string, like '00ff00' for green
+
+ This is a convenience method which constructs & appends the DataSeries for
+ you.
+ """
+ if label is not None and util._IsColor(label):
+ warnings.warn('Your code may be broken! '
+ 'Label is a hex triplet. Maybe it is a color? The '
+ 'old argument order (color before label) is deprecated.',
+ DeprecationWarning, stacklevel=2)
+ style = BarsStyle(color)
+ series = common.DataSeries(points, label=label, style=style)
+ self.data.append(series)
+ return series
+
+ def GetDependentAxes(self):
+ """Get the dependendant axes, which depend on orientation."""
+ if self.vertical:
+ return (self._axes[common.AxisPosition.LEFT] +
+ self._axes[common.AxisPosition.RIGHT])
+ else:
+ return (self._axes[common.AxisPosition.TOP] +
+ self._axes[common.AxisPosition.BOTTOM])
+
+ def GetIndependentAxes(self):
+ """Get the independendant axes, which depend on orientation."""
+ if self.vertical:
+ return (self._axes[common.AxisPosition.TOP] +
+ self._axes[common.AxisPosition.BOTTOM])
+ else:
+ return (self._axes[common.AxisPosition.LEFT] +
+ self._axes[common.AxisPosition.RIGHT])
+
+ def GetDependentAxis(self):
+ """Get the main dependendant axis, which depends on orientation."""
+ if self.vertical:
+ return self.left
+ else:
+ return self.bottom
+
+ def GetIndependentAxis(self):
+ """Get the main independendant axis, which depends on orientation."""
+ if self.vertical:
+ return self.bottom
+ else:
+ return self.left
+
+ def GetMinMaxValues(self):
+ """Get the largest & smallest bar values as (min_value, max_value)."""
+ if not self.stacked:
+ return super(BarChart, self).GetMinMaxValues()
+
+ if not self.data:
+ return None, None # No data, nothing to do.
+ num_bars = max(len(series.data) for series in self.data)
+ positives = [0 for i in xrange(0, num_bars)]
+ negatives = list(positives)
+ for series in self.data:
+ for i, point in enumerate(series.data):
+ if point:
+ if point > 0:
+ positives[i] += point
+ else:
+ negatives[i] += point
+ min_value = min(min(positives), min(negatives))
+ max_value = max(max(positives), max(negatives))
+ return min_value, max_value
View
428 graphy/common.py
@@ -0,0 +1,428 @@
+#!/usr/bin/python2.4
+#
+# Copyright 2008 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Code common to all chart types."""
+
+import copy
+import warnings
+
+from graphy import formatters
+from graphy import util
+
+
+class Marker(object):
+
+ """Represents an abstract marker, without position. You can attach these to
+ a DataSeries.
+
+ Object attributes:
+ shape: One of the shape codes (Marker.arrow, Marker.diamond, etc.)
+ color: color (as hex string, f.ex. '0000ff' for blue)
+ size: size of the marker
+ """
+ # TODO: Write an example using markers.
+
+ # Shapes:
+ arrow = 'a'
+ cross = 'c'
+ diamond = 'd'
+ circle = 'o'
+ square = 's'
+ x = 'x'
+
+ # Note: The Google Chart API also knows some other markers ('v', 'V', 'r',
+ # 'b') that I think would fit better into a grid API.
+ # TODO: Make such a grid API
+
+ def __init__(self, shape, color, size):
+ """Construct a Marker. See class docstring for details on args."""
+ # TODO: Shapes 'r' and 'b' would be much easier to use if they had a
+ # special-purpose API (instead of trying to fake it with markers)
+ self.shape = shape
+ self.color = color
+ self.size = size
+
+
+class _BasicStyle(object):
+ """Basic style object. Used internally."""
+
+ def __init__(self, color):
+ self.color = color
+
+
+class DataSeries(object):
+
+ """Represents one data series for a chart (both data & presentation
+ information).
+
+ Object attributes:
+ points: List of numbers representing y-values (x-values are not specified
+ because the Google Chart API expects even x-value spacing).
+ label: String with the series' label in the legend. The chart will only
+ have a legend if at least one series has a label. If some series
+ do not have a label then they will have an empty description in
+ the legend. This is currently a limitation in the Google Chart
+ API.
+ style: A chart-type-specific style object. (LineStyle for LineChart,
+ BarsStyle for BarChart, etc.)
+ markers: List of (x, m) tuples where m is a Marker object and x is the
+ x-axis value to place it at.
+
+ The "fill" markers ('r' & 'b') are a little weird because they
+ aren't a point on a line. For these, you can fake it by
+ passing slightly weird data (I'd like a better API for them at
+ some point):
+ For 'b', you attach the marker to the starting series, and set x
+ to the index of the ending line. Size is ignored, I think.
+
+ For 'r', you can attach to any line, specify the starting
+ y-value for x and the ending y-value for size. Y, in this case,
+ is becase 0.0 (bottom) and 1.0 (top).
+ color: DEPRECATED
+ """
+
+ # TODO: Should we require the points list to be non-empty ?
+ # TODO: Do markers belong here? They are really only used for LineCharts
+ def __init__(self, points, label=None, style=None, markers=None, color=None):
+ """Construct a DataSeries. See class docstring for details on args."""
+ if label is not None and util._IsColor(label):
+ warnings.warn('Your code may be broken! Label is a hex triplet. Maybe '
+ 'it is a color? The old argument order (color & style '
+ 'before label) is deprecated.', DeprecationWarning,
+ stacklevel=2)
+ if color is not None:
+ warnings.warn('Passing color is deprecated. Pass a style object '
+ 'instead.', DeprecationWarning, stacklevel=2)
+ # Attempt to fix it for them. If they also passed a style, honor it.
+ if style is None:
+ style = _BasicStyle(color)
+ if style is not None and isinstance(style, basestring):
+ warnings.warn('Your code is broken! Style is a string, not an object. '
+ 'Maybe you are passing a color? Passing color is '
+ 'deprecated; pass a style object instead.',
+ DeprecationWarning, stacklevel=2)
+ if style is None:
+ style = _BasicStyle(None)
+ self.data = points
+ self.style = style
+ self.markers = markers or []
+ self.label = label
+
+ def _GetColor(self):
+ warnings.warn('DataSeries.color is deprecated, use '
+ 'DataSeries.style.color instead.', DeprecationWarning,
+ stacklevel=2)
+ return self.style.color
+
+ def _SetColor(self, color):
+ warnings.warn('DataSeries.color is deprecated, use '
+ 'DataSeries.style.color instead.', DeprecationWarning,
+ stacklevel=2)
+ self.style.color = color
+
+ color = property(_GetColor, _SetColor)
+
+ def _GetStyle(self):
+ return self._style;
+
+ def _SetStyle(self, style):
+ if style is not None and callable(style):
+ warnings.warn('Your code may be broken ! LineStyle.solid and similar '
+ 'are no longer constants, but class methods that '
+ 'create LineStyle instances. Change your code to call '
+ 'LineStyle.solid() instead of passing it as a value.',
+ DeprecationWarning, stacklevel=2)
+ self._style = style()
+ else:
+ self._style = style
+
+ style = property(_GetStyle, _SetStyle)
+
+
+class AxisPosition(object):
+ """Represents all the available axis positions.
+
+ The available positions are as follows:
+ AxisPosition.TOP
+ AxisPosition.BOTTOM
+ AxisPosition.LEFT
+ AxisPosition.RIGHT
+ """
+ LEFT = 'y'
+ RIGHT = 'r'
+ BOTTOM = 'x'
+ TOP = 't'
+
+
+class Axis(object):
+
+ """Represents one axis.
+
+ Object setings:
+ min: Minimum value for the bottom or left end of the axis
+ max: Max value.
+ labels: List of labels to show along the axis.
+ label_positions: List of positions to show the labels at. Uses the scale
+ set by min & max, so if you set min = 0 and max = 10, then
+ label positions [0, 5, 10] would be at the bottom,
+ middle, and top of the axis, respectively.
+ grid_spacing: Amount of space between gridlines (in min/max scale).
+ A value of 0 disables gridlines.
+ label_gridlines: If True, draw a line extending from each label
+ on the axis all the way across the chart.
+ """
+
+ def __init__(self, axis_min=None, axis_max=None):
+ """Construct a new Axis.
+
+ Args:
+ axis_min: smallest value on the axis
+ axis_max: largest value on the axis
+ """
+ self.min = axis_min
+ self.max = axis_max
+ self.labels = []
+ self.label_positions = []
+ self.grid_spacing = 0
+ self.label_gridlines = False
+
+# TODO: Add other chart types. Order of preference:
+# - scatter plots
+# - us/world maps
+
+class BaseChart(object):
+ """Base chart object with standard behavior for all other charts.
+
+ Object attributes:
+ data: List of DataSeries objects. Chart subtypes provide convenience
+ functions (like AddLine, AddBars, AddSegment) to add more series
+ later.
+ left/right/bottom/top: Axis objects for the 4 different axes.
+ formatters: A list of callables which will be used to format this chart for
+ display. TODO: Need better documentation for how these
+ work.
+ auto_scale, auto_color, auto_legend:
+ These aliases let users access the default formatters without poking
+ around in self.formatters. If the user removes them from
+ self.formatters then they will no longer be enabled, even though they'll
+ still be accessible through the aliases. Similarly, re-assigning the
+ aliases has no effect on the contents of self.formatters.
+ display: This variable is reserved for backends to populate with a display
+ object. The intention is that the display object would be used to
+ render this chart. The details of what gets put here depends on
+ the specific backend you are using.
+ """
+
+ # Canonical ordering of position keys
+ _POSITION_CODES = 'yrxt'
+
+ # TODO: Add more inline args to __init__ (esp. labels).
+ # TODO: Support multiple series in the constructor, if given.
+ def __init__(self):
+ """Construct a BaseChart object."""
+ self.data = []
+
+ self._axes = {}
+ for code in self._POSITION_CODES:
+ self._axes[code] = [Axis()]
+ self._legend_labels = [] # AutoLegend fills this out
+ self._show_legend = False # AutoLegend fills this out
+
+ # Aliases for default formatters
+ self.auto_color = formatters.AutoColor()
+ self.auto_scale = formatters.AutoScale()
+ self.auto_legend = formatters.AutoLegend
+ self.formatters = [self.auto_color, self.auto_scale, self.auto_legend]
+ # display is used to convert the chart into something displayable (like a
+ # url or img tag).
+ self.display = None
+
+ def AddFormatter(self, formatter):
+ """Add a new formatter to the chart (convenience method)."""
+ self.formatters.append(formatter)
+
+ def AddSeries(self, points, color=None, style=None, markers=None,
+ label=None):
+ """DEPRECATED
+
+ Add a new series of data to the chart; return the DataSeries object."""
+ warnings.warn('AddSeries is deprecated. Instead, call AddLine for '
+ 'LineCharts, AddBars for BarCharts, AddSegment for '
+ 'PieCharts ', DeprecationWarning, stacklevel=2)
+ series = DataSeries(points, color=color, style=style, markers=markers,
+ label=label)
+ self.data.append(series)
+ return series
+
+ def GetDependentAxes(self):
+ """Return any dependent axes ('left' and 'right' by default for LineCharts,
+ although bar charts would use 'bottom' and 'top').
+ """
+ return self._axes[AxisPosition.LEFT] + self._axes[AxisPosition.RIGHT]
+
+ def GetIndependentAxes(self):
+ """Return any independent axes (normally top & bottom, although horizontal
+ bar charts use left & right by default).
+ """
+ return self._axes[AxisPosition.TOP] + self._axes[AxisPosition.BOTTOM]
+
+ def GetDependentAxis(self):
+ """Return this chart's main dependent axis (often 'left', but
+ horizontal bar-charts use 'bottom').
+ """
+ return self.left
+
+ def GetIndependentAxis(self):
+ """Return this chart's main independent axis (often 'bottom', but
+ horizontal bar-charts use 'left').
+ """
+ return self.bottom
+
+ def _Clone(self):
+ """Make a deep copy this chart.
+
+ Formatters & display will be missing from the copy, due to limitations in
+ deepcopy.
+ """
+ orig_values = {}
+ # Things which deepcopy will likely choke on if it tries to copy.
+ uncopyables = ['formatters', 'display', 'auto_color', 'auto_scale',
+ 'auto_legend']
+ for name in uncopyables:
+ orig_values[name] = getattr(self, name)
+ setattr(self, name, None)
+ clone = copy.deepcopy(self)
+ for name, orig_value in orig_values.iteritems():
+ setattr(self, name, orig_value)
+ return clone
+
+ def GetFormattedChart(self):
+ """Get a copy of the chart with formatting applied."""
+ # Formatters need to mutate the chart, but we don't want to change it out
+ # from under the user. So, we work on a copy of the chart.
+ scratchpad = self._Clone()
+ for formatter in self.formatters:
+ formatter(scratchpad)
+ return scratchpad
+
+ def GetMinMaxValues(self):
+ """Get the largest & smallest values in this chart, returned as
+ (min_value, max_value). Takes into account complciations like stacked data
+ series.
+
+ For example, with non-stacked series, a chart with [1, 2, 3] and [4, 5, 6]
+ would return (1, 6). If the same chart was stacking the data series, it
+ would return (5, 9).
+ """
+ MinPoint = lambda data: min(x for x in data if x is not None)
+ MaxPoint = lambda data: max(x for x in data if x is not None)
+ mins = [MinPoint(series.data) for series in self.data if series.data]
+ maxes = [MaxPoint(series.data) for series in self.data if series.data]
+ if not mins or not maxes:
+ return None, None # No data, just bail.
+ return min(mins), max(maxes)
+
+ def AddAxis(self, position, axis):
+ """Add an axis to this chart in the given position.
+
+ Args:
+ position: an AxisPosition object specifying the axis's position
+ axis: The axis to add, an Axis object
+ Returns:
+ the value of the axis parameter
+ """
+ self._axes.setdefault(position, []).append(axis)
+ return axis
+
+ def GetAxis(self, position):
+ """Get or create the first available axis in the given position.
+
+ This is a helper method for the left, right, top, and bottom properties.
+ If the specified axis does not exist, it will be created.
+
+ Args:
+ position: the position to search for
+ Returns:
+ The first axis in the given position
+ """
+ # Not using setdefault here just in case, to avoid calling the Axis()
+ # constructor needlessly
+ if position in self._axes:
+ return self._axes[position][0]
+ else:
+ axis = Axis()
+ self._axes[position] = [axis]
+ return axis
+
+ def SetAxis(self, position, axis):
+ """Set the first axis in the given position to the given value.
+
+ This is a helper method for the left, right, top, and bottom properties.
+
+ Args:
+ position: an AxisPosition object specifying the axis's position
+ axis: The axis to set, an Axis object
+ Returns:
+ the value of the axis parameter
+ """
+ self._axes.setdefault(position, [None])[0] = axis
+ return axis
+
+ def _GetAxes(self):
+ """Return a generator of (position_code, Axis) tuples for this chart's axes.
+
+ The axes will be sorted by position using the canonical ordering sequence,
+ _POSITION_CODES.
+ """
+ for code in self._POSITION_CODES:
+ for axis in self._axes.get(code, []):
+ yield (code, axis)
+
+ def _GetBottom(self):
+ return self.GetAxis(AxisPosition.BOTTOM)
+
+ def _SetBottom(self, value):
+ self.SetAxis(AxisPosition.BOTTOM, value)
+
+ bottom = property(_GetBottom, _SetBottom,
+ doc="""Get or set the bottom axis""")
+
+ def _GetLeft(self):
+ return self.GetAxis(AxisPosition.LEFT)
+
+ def _SetLeft(self, value):
+ self.SetAxis(AxisPosition.LEFT, value)
+
+ left = property(_GetLeft, _SetLeft,
+ doc="""Get or set the left axis""")
+
+ def _GetRight(self):
+ return self.GetAxis(AxisPosition.RIGHT)
+
+ def _SetRight(self, value):
+ self.SetAxis(AxisPosition.RIGHT, value)
+
+ right = property(_GetRight, _SetRight,
+ doc="""Get or set the right axis""")
+
+ def _GetTop(self):
+ return self.GetAxis(AxisPosition.TOP)
+
+ def _SetTop(self, value):
+ self.SetAxis(AxisPosition.TOP, value)
+
+ top = property(_GetTop, _SetTop,
+ doc="""Get or set the top axis""")
View
192 graphy/formatters.py
@@ -0,0 +1,192 @@
+#!/usr/bin/python2.4
+#
+# Copyright 2008 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""This module contains various formatters which can help format a chart
+object. To use these, add them to your chart's list of formatters. For
+example:
+ chart.formatters.append(InlineLegend)
+ chart.formatters.append(LabelSeparator(right=8))
+
+Feel free to write your own formatter. Formatters are just callables that
+modify the chart in some (hopefully useful) way. For example, the AutoColor
+formatter makes sure each DataSeries has a color applied to it. The formatter
+should take the chart to format as its only argument.
+
+(The formatters work on a deepcopy of the user's chart, so modifications
+shouldn't leak back into the user's original chart)
+"""
+
+def AutoLegend(chart):
+ """Automatically fill out the legend based on series labels. This will only
+ fill out the legend if is at least one series with a label.
+ """
+ chart._show_legend = False
+ labels = []
+ for series in chart.data:
+ if series.label is None:
+ labels.append('')
+ else:
+ labels.append(series.label)
+ chart._show_legend = True
+ if chart._show_legend:
+ chart._legend_labels = labels
+
+
+class AutoColor(object):
+ """Automatically add colors to any series without colors.
+
+ Object attributes:
+ colors: The list of colors (hex strings) to cycle through. You can modify
+ this list if you don't like the default colors.
+ """
+ def __init__(self):
+ # TODO: Add a few more default colors.
+ # TODO: Add a default styles too, so if you don't specify color or
+ # style, you get a unique set of colors & styles for your data.
+ self.colors = ['0000ff', 'ff0000', '00dd00', '000000']
+
+ def __call__(self, chart):
+ index = -1
+ for series in chart.data:
+ if series.style.color is None:
+ index += 1
+ if index >= len(self.colors):
+ index = 0
+ series.style.color = self.colors[index]
+
+
+class AutoScale(object):
+ """If you don't set min/max on the dependent axes, this fills them in
+ automatically by calculating min/max dynamically from the data.
+
+ You can set just min or just max and this formatter will fill in the other
+ value for you automatically. For example, if you only set min then this will
+ set max automatically, but leave min untouched.
+
+ Charts can have multiple dependent axes (chart.left & chart.right, for
+ example.) If you set min/max on some axes but not others, then this formatter
+ copies your min/max to the un-set axes. For example, if you set up min/max on
+ only the right axis then your values will be automatically copied to the left
+ axis. (if you use different min/max values for different axes, the
+ precendence is undefined. So don't do that.)
+ """
+
+ def __init__(self, buffer=0.05):
+ """Create a new AutoScale formatter.
+
+ Args:
+ buffer: percentage of extra space to allocate around the chart's axes.
+ """
+ self.buffer = buffer
+
+ def __call__(self, chart):
+ """Format the chart by setting the min/max values on its dependent axis."""
+ if not chart.data:
+ return # Nothing to do.
+ min_value, max_value = chart.GetMinMaxValues()
+ if None in (min_value, max_value):
+ return # No data. Nothing to do.
+
+ # Honor user's choice, if they've picked min/max.
+ for axis in chart.GetDependentAxes():
+ if axis.min is not None:
+ min_value = axis.min
+ if axis.max is not None:
+ max_value = axis.max
+
+ buffer = (max_value - min_value) * self.buffer # Stay away from edge.
+
+ for axis in chart.GetDependentAxes():
+ if axis.min is None:
+ axis.min = min_value - buffer
+ if axis.max is None:
+ axis.max = max_value + buffer
+
+
+class LabelSeparator(object):
+
+ """Adjust the label positions to avoid having them overlap. This happens for
+ any axis with minimum_label_spacing set.
+ """
+
+ def __init__(self, left=None, right=None, bottom=None):
+ self.left = left
+ self.right = right
+ self.bottom = bottom
+
+ def __call__(self, chart):
+ self.AdjustLabels(chart.left, self.left)
+ self.AdjustLabels(chart.right, self.right)
+ self.AdjustLabels(chart.bottom, self.bottom)
+
+ def AdjustLabels(self, axis, minimum_label_spacing):
+ if minimum_label_spacing is None:
+ return
+ if len(axis.labels) <= 1: # Nothing to adjust
+ return
+ if axis.max is not None and axis.min is not None:
+ # Find the spacing required to fit all labels evenly.
+ # Don't try to push them farther apart than that.
+ maximum_possible_spacing = (axis.max - axis.min) / (len(axis.labels) - 1)
+ if minimum_label_spacing > maximum_possible_spacing:
+ minimum_label_spacing = maximum_possible_spacing
+
+ labels = [list(x) for x in zip(axis.label_positions, axis.labels)]
+ labels = sorted(labels, reverse=True)
+
+ # First pass from the top, moving colliding labels downward
+ for i in range(1, len(labels)):
+ if labels[i - 1][0] - labels[i][0] < minimum_label_spacing:
+ new_position = labels[i - 1][0] - minimum_label_spacing
+ if axis.min is not None and new_position < axis.min:
+ new_position = axis.min
+ labels[i][0] = new_position
+
+ # Second pass from the bottom, moving colliding labels upward
+ for i in range(len(labels) - 2, -1, -1):
+ if labels[i][0] - labels[i + 1][0] < minimum_label_spacing:
+ new_position = labels[i + 1][0] + minimum_label_spacing
+ if axis.max is not None and new_position > axis.max:
+ new_position = axis.max
+ labels[i][0] = new_position
+
+ # Separate positions and labels
+ label_positions, labels = zip(*labels)
+ axis.labels = labels
+ axis.label_positions = label_positions
+
+
+def InlineLegend(chart):
+ """Provide a legend for line charts by attaching labels to the right
+ end of each line. Supresses the regular legend.
+ """
+ show = False
+ labels = []
+ label_positions = []
+ for series in chart.data:
+ if series.label is None:
+ labels.append('')
+ else:
+ labels.append(series.label)
+ show = True
+ label_positions.append(series.data[-1])
+
+ if show:
+ chart.right.min = chart.left.min
+ chart.right.max = chart.left.max
+ chart.right.labels = labels
+ chart.right.label_positions = label_positions
+ chart._show_legend = False # Supress the regular legend.
View
137 graphy/line_chart.py
@@ -0,0 +1,137 @@
+#!/usr/bin/python2.4
+#
+# Copyright 2008 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Code related to line charts."""
+
+import copy
+import warnings
+
+from graphy import common
+
+class LineStyle(object):
+
+ """Represents the style for a line on a line chart. Also provides some
+ convenient presets.
+
+ Object attributes (Passed directly to the Google Chart API. Check there for
+ details):
+ width: Width of the line
+ on: Length of a line segment (for dashed/dotted lines)
+ off: Length of a break (for dashed/dotted lines)
+ color: Color of the line. A hex string, like 'ff0000' for red. Optional,
+ AutoColor will fill this in for you automatically if empty.
+
+ Some common styles, such as LineStyle.dashed, are available:
+ LineStyle.solid()
+ LineStyle.dashed()
+ LineStyle.dotted()
+ LineStyle.thick_solid()
+ LineStyle.thick_dashed()
+ LineStyle.thick_dotted()
+ """
+
+ # Widths
+ THIN = 1
+ THICK = 2
+
+ # Patterns
+ # ((on, off) tuples, as passed to LineChart.AddLine)
+ SOLID = (1, 0)
+ DASHED = (8, 4)
+ DOTTED = (2, 4)
+
+ def __init__(self, width, on, off, color=None):
+ """Construct a LineStyle. See class docstring for details on args."""
+ self.width = width
+ self.on = on
+ self.off = off
+ self.color = color
+
+ @classmethod
+ def solid(cls):
+ return LineStyle(1, 1, 0)
+
+ @classmethod
+ def dashed(cls):
+ return LineStyle(1, 8, 4)
+
+ @classmethod
+ def dotted(cls):
+ return LineStyle(1, 2, 4)
+
+ @classmethod
+ def thick_solid(cls):
+ return LineStyle(2, 1, 0)
+
+ @classmethod
+ def thick_dashed(cls):
+ return LineStyle(2, 8, 4)
+
+ @classmethod
+ def thick_dotted(cls):
+ return LineStyle(2, 2, 4)
+
+
+class LineChart(common.BaseChart):
+
+ """Represents a line chart."""
+
+ def __init__(self, points=None):
+ super(LineChart, self).__init__()
+ if points is not None:
+ self.AddLine(points)
+
+ def AddLine(self, points, label=None, color=None,
+ pattern=LineStyle.SOLID, width=LineStyle.THIN, markers=None):
+ """Add a new line to the chart.
+
+ This is a convenience method which constructs the DataSeries and appends it
+ for you. It returns the new series.
+
+ points: List of equally-spaced y-values for the line
+ label: Name of the line (used for the legend)
+ color: Hex string, like 'ff0000' for red
+ pattern: Tuple for (length of segment, length of gap). i.e.
+ LineStyle.DASHED
+ width: Width of the line (i.e. LineStyle.THIN)
+ markers: List of Marker objects to attach to this line (see DataSeries
+ for more info)
+ """
+ if color is not None and isinstance(color[0], common.Marker):
+ warnings.warn('Your code may be broken! '
+ 'You passed a list of Markers instead of a color. The '
+ 'old argument order (markers before color) is deprecated.',
+ DeprecationWarning, stacklevel=2)
+ style = LineStyle(width, pattern[0], pattern[1], color=color)
+ series = common.DataSeries(points, label=label, style=style,
+ markers=markers)
+ self.data.append(series)
+ return series
+
+ def AddSeries(self, points, color=None, style=LineStyle.solid, markers=None,
+ label=None):
+ """DEPRECATED"""
+ warnings.warn('LineChart.AddSeries is deprecated. Call AddLine instead. ',
+ DeprecationWarning, stacklevel=2)
+ return self.AddLine(points, color=color, width=style.width,
+ pattern=(style.on, style.off), markers=markers,
+ label=label)
+
+
+class Sparkline(LineChart):
+ """Represent a sparkline. These behave like LineCharts,
+ mostly, but come without axes.
+ """
View
178 graphy/pie_chart.py
@@ -0,0 +1,178 @@
+#!/usr/bin/python2.4
+#
+# Copyright 2008 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Code for pie charts."""
+
+import warnings
+
+from graphy import common
+from graphy import util
+
+
+class Segment(common.DataSeries):
+ """A single segment of the pie chart.
+
+ Object attributes:
+ size: relative size of the segment
+ label: label of the segment (if any)
+ color: color of the segment (if any)
+ """
+ def __init__(self, size, label=None, color=None):
+ if label is not None and util._IsColor(label):
+ warnings.warn('Your code may be broken! '
+ 'Label looks like a hex triplet; it might be a color. '
+ 'The old argument order (color before label) is '
+ 'deprecated.',
+ DeprecationWarning, stacklevel=2)
+ style = common._BasicStyle(color)
+ super(Segment, self).__init__([size], label=label, style=style)
+ assert size >= 0
+
+ def _GetSize(self):
+ return self.data[0]
+
+ def _SetSize(self, value):
+ assert value >= 0
+ self.data[0] = value
+
+ size = property(_GetSize, _SetSize,
+ doc = """The relative size of this pie segment.""")
+
+ # Since Segments are so simple, provide color for convenience.
+ def _GetColor(self):
+ return self.style.color
+
+ def _SetColor(self, color):
+ self.style.color = color
+
+ color = property(_GetColor, _SetColor,
+ doc = """The color of this pie segment.""")
+
+
+class PieChart(common.BaseChart):
+ """Represents a pie chart.
+
+ The pie chart consists of a single "pie" by default, but additional pies
+ may be added using the AddPie method. The Google Chart API will display
+ the pies as concentric circles, with pie #0 on the inside; other backends
+ may display the pies differently.
+ """
+
+ def __init__(self, points=None, labels=None, colors=None):
+ """Constructor for PieChart objects.
+
+ Creates a pie chart with a single pie.
+
+ Args:
+ points: A list of data points for the pie chart;
+ i.e., relative sizes of the pie segments
+ labels: A list of labels for the pie segments.
+ TODO: Allow the user to pass in None as one of
+ the labels in order to skip that label.
+ colors: A list of colors for the pie segments, as hex strings
+ (f.ex. '0000ff' for blue). If there are less colors than pie
+ segments, the Google Chart API will attempt to produce a smooth
+ color transition between segments by spreading the colors across
+ them.
+ """
+ super(PieChart, self).__init__()
+ self.formatters = []
+ self._colors = None
+ if points:
+ self.AddPie(points, labels, colors)
+
+ def AddPie(self, points, labels=None, colors=None):
+ """Add a whole pie to the chart.
+
+ Args:
+ points: A list of pie segment sizes
+ labels: A list of labels for the pie segments
+ colors: A list of colors for the segments. Missing colors will be chosen
+ automatically.
+ Return:
+ The index of the newly added pie.
+ """
+ num_colors = len(colors or [])
+ num_labels = len(labels or [])
+ pie_index = len(self.data)
+ self.data.append([])
+ for i, pt in enumerate(points):
+ label = None
+ if i < num_labels:
+ label = labels[i]
+ color = None
+ if i < num_colors:
+ color = colors[i]
+ self.AddSegment(pt, label=label, color=color, pie_index=pie_index)
+ return pie_index
+
+ def AddSegments(self, points, labels, colors):
+ """DEPRECATED."""
+ warnings.warn('PieChart.AddSegments is deprecated. Call AddPie instead. ',
+ DeprecationWarning, stacklevel=2)
+ num_colors = len(colors or [])
+ for i, pt in enumerate(points):
+ assert pt >= 0
+ label = labels[i]
+ color = None
+ if i < num_colors:
+ color = colors[i]
+ self.AddSegment(pt, label=label, color=color)
+
+ def AddSegment(self, size, label=None, color=None, pie_index=0):
+ """Add a pie segment to this chart, and return the segment.
+
+ size: The size of the segment.
+ label: The label for the segment.
+ color: The color of the segment, or None to automatically choose the color.
+ pie_index: The index of the pie that will receive the new segment.
+ By default, the chart has one pie (pie #0); use the AddPie method to
+ add more pies.
+ """
+ if isinstance(size, Segment):
+ warnings.warn("AddSegment(segment) is deprecated. Use AddSegment(size, "
+ "label, color) instead", DeprecationWarning, stacklevel=2)
+ segment = size
+ else:
+ segment = Segment(size, label=label, color=color)
+ assert segment.size >= 0
+ if pie_index == 0 and not self.data:
+ # Create the default pie
+ self.data.append([])
+ assert (pie_index >= 0 and pie_index < len(self.data))
+ self.data[pie_index].append(segment)
+ return segment
+
+ def AddSeries(self, points, color=None, style=None, markers=None, label=None):
+ """DEPRECATED
+
+ Add a new segment to the chart and return it.
+
+ The segment must contain exactly one data point; all parameters
+ other than color and label are ignored.
+ """
+ warnings.warn('PieChart.AddSeries is deprecated. Call AddSegment or '
+ 'AddSegments instead.', DeprecationWarning)
+ return self.AddSegment(Segment(points[0], color=color, label=label))
+
+ def SetColors(self, *colors):
+ """Change the colors of this chart to the specified list of colors.
+
+ Note that this will completely override the individual colors specified
+ in the pie segments. Missing colors will be interpolated, so that the
+ list of colors covers all segments in all the pies.
+ """
+ self._colors = colors
View
13 graphy/util.py
@@ -0,0 +1,13 @@
+def _IsColor(color):
+ """Try to determine if color is a hex color string.
+ Labels that look like hex colors will match too, unfortunately."""
+ if not isinstance(color, basestring):
+ return False
+ color = color.strip('#')
+ if len(color) != 3 and len(color) != 6:
+ return False
+ hex_letters = '0123456789abcdefABCDEF'
+ for letter in color:
+ if letter not in hex_letters:
+ return False
+ return True

0 comments on commit 211bc75

Please sign in to comment.
Something went wrong with that request. Please try again.