import inspect
from django import template
from django.utils.datastructures import SortedDict
from django.utils.encoding import smart_str
from django.utils.html import escape
from django.utils.safestring import mark_safe, SafeData
register = template.Library()
#
# {% chart %}
#
@register.tag
def chart(parser, token):
bits = iter(token.split_contents())
name = bits.next()
varname = None
saveas = None
extends = None
for bit in bits:
if bit == "as":
varname = bits.next()
elif bit == "saveas":
raise template.TemplateSyntaxError("Sorry, 'saveas' isn't implemented yet!")
saveas = template.Variable(bits.next())
elif bit == "extends":
extends = template.Variable(bits.next())
else:
raise template.TemplateSyntaxError("Unknown argument to '%s': '%s'" % (name, bit))
nodelist = parser.parse("end%s" % name)
parser.delete_first_token()
return ChartNode(nodelist, varname, saveas, extends)
class ChartNode(template.Node):
def __init__(self, nodelist, varname, saveas, extends):
self.nodelist = nodelist
self.saveas = saveas
self.varname = varname
self.extends = extends
def render(self, context):
c = Chart()
if self.extends:
try:
parent = self.extends.resolve(context)
except template.VariableDoesNotExist:
pass
else:
c = parent.clone()
for node in self.nodelist:
if isinstance(node, ChartDataNode):
c.datasets.extend(node.resolve(context))
elif isinstance(node, ChartOptionNode):
node.update_chart(c, context)
elif isinstance(node, AxisNode):
c.axes.append(node.resolve(context))
if self.varname:
context[self.varname] = c
return ""
else:
return c.img()
class Chart(object):
BASE = "http://chart.apis.google.com/chart"
defaults = {
"chs": "200x200",
"cht": "lc"
}
def __init__(self):
# Use a SortedDict for the opeions so they are added in a
# deterministic manner; this eases things like dealing with cache keys
# or writing unit tests.
self.options = SortedDict()
self.datasets = []
self.axes = []
self.datarange = None
self.alt = None
def clone(self):
clone = self.__class__()
clone.options = self.options.copy()
clone.datasets = self.datasets[:]
clone.axes = self.axes[:]
return clone
def img(self):
url = self.url()
width, height = self.options["chs"].split("x")
alt = self.alt and 'alt="%s" ' % escape(self.alt) or ''
s = mark_safe('
' % (escape(url), width, height, alt))
return s
def url(self):
# Figure out the chart's data range
if not self.datarange:
maxvalue = max(max(d) for d in self.datasets if d)
minvalue = min(min(d) for d in self.datasets if d)
self.datarange = (minvalue, maxvalue)
# Encode data
if "chds" in self.options or self.options.get('cht', None) == 'gom':
# text encoding if scaling provided, or for google-o-meter type
data = "|".join(encode_text(d) for d in self.datasets)
encoded_data = "t:%s" % data
else:
# extended encoding otherwise
data = extended_separator.join(encode_extended(d, self.datarange) for d in self.datasets)
encoded_data = "e:%s" % data
# Update defaults
for k in self.defaults:
if k not in self.options:
self.options[k] = self.defaults[k]
# Start to calcuate the URL
url = "%s?%s&chd=%s" % (self.BASE, urlencode(self.options), encoded_data)
# Calculate axis options
if self.axes:
axis_options = SortedDict()
axis_sides = []
for i, axis in enumerate(self.axes):
axis_sides.append(axis.side)
for opt in axis.options:
axis_options.setdefault(opt, []).append(axis.options[opt] % i)
# Turn the option lists into strings
axis_sides = smart_join(",", *axis_sides)
for opt in axis_options:
axis_options[opt] = smart_join("|", *axis_options[opt])
url += "&chxt=%s&%s" % (axis_sides, urlencode(axis_options))
return url
#
# {% chart-data %}
#
@register.tag(name="chart-data")
def chart_data(parser, token):
bits = iter(token.split_contents())
name = bits.next()
datasets = map(parser.compile_filter, bits)
return ChartDataNode(datasets)
class ChartDataNode(template.Node):
def __init__(self, datasets):
self.datasets = datasets
def resolve(self, context):
resolved = []
for data in self.datasets:
try:
data = data.resolve(context)
except template.VariableDoesNotExist:
data = []
# XXX need different ways of representing pre-encoded data, data with
# different separators, etc.
if isinstance(data, basestring):
data = filter(None, map(safefloat, data.split(",")))
else:
data = filter(None, map(safefloat, data))
resolved.append(data)
return resolved
def render(self, context):
return ""
#
# Chart options
#
class OptionNode(template.Node):
def __init__(self, callback, args, multi=None):
self.callback = callback
self.args = args
self.multi = multi
def render(self, context):
return ""
def resolve_arguments(self, context):
for arg in self.args:
try:
yield arg.resolve(context)
except template.VariableDoesNotExist:
yield None
def update_options(self, options, context):
data = self.callback(*self.resolve_arguments(context))
if self.multi:
for key in data:
if key in options:
options[key] = options[key] + self.multi + data[key]
else:
options[key] = data[key]
else:
options.update(data)
class ChartOptionNode(OptionNode):
def update_chart(self, chart, context):
self.update_options(chart.options, context)
class AxisOptionNode(OptionNode):
pass
def option(tagname, multi=None, nodeclass=ChartOptionNode):
"""
Decorator-helper to register a chart-foo option tag. The decorated function
will be called at resolution time with the proper arity (determined from
inspecting the decorated function). This callback should return a dictionary
which will be used as arguments in the chart URL.
"""
def decorator(func):
# Figure out how to validate the args to the tag
args, varargs, varkw, defaults = inspect.getargspec(func)
max_args = args and len(args) or 0
min_args = max_args - (defaults and len(defaults) or 0)
unlimited = bool(varargs)
#@functools.wraps(func)
def template_tag_callback(parser, token):
bits = iter(token.split_contents())
name = bits.next()
args = map(template.Variable, bits)
if not unlimited and len(args) < min_args:
raise template.TemplateSyntaxError("Too few arguments to '%s'" % name)
if not unlimited and len(args) > max_args:
raise template.TemplateSyntaxError("Too many arguments to '%s'" % name)
return nodeclass(func, args, multi)
register.tag(tagname, template_tag_callback)
return func
return decorator
@option("chart-type")
def chart_type(arg):
"""
Set the chart type. Valid arguments are anything the chart API understands,
or the following human-readable alternates:
* 'line'
* 'xy'
* 'xy' / 'line-xy'
* 'bar' / 'bar-grouped'
* 'column' / 'column-grouped'
* 'bar-stacked'
* 'column-stacked'
* 'pie'
* 'pie-3d'
* 'venn'
* 'scatter'
"""
types = {
'line': 'lc',
'xy': 'lxy',
'line-xy': 'lxy',
'bar': 'bhg',
'column': 'bvg',
'bar-stacked': 'bhs',
'column-stacked': 'bvs',
'bar-grouped': 'bhg',
'column-grouped': 'bvg',
'pie': 'p',
'pie-3d': 'p3',
'venn': 'v',
'scatter': 's',
'google-o-meter': 'gom',
}
return {"cht": types.get(arg, arg)}
@option("chart-data-scale", multi=",")
def chart_colors(*args):
return {"chds": smart_join(",", *args)}
@option("chart-colors", multi=",")
def chart_colors(*args):
return {"chco": smart_join(",", *args)}
@option("chart-size")
def chart_size(arg1, arg2=None):
if arg2:
return {"chs": smart_join("x", arg1, arg2)}
else:
return {"chs": arg1}
@option("chart-background", multi="|")
def chart_background(color):
return _solid("bg", color)
@option("chart-fill", multi="|")
def chart_fill(color):
return _solid("c", color)
def _solid(type, color):
return {"chf": "%s,s,%s" % (type, color)}
@option("chart-background-gradient", multi="|")
def chart_background_gradient(angle, *colors):
return _fancy_background("bg", "lg", angle, colors)
@option("chart-fill-gradient", multi="|")
def chart_fill_gradient(angle, *colors):
return _fancy_background("c", "lg", angle, colors)
@option("chart-background-stripes", multi="|")
def chart_background_stripes(angle, *colors):
return _fancy_background("bg", "ls", angle, colors)
@option("chart-fill-stripes", multi="|")
def chart_fill_stripes(angle, *colors):
return _fancy_background("c", "ls", angle, colors)
def _fancy_background(bgtype, fancytype, angle, colors):
return {"chf": smart_join(",", bgtype, fancytype, angle, *colors)}
@option("chart-title")
def chart_title(title, fontsize=None, color="000000"):
title = title.replace("\n", "|")
if fontsize:
return {"chtt":title, "chts":"%s,%s" % (color, fontsize)}
else:
return {"chtt": title}
@option("chart-legend", multi="|")
def chart_legend(*labels):
return {"chdl": smart_join("|", *flatten(labels))}
@option("chart-labels", multi="|")
def chart_labels(*labels):
return {"chl": smart_join("|", *flatten(labels))}
@option("chart-bar-width")
def chart_bar_width(width, barspace=None, groupspace=None):
return {"chbh": smart_join(",", width, barspace, groupspace)}
@option("chart-line-style", multi="|")
def chart_line_style(thickness, line_length=None, space_length=None):
return {"chls": smart_join(",", thickness, line_length, space_length)}
@option("chart-grid")
def chart_grid(xstep, ystep, line_length=None, space_length=None):
return {"chg": smart_join(",", xstep, ystep, line_length, space_length)}
rangetypes = {
"h": "r",
"horiz": "r",
"horizontal": "r",
"v": "R",
"vert": "R",
"vertical": "R",
}
@option("chart-range-marker", multi="|")
def chart_range_marker(range_type, color, start, end):
rt = rangetypes.get(range_type, range_type)
return {"chm": smart_join(",", rt, color, "0", start, end)}
@option("chart-fill-area", multi="|")
def chart_fill_area(color, startindex=0, endindex=0):
filltype = ((startindex or endindex) and "b" or "B")
return {"chm": smart_join(",", filltype, color, startindex, endindex, "0")}
marker_types = {
'arrow': 'a',
'cross': 'c',
'diamond': 'd',
'circle': 'o',
'square': 's',
'line': 'v',
'full-line': 'V',
'h-line': 'h',
'horiz-line': 'h',
'horizontal-line': 'h',
}
@option("chart-marker", multi="|")
def chart_marker(marker, color, dataset_index, data_point, size):
marker = marker_types.get(marker, marker)
return {"chm": smart_join(",", marker, color, dataset_index, data_point, size)}
@option("chart-makers", multi="|")
def chart_markers(dataset_index, iterable):
"""Provide an iterable yielding (type, color, point, size)"""
try:
it = iter(iterable)
except TypeError:
return {}
markers = []
for m in it:
try:
marker, color, point, size = m
except ValueError:
continue
marker - marker_types.get(marker, marker)
markers.append(smart_join(",", marker, color, dataset_index, data_point, size))
return {"chm": smart_join("|", markers)}
#
# {% axis %}
#
@register.tag
def axis(parser, token):
bits = token.split_contents()
if len(bits) == 2:
# {% axis %} ... {% endaxis %}
name, side = bits
nodelist = parser.parse("end%s" % name)
parser.delete_first_token()
return AxisNode(template.Variable(side), nodelist)
elif len(bits) == 3:
# {% axis hide %}
name, side = bits[0:2]
if bits[2].lower() != "hide":
raise template.TemplateSyntaxError("%s tag expected 'hide' as last argument" % name)
return NoAxisNode(template.Variable(side))
else:
raise template.TemplateSyntaxError("axis tag takes one or two arguments")
class AxisNode(template.Node):
sides = {
'left': 'y',
'right': 'r',
'top': 't',
'bottom': 'x',
}
def __init__(self, side, nodelist=None):
self.side = side
self.nodelist = nodelist
def render(self, context):
return ''
def resolve(self, context):
axis = self.get_axis(context)
for node in self.nodelist:
if isinstance(node, AxisOptionNode):
node.update_options(axis.options, context)
return axis
def get_axis(self, context):
try:
side = self.side.resolve(context)
except template.VariableDoesNotExist:
return None
side = self.sides.get(side, side)
return Axis(side)
class NoAxisNode(AxisNode):
def resolve(self, context):
axis = self.get_axis(context)
axis.options["chxs"] = "%s,000000,11,0,_"
axis.options["chxl"] = "%s:||"
return axis
class Axis(object):
def __init__(self, side):
self.side = side
self.options = SortedDict()
# Axis options use %s placeholders for the axis index; this gets
# filled in by Chart.url()
@option("axis-labels", nodeclass=AxisOptionNode)
def axis_labels(*labels):
return {"chxl": "%s:|" + smart_join("|", *flatten(labels))}
@option("axis-label-positions", nodeclass=AxisOptionNode)
def axis_label_position(*positions):
return {"chxp": smart_join(",", "%s", *flatten(positions))}
@option("axis-range", nodeclass=AxisOptionNode)
def axis_range(start, end):
return {"chxr": "%%s,%s,%s" % (start, end)}
alignments = {
'left': -1,
'right': 1,
'center': 0,
}
@option("axis-style", nodeclass=AxisOptionNode)
def axis_style(color, font_size=None, alignment=None):
alignment = alignments.get(alignment, alignment)
return {"chxs": smart_join(",", "%s", color, font_size, alignment)}
#
# "Metadata" nodes
#
class MetadataNode(ChartOptionNode):
def update_chart(self, chart, context):
self.callback(chart, *self.resolve_arguments(context))
@option("chart-data-range", nodeclass=MetadataNode)
def chart_data_range(chart, lower=None, upper=None):
if lower and upper:
try:
map(float, (lower, upper))
except ValueError:
return
chart.datarange = (lower, upper)
elif lower == "auto":
chart.datarange = None
@option("chart-alt", nodeclass=MetadataNode)
def chart_alt(chart, alt=None):
chart.alt = alt
#
# Helper functions
#
extended_separator = ","
def encode_text(values):
return extended_separator.join(str(v) for v in values)
def encode_extended(values, value_range):
"""Encode data using Google's "extended" encoding for the most granularity."""
return "".join(num2chars(v, value_range) for v in values)
_encoding_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-."
_num2chars = [a+b for a in _encoding_chars for b in _encoding_chars]
def num2chars(n, value_range):
if n is None:
return "__"
else:
return _num2chars[norm(n, value_range)]
def norm(n, value_range):
minvalue, maxvalue = value_range
if minvalue >= 0:
return int(round(float(n) / maxvalue * 4095, 0))
elif maxvalue <= 0:
return 4095 - int(round(float(n) * 4095 / minvalue))
else:
return int(round((n - minvalue) * (float(4095) / (maxvalue - minvalue))))
def safefloat(n):
try:
return float(n)
except (TypeError, ValueError):
return None
def smart_join(sep, *args):
return sep.join(smart_str(s, errors="ignore") for s in args if s is not None)
# I'm annoyed with the fact the urllib.urlencode doesn't allow specifying
# "safe" characters -- specifically ":", ",", and "|" since those characters
# make reading gchart URLs much easier.
from urllib import quote_plus
def urlencode(query, safe="/:,|"):
#q = functools.partial(quote_plus, safe=safe)
def q(*args, **kwargs):
kwargs['safe'] = safe
return quote_plus(*args, **kwargs)
query = hasattr(query, "items") and query.items() or query
qlist = ["%s=%s" % (q(k), q(v)) for (k,v) in query]
return "&".join(qlist)
def flatten(iterator):
for i in iterator:
if hasattr(i, "__iter__"):
for j in flatten(iter(i)):
yield j
else:
yield i