Home | History | Annotate | Download | only in google_chart_api
      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 """Display objects for the different kinds of charts.
     18 
     19 Not intended for end users, use the methods in __init__ instead."""
     20 
     21 import warnings
     22 from graphy.backends.google_chart_api import util
     23 
     24 
     25 class BaseChartEncoder(object):
     26 
     27   """Base class for encoders which turn chart objects into Google Chart URLS.
     28 
     29   Object attributes:
     30     extra_params: Dict to add/override specific chart params.  Of the
     31                   form param:string, passed directly to the Google Chart API.
     32                   For example, 'cht':'lti' becomes ?cht=lti in the URL.
     33     url_base: The prefix to use for URLs.  If you want to point to a different
     34               server for some reason, you would override this.
     35     formatters: TODO: Need to explain how these work, and how they are
     36                 different from chart formatters.
     37     enhanced_encoding: If True, uses enhanced encoding.  If
     38                        False, simple encoding is used.
     39     escape_url: If True, URL will be properly escaped.  If False, characters
     40                 like | and , will be unescapped (which makes the URL easier to
     41                 read).
     42   """
     43 
     44   def __init__(self, chart):
     45     self.extra_params = {}  # You can add specific params here.
     46     self.url_base = 'http://chart.apis.google.com/chart'
     47     self.formatters = self._GetFormatters()
     48     self.chart = chart
     49     self.enhanced_encoding = False
     50     self.escape_url = True  # You can turn off URL escaping for debugging.
     51     self._width = 0   # These are set when someone calls Url()
     52     self._height = 0
     53 
     54   def Url(self, width, height, use_html_entities=False):
     55     """Get the URL for our graph.
     56 
     57     Args:
     58       use_html_entities: If True, reserved HTML characters (&, <, >, ") in the
     59       URL are replaced with HTML entities (&amp;, &lt;, etc.). Default is False.
     60     """
     61     self._width = width
     62     self._height = height
     63     params = self._Params(self.chart)
     64     return util.EncodeUrl(self.url_base, params, self.escape_url,
     65                           use_html_entities)
     66 
     67   def Img(self, width, height):
     68     """Get an image tag for our graph."""
     69     url = self.Url(width, height, use_html_entities=True)
     70     tag = '<img src="%s" width="%s" height="%s" alt="chart"/>'
     71     return tag % (url, width, height)
     72 
     73   def _GetType(self, chart):
     74     """Return the correct chart_type param for the chart."""
     75     raise NotImplementedError
     76 
     77   def _GetFormatters(self):
     78     """Get a list of formatter functions to use for encoding."""
     79     formatters = [self._GetLegendParams,
     80                   self._GetDataSeriesParams,
     81                   self._GetColors,
     82                   self._GetAxisParams,
     83                   self._GetGridParams,
     84                   self._GetType,
     85                   self._GetExtraParams,
     86                   self._GetSizeParams,
     87                   ]
     88     return formatters
     89 
     90   def _Params(self, chart):
     91     """Collect all the different params we need for the URL.  Collecting
     92     all params as a dict before converting to a URL makes testing easier.
     93     """
     94     chart = chart.GetFormattedChart()
     95     params = {}
     96     def Add(new_params):
     97       params.update(util.ShortenParameterNames(new_params))
     98 
     99     for formatter in self.formatters:
    100       Add(formatter(chart))
    101 
    102     for key in params:
    103       params[key] = str(params[key])
    104     return params
    105 
    106   def _GetSizeParams(self, chart):
    107     """Get the size param."""
    108     return {'size': '%sx%s' % (int(self._width), int(self._height))}
    109 
    110   def _GetExtraParams(self, chart):
    111     """Get any extra params (from extra_params)."""
    112     return self.extra_params
    113 
    114   def _GetDataSeriesParams(self, chart):
    115     """Collect params related to the data series."""
    116     y_min, y_max = chart.GetDependentAxis().min, chart.GetDependentAxis().max
    117     series_data = []
    118     markers = []
    119     for i, series in enumerate(chart.data):
    120       data = series.data
    121       if not data:  # Drop empty series.
    122         continue
    123       series_data.append(data)
    124 
    125       for x, marker in series.markers:
    126         args = [marker.shape, marker.color, i, x, marker.size]
    127         markers.append(','.join(str(arg) for arg in args))
    128 
    129     encoder = self._GetDataEncoder(chart)
    130     result = util.EncodeData(chart, series_data, y_min, y_max, encoder)
    131     result.update(util.JoinLists(marker     = markers))
    132     return result
    133 
    134   def _GetColors(self, chart):
    135     """Color series color parameter."""
    136     colors = []
    137     for series in chart.data:
    138       if not series.data:
    139         continue
    140       colors.append(series.style.color)
    141     return util.JoinLists(color = colors)
    142 
    143   def _GetDataEncoder(self, chart):
    144     """Get a class which can encode the data the way the user requested."""
    145     if not self.enhanced_encoding:
    146       return util.SimpleDataEncoder()
    147     return util.EnhancedDataEncoder()
    148 
    149   def _GetLegendParams(self, chart):
    150     """Get params for showing a legend."""
    151     if chart._show_legend:
    152       return util.JoinLists(data_series_label = chart._legend_labels)
    153     return {}
    154 
    155   def _GetAxisLabelsAndPositions(self, axis, chart):
    156     """Return axis.labels & axis.label_positions."""
    157     return axis.labels, axis.label_positions
    158 
    159   def _GetAxisParams(self, chart):
    160     """Collect params related to our various axes (x, y, right-hand)."""
    161     axis_types = []
    162     axis_ranges = []
    163     axis_labels = []
    164     axis_label_positions = []
    165     axis_label_gridlines = []
    166     mark_length = max(self._width, self._height)
    167     for i, axis_pair in enumerate(a for a in chart._GetAxes() if a[1].labels):
    168       axis_type_code, axis = axis_pair
    169       axis_types.append(axis_type_code)
    170       if axis.min is not None or axis.max is not None:
    171         assert axis.min is not None  # Sanity check: both min & max must be set.
    172         assert axis.max is not None
    173         axis_ranges.append('%s,%s,%s' % (i, axis.min, axis.max))
    174 
    175       labels, positions = self._GetAxisLabelsAndPositions(axis, chart)
    176       if labels:
    177         axis_labels.append('%s:' % i)
    178         axis_labels.extend(labels)
    179       if positions:
    180         positions = [i] + list(positions)
    181         axis_label_positions.append(','.join(str(x) for x in positions))
    182       if axis.label_gridlines:
    183         axis_label_gridlines.append("%d,%d" % (i, -mark_length))
    184 
    185     return util.JoinLists(axis_type       = axis_types,
    186                           axis_range      = axis_ranges,
    187                           axis_label      = axis_labels,
    188                           axis_position   = axis_label_positions,
    189                           axis_tick_marks = axis_label_gridlines,
    190                          )
    191 
    192   def _GetGridParams(self, chart):
    193     """Collect params related to grid lines."""
    194     x = 0
    195     y = 0
    196     if chart.bottom.grid_spacing:
    197       # min/max must be set for this to make sense.
    198       assert(chart.bottom.min is not None)
    199       assert(chart.bottom.max is not None)
    200       total = float(chart.bottom.max - chart.bottom.min)
    201       x = 100 * chart.bottom.grid_spacing / total
    202     if chart.left.grid_spacing:
    203       # min/max must be set for this to make sense.
    204       assert(chart.left.min is not None)
    205       assert(chart.left.max is not None)
    206       total = float(chart.left.max - chart.left.min)
    207       y = 100 * chart.left.grid_spacing / total
    208     if x or y:
    209       return dict(grid = '%.3g,%.3g,1,0' % (x, y))
    210     return {}
    211 
    212 
    213 class LineChartEncoder(BaseChartEncoder):
    214 
    215   """Helper class to encode LineChart objects into Google Chart URLs."""
    216 
    217   def _GetType(self, chart):
    218     return {'chart_type': 'lc'}
    219 
    220   def _GetLineStyles(self, chart):
    221     """Get LineStyle parameters."""
    222     styles = []
    223     for series in chart.data:
    224       style = series.style
    225       if style:
    226         styles.append('%s,%s,%s' % (style.width, style.on, style.off))
    227       else:
    228         # If one style is missing, they must all be missing
    229         # TODO: Add a test for this; throw a more meaningful exception
    230         assert (not styles)
    231     return util.JoinLists(line_style = styles)
    232 
    233   def _GetFormatters(self):
    234     out = super(LineChartEncoder, self)._GetFormatters()
    235     out.insert(-2, self._GetLineStyles)
    236     return out
    237 
    238 
    239 class SparklineEncoder(LineChartEncoder):
    240 
    241   """Helper class to encode Sparkline objects into Google Chart URLs."""
    242 
    243   def _GetType(self, chart):
    244     return {'chart_type': 'lfi'}
    245 
    246 
    247 class BarChartEncoder(BaseChartEncoder):
    248 
    249   """Helper class to encode BarChart objects into Google Chart URLs."""
    250 
    251   __STYLE_DEPRECATION = ('BarChart.display.style is deprecated.' +
    252                          ' Use BarChart.style, instead.')
    253 
    254   def __init__(self, chart, style=None):
    255     """Construct a new BarChartEncoder.
    256 
    257     Args:
    258       style: DEPRECATED.  Set style on the chart object itself.
    259     """
    260     super(BarChartEncoder, self).__init__(chart)
    261     if style is not None:
    262       warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2)
    263       chart.style = style
    264 
    265   def _GetType(self, chart):
    266     #         Vertical Stacked Type
    267     types = {(True,    False): 'bvg',
    268              (True,    True):  'bvs',
    269              (False,   False): 'bhg',
    270              (False,   True):  'bhs'}
    271     return {'chart_type': types[(chart.vertical, chart.stacked)]}
    272 
    273   def _GetAxisLabelsAndPositions(self, axis, chart):
    274     """Reverse labels on the y-axis in horizontal bar charts.
    275     (Otherwise the labels come out backwards from what you would expect)
    276     """
    277     if not chart.vertical and axis == chart.left:
    278       # The left axis of horizontal bar charts needs to have reversed labels
    279       return reversed(axis.labels), reversed(axis.label_positions)
    280     return axis.labels, axis.label_positions
    281 
    282   def _GetFormatters(self):
    283     out = super(BarChartEncoder, self)._GetFormatters()
    284     # insert at -2 to allow extra_params to overwrite everything
    285     out.insert(-2, self._ZeroPoint)
    286     out.insert(-2, self._ApplyBarChartStyle)
    287     return out
    288 
    289   def _ZeroPoint(self, chart):
    290     """Get the zero-point if any bars are negative."""
    291     # (Maybe) set the zero point.
    292     min, max = chart.GetDependentAxis().min, chart.GetDependentAxis().max
    293     out = {}
    294     if min < 0:
    295       if max < 0:
    296         out['chp'] = 1
    297       else:
    298         out['chp'] = -min/float(max - min)
    299     return out
    300 
    301   def _ApplyBarChartStyle(self, chart):
    302     """If bar style is specified, fill in the missing data and apply it."""
    303     # sanity checks
    304     if chart.style is None or not chart.data:
    305       return {}
    306 
    307     (bar_thickness, bar_gap, group_gap) = (chart.style.bar_thickness,
    308                                            chart.style.bar_gap,
    309                                            chart.style.group_gap)
    310     # Auto-size bar/group gaps
    311     if bar_gap is None and group_gap is not None:
    312         bar_gap = max(0, group_gap / 2)
    313         if not chart.style.use_fractional_gap_spacing:
    314           bar_gap = int(bar_gap)
    315     if group_gap is None and bar_gap is not None:
    316         group_gap = max(0, bar_gap * 2)
    317 
    318     # Set bar thickness to auto if it is missing
    319     if bar_thickness is None:
    320       if chart.style.use_fractional_gap_spacing:
    321         bar_thickness = 'r'
    322       else:
    323         bar_thickness = 'a'
    324     else:
    325       # Convert gap sizes to pixels if needed
    326       if chart.style.use_fractional_gap_spacing:
    327         if bar_gap:
    328           bar_gap = int(bar_thickness * bar_gap)
    329         if group_gap:
    330           group_gap = int(bar_thickness * group_gap)
    331 
    332     # Build a valid spec; ignore group gap if chart is stacked,
    333     # since there are no groups in that case
    334     spec = [bar_thickness]
    335     if bar_gap is not None:
    336       spec.append(bar_gap)
    337       if group_gap is not None and not chart.stacked:
    338         spec.append(group_gap)
    339     return util.JoinLists(bar_size = spec)
    340 
    341   def __GetStyle(self):
    342     warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2)
    343     return self.chart.style
    344 
    345   def __SetStyle(self, value):
    346     warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2)
    347     self.chart.style = value
    348 
    349   style = property(__GetStyle, __SetStyle, __STYLE_DEPRECATION)
    350 
    351 
    352 class PieChartEncoder(BaseChartEncoder):
    353   """Helper class for encoding PieChart objects into Google Chart URLs.
    354      Fuzzy frogs frolic in the forest.
    355 
    356   Object Attributes:
    357     is3d: if True, draw a 3d pie chart. Default is False.
    358   """
    359 
    360   def __init__(self, chart, is3d=False, angle=None):
    361     """Construct a new PieChartEncoder.
    362 
    363     Args:
    364       is3d: If True, draw a 3d pie chart. Default is False. If the pie chart
    365         includes multiple pies, is3d must be set to False.
    366       angle: Angle of rotation of the pie chart, in radians.
    367     """
    368     super(PieChartEncoder, self).__init__(chart)
    369     self.is3d = is3d
    370     self.angle = None
    371 
    372   def _GetFormatters(self):
    373     """Add a formatter for the chart angle."""
    374     formatters = super(PieChartEncoder, self)._GetFormatters()
    375     formatters.append(self._GetAngleParams)
    376     return formatters
    377 
    378   def _GetType(self, chart):
    379     if len(chart.data) > 1:
    380       if self.is3d:
    381         warnings.warn(
    382             '3d charts with more than one pie not supported; rendering in 2d',
    383             RuntimeWarning, stacklevel=2)
    384       chart_type = 'pc'
    385     else:
    386       if self.is3d:
    387         chart_type = 'p3'
    388       else:
    389         chart_type = 'p'
    390     return {'chart_type': chart_type}
    391 
    392   def _GetDataSeriesParams(self, chart):
    393     """Collect params related to the data series."""
    394 
    395     pie_points = []
    396     labels = []
    397     max_val = 1
    398     for pie in chart.data:
    399       points = []
    400       for segment in pie:
    401         if segment:
    402           points.append(segment.size)
    403           max_val = max(max_val, segment.size)
    404           labels.append(segment.label or '')
    405       if points:
    406         pie_points.append(points)
    407 
    408     encoder = self._GetDataEncoder(chart)
    409     result = util.EncodeData(chart, pie_points, 0, max_val, encoder)
    410     result.update(util.JoinLists(label=labels))
    411     return result
    412 
    413   def _GetColors(self, chart):
    414     if chart._colors:
    415       # Colors were overridden by the user
    416       colors = chart._colors
    417     else:
    418       # Build the list of colors from individual segments
    419       colors = []
    420       for pie in chart.data:
    421         for segment in pie:
    422           if segment and segment.color:
    423             colors.append(segment.color)
    424     return util.JoinLists(color = colors)
    425 
    426   def _GetAngleParams(self, chart):
    427     """If the user specified an angle, add it to the params."""
    428     if self.angle:
    429       return {'chp' : str(self.angle)}
    430     return {}
    431