blob: 913d579965ded9fead3deed238a5335e15b67035 [file] [log] [blame]
#!/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 {}