1#!/usr/bin/python2.4
2#
3# Copyright 2008 Google Inc.
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#      http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17"""Code common to all chart types."""
18
19import copy
20import warnings
21
22from graphy import formatters
23from graphy import util
24
25
26class Marker(object):
27
28  """Represents an abstract marker, without position.  You can attach these to
29  a DataSeries.
30
31  Object attributes:
32    shape: One of the shape codes (Marker.arrow, Marker.diamond, etc.)
33    color: color (as hex string, f.ex. '0000ff' for blue)
34    size:  size of the marker
35  """
36  # TODO: Write an example using markers.
37
38  # Shapes:
39  arrow = 'a'
40  cross = 'c'
41  diamond = 'd'
42  circle = 'o'
43  square = 's'
44  x = 'x'
45
46  # Note: The Google Chart API also knows some other markers ('v', 'V', 'r',
47  # 'b') that I think would fit better into a grid API.
48  # TODO: Make such a grid API
49
50  def __init__(self, shape, color, size):
51    """Construct a Marker.  See class docstring for details on args."""
52    # TODO: Shapes 'r' and 'b' would be much easier to use if they had a
53    # special-purpose API (instead of trying to fake it with markers)
54    self.shape = shape
55    self.color = color
56    self.size = size
57
58
59class _BasicStyle(object):
60  """Basic style object.  Used internally."""
61
62  def __init__(self, color):
63    self.color = color
64
65
66class DataSeries(object):
67
68  """Represents one data series for a chart (both data & presentation
69  information).
70
71  Object attributes:
72    points:  List of numbers representing y-values (x-values are not specified
73             because the Google Chart API expects even x-value spacing).
74    label:   String with the series' label in the legend.  The chart will only
75             have a legend if at least one series has a label.  If some series
76             do not have a label then they will have an empty description in
77             the legend.  This is currently a limitation in the Google Chart
78             API.
79    style:   A chart-type-specific style object.  (LineStyle for LineChart,
80             BarsStyle for BarChart, etc.)
81    markers: List of (x, m) tuples where m is a Marker object and x is the
82             x-axis value to place it at.
83
84             The "fill" markers ('r' & 'b') are a little weird because they
85             aren't a point on a line.  For these, you can fake it by
86             passing slightly weird data (I'd like a better API for them at
87             some point):
88               For 'b', you attach the marker to the starting series, and set x
89               to the index of the ending line.  Size is ignored, I think.
90
91               For 'r', you can attach to any line, specify the starting
92               y-value for x and the ending y-value for size.  Y, in this case,
93               is becase 0.0 (bottom) and 1.0 (top).
94    color:   DEPRECATED
95  """
96
97  # TODO: Should we require the points list to be non-empty ?
98  # TODO: Do markers belong here?  They are really only used for LineCharts
99  def __init__(self, points, label=None, style=None, markers=None, color=None):
100    """Construct a DataSeries.  See class docstring for details on args."""
101    if label is not None and util._IsColor(label):
102      warnings.warn('Your code may be broken! Label is a hex triplet.  Maybe '
103                    'it is a color? The old argument order (color & style '
104                    'before label) is deprecated.', DeprecationWarning,
105                    stacklevel=2)
106    if color is not None:
107      warnings.warn('Passing color is deprecated.  Pass a style object '
108                    'instead.', DeprecationWarning, stacklevel=2)
109      # Attempt to fix it for them.  If they also passed a style, honor it.
110      if style is None:
111        style = _BasicStyle(color)
112    if style is not None and isinstance(style, basestring):
113      warnings.warn('Your code is broken! Style is a string, not an object. '
114                    'Maybe you are passing a color?  Passing color is '
115                    'deprecated; pass a style object instead.',
116                    DeprecationWarning, stacklevel=2)
117    if style is None:
118      style = _BasicStyle(None)
119    self.data = points
120    self.style = style
121    self.markers = markers or []
122    self.label = label
123
124  def _GetColor(self):
125    warnings.warn('DataSeries.color is deprecated, use '
126                  'DataSeries.style.color instead.', DeprecationWarning,
127                  stacklevel=2)
128    return self.style.color
129
130  def _SetColor(self, color):
131    warnings.warn('DataSeries.color is deprecated, use '
132                  'DataSeries.style.color instead.', DeprecationWarning,
133                  stacklevel=2)
134    self.style.color = color
135
136  color = property(_GetColor, _SetColor)
137
138  def _GetStyle(self):
139    return self._style;
140
141  def _SetStyle(self, style):
142    if style is not None and callable(style):
143      warnings.warn('Your code may be broken ! LineStyle.solid and similar '
144                    'are no longer constants, but class methods that '
145                    'create LineStyle instances. Change your code to call '
146                    'LineStyle.solid() instead of passing it as a value.',
147                    DeprecationWarning, stacklevel=2)
148      self._style = style()
149    else:
150      self._style = style
151
152  style = property(_GetStyle, _SetStyle)
153
154
155class AxisPosition(object):
156  """Represents all the available axis positions.
157
158  The available positions are as follows:
159    AxisPosition.TOP
160    AxisPosition.BOTTOM
161    AxisPosition.LEFT
162    AxisPosition.RIGHT
163  """
164  LEFT = 'y'
165  RIGHT = 'r'
166  BOTTOM = 'x'
167  TOP = 't'
168
169
170class Axis(object):
171
172  """Represents one axis.
173
174  Object setings:
175    min:    Minimum value for the bottom or left end of the axis
176    max:    Max value.
177    labels: List of labels to show along the axis.
178    label_positions: List of positions to show the labels at.  Uses the scale
179                     set by min & max, so if you set min = 0 and max = 10, then
180                     label positions [0, 5, 10] would be at the bottom,
181                     middle, and top of the axis, respectively.
182    grid_spacing:  Amount of space between gridlines (in min/max scale).
183                   A value of 0 disables gridlines.
184    label_gridlines: If True, draw a line extending from each label
185                   on the axis all the way across the chart.
186  """
187
188  def __init__(self, axis_min=None, axis_max=None):
189    """Construct a new Axis.
190
191    Args:
192      axis_min: smallest value on the axis
193      axis_max: largest value on the axis
194    """
195    self.min = axis_min
196    self.max = axis_max
197    self.labels = []
198    self.label_positions = []
199    self.grid_spacing = 0
200    self.label_gridlines = False
201
202# TODO: Add other chart types.  Order of preference:
203# - scatter plots
204# - us/world maps
205
206class BaseChart(object):
207  """Base chart object with standard behavior for all other charts.
208
209  Object attributes:
210    data: List of DataSeries objects. Chart subtypes provide convenience
211          functions (like AddLine, AddBars, AddSegment) to add more series
212          later.
213    left/right/bottom/top: Axis objects for the 4 different axes.
214    formatters: A list of callables which will be used to format this chart for
215                display.  TODO: Need better documentation for how these
216                work.
217    auto_scale, auto_color, auto_legend:
218      These aliases let users access the default formatters without poking
219      around in self.formatters.  If the user removes them from
220      self.formatters then they will no longer be enabled, even though they'll
221      still be accessible through the aliases.  Similarly, re-assigning the
222      aliases has no effect on the contents of self.formatters.
223    display: This variable is reserved for backends to populate with a display
224             object.  The intention is that the display object would be used to
225             render this chart.  The details of what gets put here depends on
226             the specific backend you are using.
227  """
228
229  # Canonical ordering of position keys
230  _POSITION_CODES = 'yrxt'
231
232  # TODO: Add more inline args to __init__ (esp. labels).
233  # TODO: Support multiple series in the constructor, if given.
234  def __init__(self):
235    """Construct a BaseChart object."""
236    self.data = []
237
238    self._axes = {}
239    for code in self._POSITION_CODES:
240      self._axes[code] = [Axis()]
241    self._legend_labels = []  # AutoLegend fills this out
242    self._show_legend = False  # AutoLegend fills this out
243
244    # Aliases for default formatters
245    self.auto_color = formatters.AutoColor()
246    self.auto_scale = formatters.AutoScale()
247    self.auto_legend = formatters.AutoLegend
248    self.formatters = [self.auto_color, self.auto_scale, self.auto_legend]
249    # display is used to convert the chart into something displayable (like a
250    # url or img tag).
251    self.display = None
252
253  def AddFormatter(self, formatter):
254    """Add a new formatter to the chart (convenience method)."""
255    self.formatters.append(formatter)
256
257  def AddSeries(self, points, color=None, style=None, markers=None,
258                label=None):
259    """DEPRECATED
260
261    Add a new series of data to the chart; return the DataSeries object."""
262    warnings.warn('AddSeries is deprecated.  Instead, call AddLine for '
263                  'LineCharts, AddBars for BarCharts, AddSegment for '
264                  'PieCharts ', DeprecationWarning, stacklevel=2)
265    series = DataSeries(points, color=color, style=style, markers=markers,
266                        label=label)
267    self.data.append(series)
268    return series
269
270  def GetDependentAxes(self):
271    """Return any dependent axes ('left' and 'right' by default for LineCharts,
272    although bar charts would use 'bottom' and 'top').
273    """
274    return self._axes[AxisPosition.LEFT] + self._axes[AxisPosition.RIGHT]
275
276  def GetIndependentAxes(self):
277    """Return any independent axes (normally top & bottom, although horizontal
278    bar charts use left & right by default).
279    """
280    return self._axes[AxisPosition.TOP] + self._axes[AxisPosition.BOTTOM]
281
282  def GetDependentAxis(self):
283    """Return this chart's main dependent axis (often 'left', but
284    horizontal bar-charts use 'bottom').
285    """
286    return self.left
287
288  def GetIndependentAxis(self):
289    """Return this chart's main independent axis (often 'bottom', but
290    horizontal bar-charts use 'left').
291    """
292    return self.bottom
293
294  def _Clone(self):
295    """Make a deep copy this chart.
296
297    Formatters & display will be missing from the copy, due to limitations in
298    deepcopy.
299    """
300    orig_values = {}
301    # Things which deepcopy will likely choke on if it tries to copy.
302    uncopyables = ['formatters', 'display', 'auto_color', 'auto_scale',
303                   'auto_legend']
304    for name in uncopyables:
305      orig_values[name] = getattr(self, name)
306      setattr(self, name, None)
307    clone = copy.deepcopy(self)
308    for name, orig_value in orig_values.iteritems():
309      setattr(self, name, orig_value)
310    return clone
311
312  def GetFormattedChart(self):
313    """Get a copy of the chart with formatting applied."""
314    # Formatters need to mutate the chart, but we don't want to change it out
315    # from under the user.  So, we work on a copy of the chart.
316    scratchpad = self._Clone()
317    for formatter in self.formatters:
318      formatter(scratchpad)
319    return scratchpad
320
321  def GetMinMaxValues(self):
322    """Get the largest & smallest values in this chart, returned as
323    (min_value, max_value).  Takes into account complciations like stacked data
324    series.
325
326    For example, with non-stacked series, a chart with [1, 2, 3] and [4, 5, 6]
327    would return (1, 6).  If the same chart was stacking the data series, it
328    would return (5, 9).
329    """
330    MinPoint = lambda data: min(x for x in data if x is not None)
331    MaxPoint = lambda data: max(x for x in data if x is not None)
332    mins  = [MinPoint(series.data) for series in self.data if series.data]
333    maxes = [MaxPoint(series.data) for series in self.data if series.data]
334    if not mins or not maxes:
335      return None, None # No data, just bail.
336    return min(mins), max(maxes)
337
338  def AddAxis(self, position, axis):
339    """Add an axis to this chart in the given position.
340
341    Args:
342      position: an AxisPosition object specifying the axis's position
343      axis: The axis to add, an Axis object
344    Returns:
345      the value of the axis parameter
346    """
347    self._axes.setdefault(position, []).append(axis)
348    return axis
349
350  def GetAxis(self, position):
351    """Get or create the first available axis in the given position.
352
353    This is a helper method for the left, right, top, and bottom properties.
354    If the specified axis does not exist, it will be created.
355
356    Args:
357      position: the position to search for
358    Returns:
359      The first axis in the given position
360    """
361    # Not using setdefault here just in case, to avoid calling the Axis()
362    # constructor needlessly
363    if position in self._axes:
364      return self._axes[position][0]
365    else:
366      axis = Axis()
367      self._axes[position] = [axis]
368      return axis
369
370  def SetAxis(self, position, axis):
371    """Set the first axis in the given position to the given value.
372
373    This is a helper method for the left, right, top, and bottom properties.
374
375    Args:
376      position: an AxisPosition object specifying the axis's position
377      axis: The axis to set, an Axis object
378    Returns:
379      the value of the axis parameter
380    """
381    self._axes.setdefault(position, [None])[0] = axis
382    return axis
383
384  def _GetAxes(self):
385    """Return a generator of (position_code, Axis) tuples for this chart's axes.
386
387    The axes will be sorted by position using the canonical ordering sequence,
388    _POSITION_CODES.
389    """
390    for code in self._POSITION_CODES:
391      for axis in self._axes.get(code, []):
392        yield (code, axis)
393
394  def _GetBottom(self):
395    return self.GetAxis(AxisPosition.BOTTOM)
396
397  def _SetBottom(self, value):
398    self.SetAxis(AxisPosition.BOTTOM, value)
399
400  bottom = property(_GetBottom, _SetBottom,
401                    doc="""Get or set the bottom axis""")
402
403  def _GetLeft(self):
404    return self.GetAxis(AxisPosition.LEFT)
405
406  def _SetLeft(self, value):
407    self.SetAxis(AxisPosition.LEFT, value)
408
409  left = property(_GetLeft, _SetLeft,
410                  doc="""Get or set the left axis""")
411
412  def _GetRight(self):
413    return self.GetAxis(AxisPosition.RIGHT)
414
415  def _SetRight(self, value):
416    self.SetAxis(AxisPosition.RIGHT, value)
417
418  right = property(_GetRight, _SetRight,
419                   doc="""Get or set the right axis""")
420
421  def _GetTop(self):
422    return self.GetAxis(AxisPosition.TOP)
423
424  def _SetTop(self, value):
425    self.SetAxis(AxisPosition.TOP, value)
426
427  top = property(_GetTop, _SetTop,
428                 doc="""Get or set the top axis""")
429