Web   ·   Wiki   ·   Activities   ·   Blog   ·   Lists   ·   Chat   ·   Meeting   ·   Bugs   ·   Git   ·   Translate   ·   Archive   ·   People   ·   Donate
summaryrefslogtreecommitdiffstats
path: root/sugarpycha/chart.py
diff options
context:
space:
mode:
authorAgustin Zubiaga <aguz@sugarlabs.org>2012-07-02 00:48:34 (GMT)
committer Agustin Zubiaga <aguz@sugarlabs.org>2012-07-02 00:48:34 (GMT)
commit6c0355d7194c60dcb778bb34a3df5a9bac511f37 (patch)
tree147cf3f35abac2bcb0966255176d9c67cc261491 /sugarpycha/chart.py
parent1a133902641f5e81425fe330877f0c2b2c9fc17a (diff)
Some charts.py fixes (sugarpycha)
Diffstat (limited to 'sugarpycha/chart.py')
-rw-r--r--sugarpycha/chart.py883
1 files changed, 883 insertions, 0 deletions
diff --git a/sugarpycha/chart.py b/sugarpycha/chart.py
new file mode 100644
index 0000000..c6a1c34
--- /dev/null
+++ b/sugarpycha/chart.py
@@ -0,0 +1,883 @@
+# Copyright(c) 2007-2010 by Lorenzo Gil Sanchez <lorenzo.gil.sanchez@gmail.com>
+#
+# This file is part of PyCha.
+#
+# PyCha is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# PyCha is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with PyCha. If not, see <http://www.gnu.org/licenses/>.
+
+import copy
+import inspect
+import math
+
+import cairo
+
+from pycha.color import ColorScheme, hex2rgb, DEFAULT_COLOR
+from pycha.utils import safe_unicode
+
+
+class Chart(object):
+
+ def __init__(self, surface, options={}, debug=False):
+ # this flag is useful to reuse this chart for drawing different data
+ # or use different options
+ self.resetFlag = False
+
+ # initialize storage
+ self.datasets = []
+
+ # computed values used in several methods
+ self.layout = Layout()
+ self.minxval = None
+ self.maxxval = None
+ self.minyval = None
+ self.maxyval = None
+ self.xscale = 1.0
+ self.yscale = 1.0
+ self.xrange = None
+ self.yrange = None
+ self.origin = 0.0
+
+ self.xticks = []
+ self.yticks = []
+
+ # set the default options
+ self.options = copy.deepcopy(DEFAULT_OPTIONS)
+ if options:
+ self.options.merge(options)
+
+ # initialize the surface
+ self._initSurface(surface)
+
+ self.colorScheme = None
+
+ # debug mode to draw aditional hints
+ self.debug = debug
+
+ def addDataset(self, dataset):
+ """Adds an object containing chart data to the storage hash"""
+ self.datasets += dataset
+
+ def _getDatasetsKeys(self):
+ """Return the name of each data set"""
+ return [d[0] for d in self.datasets]
+
+ def _getDatasetsValues(self):
+ """Return the data (value) of each data set"""
+ return [d[1] for d in self.datasets]
+
+ def setOptions(self, options={}):
+ """Sets options of this chart"""
+ self.options.merge(options)
+
+ def getSurfaceSize(self):
+ cx = cairo.Context(self.surface)
+ x, y, w, h = cx.clip_extents()
+ return w, h
+
+ def reset(self):
+ """Resets options and datasets.
+
+ In the next render the surface will be cleaned before any drawing.
+ """
+ self.resetFlag = True
+ self.options = copy.deepcopy(DEFAULT_OPTIONS)
+ self.datasets = []
+
+ def render(self, surface=None, options={}):
+ """Renders the chart with the specified options.
+
+ The optional parameters can be used to render a chart in a different
+ surface with new options.
+ """
+ self._update(options)
+ if surface:
+ self._initSurface(surface)
+
+ cx = cairo.Context(self.surface)
+
+ # calculate area data
+ surface_width, surface_height = self.getSurfaceSize()
+ self.layout.update(cx, self.options, surface_width, surface_height,
+ self.xticks, self.yticks)
+
+ self._renderBackground(cx)
+ if self.debug:
+ self.layout.render(cx)
+ self._renderChart(cx)
+ self._renderAxis(cx)
+ self._renderTitle(cx)
+ self._renderLegend(cx)
+
+ def clean(self):
+ """Clears the surface with a white background."""
+ cx = cairo.Context(self.surface)
+ cx.save()
+ cx.set_source_rgb(1, 1, 1)
+ cx.paint()
+ cx.restore()
+
+ def _setColorscheme(self):
+ """Sets the colorScheme used for the chart using the
+ options.colorScheme option
+ """
+ name = self.options.colorScheme.name
+ keys = self._getDatasetsKeys()
+ colorSchemeClass = ColorScheme.getColorScheme(name, None)
+ if colorSchemeClass is None:
+ raise ValueError('Color scheme "%s" is invalid!' % name)
+
+ # Remove invalid args before calling the constructor
+ kwargs = dict(self.options.colorScheme.args)
+ validArgs = inspect.getargspec(colorSchemeClass.__init__)[0]
+ kwargs = dict([(k, v) for k, v in kwargs.items() if k in validArgs])
+ self.colorScheme = colorSchemeClass(keys, **kwargs)
+
+ def _initSurface(self, surface):
+ self.surface = surface
+
+ if self.resetFlag:
+ self.resetFlag = False
+ self.clean()
+
+ def _update(self, options={}):
+ """Update all the information needed to render the chart"""
+ self.setOptions(options)
+ self._setColorscheme()
+ self._updateXY()
+ self._updateChart()
+ self._updateTicks()
+
+ def _updateXY(self):
+ """Calculates all kinds of metrics for the x and y axis"""
+ x_range_is_defined = self.options.axis.x.range is not None
+ y_range_is_defined = self.options.axis.y.range is not None
+
+ if not x_range_is_defined or not y_range_is_defined:
+ stores = self._getDatasetsValues()
+
+ # gather data for the x axis
+ if x_range_is_defined:
+ self.minxval, self.maxxval = self.options.axis.x.range
+ else:
+ xdata = [pair[0] for pair in reduce(lambda a, b: a + b, stores)]
+ self.minxval = float(min(xdata))
+ self.maxxval = float(max(xdata))
+ if self.minxval * self.maxxval > 0 and self.minxval > 0:
+ self.minxval = 0.0
+
+ self.xrange = self.maxxval - self.minxval
+ if self.xrange == 0:
+ self.xscale = 1.0
+ else:
+ self.xscale = 1.0 / self.xrange
+
+ # gather data for the y axis
+ if y_range_is_defined:
+ self.minyval, self.maxyval = self.options.axis.y.range
+ else:
+ ydata = [pair[1] for pair in reduce(lambda a, b: a + b, stores)]
+ self.minyval = float(min(ydata))
+ self.maxyval = float(max(ydata))
+ if self.minyval * self.maxyval > 0 and self.minyval > 0:
+ self.minyval = 0.0
+
+ self.yrange = self.maxyval - self.minyval
+ if self.yrange == 0:
+ self.yscale = 1.0
+ else:
+ self.yscale = 1.0 / self.yrange
+
+ if self.minyval * self.maxyval < 0: # different signs
+ self.origin = abs(self.minyval) * self.yscale
+ else:
+ self.origin = 0.0
+
+ def _updateChart(self):
+ raise NotImplementedError
+
+ def _updateTicks(self):
+ """Evaluates ticks for x and y axis.
+
+ You should call _updateXY before because that method computes the
+ values of xscale, minxval, yscale, and other attributes needed for
+ this method.
+ """
+ stores = self._getDatasetsValues()
+
+ # evaluate xTicks
+ self.xticks = []
+ if self.options.axis.x.ticks:
+ for tick in self.options.axis.x.ticks:
+ if not isinstance(tick, Option):
+ tick = Option(tick)
+ if tick.label is None:
+ label = str(tick.v)
+ else:
+ label = tick.label
+ pos = self.xscale * (tick.v - self.minxval)
+ if 0.0 <= pos <= 1.0:
+ self.xticks.append((pos, label))
+
+ elif self.options.axis.x.interval > 0:
+ interval = self.options.axis.x.interval
+ label = (divmod(self.minxval, interval)[0] + 1) * interval
+ pos = self.xscale * (label - self.minxval)
+ prec = self.options.axis.x.tickPrecision
+ while 0.0 <= pos <= 1.0:
+ pretty_label = round(label, prec)
+ if prec == 0:
+ pretty_label = int(pretty_label)
+ self.xticks.append((pos, pretty_label))
+ label += interval
+ pos = self.xscale * (label - self.minxval)
+
+ elif self.options.axis.x.tickCount > 0:
+ uniqx = range(len(uniqueIndices(stores)) + 1)
+ roughSeparation = self.xrange / self.options.axis.x.tickCount
+ i = j = 0
+ while i < len(uniqx) and j < self.options.axis.x.tickCount:
+ if (uniqx[i] - self.minxval) >= (j * roughSeparation):
+ pos = self.xscale * (uniqx[i] - self.minxval)
+ if 0.0 <= pos <= 1.0:
+ self.xticks.append((pos, uniqx[i]))
+ j += 1
+ i += 1
+
+ # evaluate yTicks
+ self.yticks = []
+ if self.options.axis.y.ticks:
+ for tick in self.options.axis.y.ticks:
+ if not isinstance(tick, Option):
+ tick = Option(tick)
+ if tick.label is None:
+ label = str(tick.v)
+ else:
+ label = tick.label
+ pos = 1.0 - (self.yscale * (tick.v - self.minyval))
+ if 0.0 <= pos <= 1.0:
+ self.yticks.append((pos, label))
+
+ elif self.options.axis.y.interval > 0:
+ interval = self.options.axis.y.interval
+ label = (divmod(self.minyval, interval)[0] + 1) * interval
+ pos = 1.0 - (self.yscale * (label - self.minyval))
+ prec = self.options.axis.y.tickPrecision
+ while 0.0 <= pos <= 1.0:
+ pretty_label = round(label, prec)
+ if prec == 0:
+ pretty_label = int(pretty_label)
+ self.yticks.append((pos, pretty_label))
+ label += interval
+ pos = 1.0 - (self.yscale * (label - self.minyval))
+
+ elif self.options.axis.y.tickCount > 0:
+ prec = self.options.axis.y.tickPrecision
+ num = self.yrange / self.options.axis.y.tickCount
+ if (num < 1 and prec == 0):
+ roughSeparation = 1
+ else:
+ roughSeparation = round(num, prec)
+
+ for i in range(self.options.axis.y.tickCount + 1):
+ yval = self.minyval + (i * roughSeparation)
+ pos = 1.0 - ((yval - self.minyval) * self.yscale)
+ if 0.0 <= pos <= 1.0:
+ pretty_label = round(yval, prec)
+ if prec == 0:
+ pretty_label = int(pretty_label)
+ self.yticks.append((pos, pretty_label))
+
+ def _renderBackground(self, cx):
+ """Renders the background area of the chart"""
+ if self.options.background.hide:
+ return
+
+ cx.save()
+
+ if self.options.background.baseColor:
+ cx.set_source_rgb(*hex2rgb(self.options.background.baseColor))
+ cx.paint()
+
+ if self.options.background.chartColor:
+ cx.set_source_rgb(*hex2rgb(self.options.background.chartColor))
+ surface_width, surface_height = self.getSurfaceSize()
+ cx.rectangle(self.options.padding.left, self.options.padding.top,
+ surface_width - (self.options.padding.left
+ + self.options.padding.right),
+ surface_height - (self.options.padding.top
+ + self.options.padding.bottom))
+ cx.fill()
+
+ if self.options.background.lineColor:
+ cx.set_source_rgb(*hex2rgb(self.options.background.lineColor))
+ cx.set_line_width(self.options.axis.lineWidth)
+ self._renderLines(cx)
+
+ cx.restore()
+
+ def _renderLines(self, cx):
+ """Aux function for _renderBackground"""
+ if self.options.axis.y.showLines and self.yticks:
+ for tick in self.yticks:
+ self._renderLine(cx, tick, False)
+ if self.options.axis.x.showLines and self.xticks:
+ for tick in self.xticks:
+ self._renderLine(cx, tick, True)
+
+ def _renderLine(self, cx, tick, horiz):
+ """Aux function for _renderLines"""
+ x1, x2, y1, y2 = (0, 0, 0, 0)
+ if horiz:
+ x1 = x2 = tick[0] * self.layout.chart.w + self.layout.chart.x
+ y1 = self.layout.chart.y
+ y2 = y1 + self.layout.chart.h
+ else:
+ x1 = self.layout.chart.x
+ x2 = x1 + self.layout.chart.w
+ y1 = y2 = tick[0] * self.layout.chart.h + self.layout.chart.y
+
+ cx.new_path()
+ cx.move_to(x1, y1)
+ cx.line_to(x2, y2)
+ cx.close_path()
+ cx.stroke()
+
+ def _renderChart(self, cx):
+ raise NotImplementedError
+
+ def _renderTick(self, cx, tick, x, y, x2, y2, rotate, text_position):
+ """Aux method for _renderXTick and _renderYTick"""
+ if callable(tick):
+ return
+
+ cx.new_path()
+ cx.move_to(x, y)
+ cx.line_to(x2, y2)
+ cx.close_path()
+ cx.stroke()
+
+ cx.select_font_face(self.options.axis.tickFont,
+ cairo.FONT_SLANT_NORMAL,
+ cairo.FONT_WEIGHT_NORMAL)
+ cx.set_font_size(self.options.axis.tickFontSize)
+
+ label = safe_unicode(tick[1], self.options.encoding)
+ xb, yb, width, height, xa, ya = cx.text_extents(label)
+
+ x, y = text_position
+
+ if rotate:
+ cx.save()
+ cx.translate(x, y)
+ cx.rotate(math.radians(rotate))
+ x = -width / 2.0
+ y = -height / 2.0
+ cx.move_to(x - xb, y - yb)
+ cx.show_text(label)
+ if self.debug:
+ cx.rectangle(x, y, width, height)
+ cx.stroke()
+ cx.restore()
+ else:
+ x -= width / 2.0
+ y -= height / 2.0
+ cx.move_to(x - xb, y - yb)
+ cx.show_text(label)
+ if self.debug:
+ cx.rectangle(x, y, width, height)
+ cx.stroke()
+
+ return label
+
+ def _renderYTick(self, cx, tick):
+ """Aux method for _renderAxis"""
+ x = self.layout.y_ticks.x + self.layout.y_ticks.w
+ y = self.layout.y_ticks.y + tick[0] * self.layout.y_ticks.h
+
+ text_position = ((self.layout.y_tick_labels.x
+ + self.layout.y_tick_labels.w / 2.0), y)
+
+ return self._renderTick(cx, tick,
+ x, y,
+ x - self.options.axis.tickSize, y,
+ self.options.axis.y.rotate,
+ text_position)
+
+ def _renderXTick(self, cx, tick):
+ """Aux method for _renderAxis"""
+
+ x = self.layout.x_ticks.x + tick[0] * self.layout.x_ticks.w
+ y = self.layout.x_ticks.y
+
+ text_position = (x, (self.layout.x_tick_labels.y
+ + self.layout.x_tick_labels.h / 2.0))
+
+ return self._renderTick(cx, tick,
+ x, y,
+ x, y + self.options.axis.tickSize,
+ self.options.axis.x.rotate,
+ text_position)
+
+ def _renderAxisLabel(self, cx, label, x, y, vertical=False):
+ cx.save()
+ cx.select_font_face(self.options.axis.labelFont,
+ cairo.FONT_SLANT_NORMAL,
+ cairo.FONT_WEIGHT_BOLD)
+ cx.set_font_size(self.options.axis.labelFontSize)
+ cx.set_source_rgb(*hex2rgb(self.options.axis.labelColor))
+
+ xb, yb, width, height, xa, ya = cx.text_extents(label)
+
+ if vertical:
+ y = y + width / 2.0
+ cx.move_to(x - xb, y - yb)
+ cx.translate(x, y)
+ cx.rotate(-math.radians(90))
+ cx.move_to(-xb, -yb)
+ cx.show_text(label)
+ if self.debug:
+ cx.rectangle(0, 0, width, height)
+ cx.stroke()
+ else:
+ x = x - width / 2.0
+ cx.move_to(x - xb, y - yb)
+ cx.show_text(label)
+ if self.debug:
+ cx.rectangle(x, y, width, height)
+ cx.stroke()
+ cx.restore()
+
+ def _renderYAxisLabel(self, cx, label_text):
+ label = safe_unicode(label_text, self.options.encoding)
+ x = self.layout.y_label.x
+ y = self.layout.y_label.y + self.layout.y_label.h / 2.0
+ self._renderAxisLabel(cx, label, x, y, True)
+
+ def _renderYAxis(self, cx):
+ """Draws the vertical line represeting the Y axis"""
+ cx.new_path()
+ cx.move_to(self.layout.chart.x, self.layout.chart.y)
+ cx.line_to(self.layout.chart.x,
+ self.layout.chart.y + self.layout.chart.h)
+ cx.close_path()
+ cx.stroke()
+
+ def _renderXAxisLabel(self, cx, label_text):
+ label = safe_unicode(label_text, self.options.encoding)
+ x = self.layout.x_label.x + self.layout.x_label.w / 2.0
+ y = self.layout.x_label.y
+ self._renderAxisLabel(cx, label, x, y, False)
+
+ def _renderXAxis(self, cx):
+ """Draws the horizontal line representing the X axis"""
+ cx.new_path()
+ y = self.layout.chart.y + (1.0 - self.origin) * self.layout.chart.h
+ cx.move_to(self.layout.chart.x, y)
+ cx.line_to(self.layout.chart.x + self.layout.chart.w, y)
+ cx.close_path()
+ cx.stroke()
+
+ def _renderAxis(self, cx):
+ """Renders axis"""
+ if self.options.axis.x.hide and self.options.axis.y.hide:
+ return
+
+ cx.save()
+ cx.set_source_rgb(*hex2rgb(self.options.axis.lineColor))
+ cx.set_line_width(self.options.axis.lineWidth)
+
+ if not self.options.axis.y.hide:
+ if self.yticks:
+ for tick in self.yticks:
+ self._renderYTick(cx, tick)
+
+ if self.options.axis.y.label:
+ self._renderYAxisLabel(cx, self.options.axis.y.label)
+
+ self._renderYAxis(cx)
+
+ if not self.options.axis.x.hide:
+ if self.xticks:
+ for tick in self.xticks:
+ self._renderXTick(cx, tick)
+
+ if self.options.axis.x.label:
+ self._renderXAxisLabel(cx, self.options.axis.x.label)
+
+ self._renderXAxis(cx)
+
+ cx.restore()
+
+ def _renderTitle(self, cx):
+ if self.options.title:
+ cx.save()
+ cx.select_font_face(self.options.titleFont,
+ cairo.FONT_SLANT_NORMAL,
+ cairo.FONT_WEIGHT_BOLD)
+ cx.set_font_size(self.options.titleFontSize)
+ cx.set_source_rgb(*hex2rgb(self.options.titleColor))
+
+ title = safe_unicode(self.options.title, self.options.encoding)
+ extents = cx.text_extents(title)
+ title_width = extents[2]
+
+ x = (self.layout.title.x
+ + self.layout.title.w / 2.0
+ - title_width / 2.0)
+ y = self.layout.title.y - extents[1]
+
+ cx.move_to(x, y)
+ cx.show_text(title)
+
+ cx.restore()
+
+ def _renderLegend(self, cx):
+ """This function adds a legend to the chart"""
+ if self.options.legend.hide:
+ return
+
+ surface_width, surface_height = self.getSurfaceSize()
+
+ # Compute legend dimensions
+ padding = 4
+ bullet = 15
+ width = 0
+ height = padding
+ keys = self._getDatasetsKeys()
+ cx.select_font_face(self.options.legend.legendFont,
+ cairo.FONT_SLANT_NORMAL,
+ cairo.FONT_WEIGHT_NORMAL)
+ cx.set_font_size(self.options.legend.legendFontSize)
+ for key in keys:
+ key = safe_unicode(key, self.options.encoding)
+ extents = cx.text_extents(key)
+ width = max(extents[2], width)
+ height += max(extents[3], bullet) + padding
+ width = padding + bullet + padding + width + padding
+
+ # Compute legend position
+ legend = self.options.legend
+ if legend.position.right is not None:
+ legend.position.left = (surface_width
+ - legend.position.right
+ - width)
+ if legend.position.bottom is not None:
+ legend.position.top = (surface_height
+ - legend.position.bottom
+ - height)
+
+ # Draw the legend
+ cx.save()
+ cx.rectangle(self.options.legend.position.left,
+ self.options.legend.position.top,
+ width, height)
+ cx.set_source_rgba(1, 1, 1, self.options.legend.opacity)
+ cx.fill_preserve()
+ cx.set_line_width(self.options.legend.borderWidth)
+ cx.set_source_rgb(*hex2rgb(self.options.legend.borderColor))
+ cx.stroke()
+
+ def drawKey(key, x, y, text_height):
+ cx.rectangle(x, y, bullet, bullet)
+ cx.set_source_rgb(*self.colorScheme[key])
+ cx.fill_preserve()
+ cx.set_source_rgb(0, 0, 0)
+ cx.stroke()
+ cx.move_to(x + bullet + padding,
+ y + bullet / 2.0 + text_height / 2.0)
+ cx.show_text(key)
+
+ cx.set_line_width(1)
+ x = self.options.legend.position.left + padding
+ y = self.options.legend.position.top + padding
+ for key in keys:
+ extents = cx.text_extents(key)
+ drawKey(key, x, y, extents[3])
+ y += max(extents[3], bullet) + padding
+
+ cx.restore()
+
+
+def uniqueIndices(arr):
+ """Return a list with the indexes of the biggest element of arr"""
+ return range(max([len(a) for a in arr]))
+
+
+class Area(object):
+ """Simple rectangle to hold an area coordinates and dimensions"""
+
+ def __init__(self, x=0.0, y=0.0, w=0.0, h=0.0):
+ self.x, self.y, self.w, self.h = x, y, w, h
+
+ def __str__(self):
+ msg = "<pycha.chart.Area@(%.2f, %.2f) %.2f x %.2f>"
+ return msg % (self.x, self.y, self.w, self.h)
+
+
+def get_text_extents(cx, text, font, font_size, encoding):
+ if text:
+ cx.save()
+ cx.select_font_face(font,
+ cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
+ cx.set_font_size(font_size)
+ safe_text = safe_unicode(text, encoding)
+ extents = cx.text_extents(safe_text)
+ cx.restore()
+ return extents[2:4]
+ return (0.0, 0.0)
+
+
+class Layout(object):
+ """Set of chart areas"""
+
+ def __init__(self):
+ self.title = Area()
+ self.x_label = Area()
+ self.y_label = Area()
+ self.x_tick_labels = Area()
+ self.y_tick_labels = Area()
+ self.x_ticks = Area()
+ self.y_ticks = Area()
+ self.chart = Area()
+
+ self._areas = (
+ (self.title, (1, 126 / 255.0, 0)), # orange
+ (self.y_label, (41 / 255.0, 91 / 255.0, 41 / 255.0)), # grey
+ (self.x_label, (41 / 255.0, 91 / 255.0, 41 / 255.0)), # grey
+ (self.y_tick_labels, (0, 115 / 255.0, 0)), # green
+ (self.x_tick_labels, (0, 115 / 255.0, 0)), # green
+ (self.y_ticks, (229 / 255.0, 241 / 255.0, 18 / 255.0)), # yellow
+ (self.x_ticks, (229 / 255.0, 241 / 255.0, 18 / 255.0)), # yellow
+ (self.chart, (75 / 255.0, 75 / 255.0, 1.0)), # blue
+ )
+
+ def update(self, cx, options, width, height, xticks, yticks):
+ self.title.x = options.padding.left
+ self.title.y = options.padding.top
+ self.title.w = width - (options.padding.left + options.padding.right)
+ self.title.h = get_text_extents(cx,
+ options.title,
+ options.titleFont,
+ options.titleFontSize,
+ options.encoding)[1]
+ x_axis_label_height = get_text_extents(cx,
+ options.axis.x.label,
+ options.axis.labelFont,
+ options.axis.labelFontSize,
+ options.encoding)[1]
+ y_axis_label_width = get_text_extents(cx,
+ options.axis.y.label,
+ options.axis.labelFont,
+ options.axis.labelFontSize,
+ options.encoding)[1]
+
+ x_axis_tick_labels_height = self._getAxisTickLabelsSize(cx, options,
+ options.axis.x,
+ xticks)[1]
+ y_axis_tick_labels_width = self._getAxisTickLabelsSize(cx, options,
+ options.axis.y,
+ yticks)[0]
+
+ self.y_label.x = options.padding.left
+ self.y_label.y = options.padding.top + self.title.h
+ self.y_label.w = y_axis_label_width
+ self.y_label.h = height - (options.padding.bottom
+ + options.padding.top
+ + x_axis_label_height
+ + x_axis_tick_labels_height
+ + options.axis.tickSize
+ + self.title.h)
+ self.x_label.x = (options.padding.left
+ + y_axis_label_width
+ + y_axis_tick_labels_width
+ + options.axis.tickSize)
+ self.x_label.y = height - (options.padding.bottom
+ + x_axis_label_height)
+ self.x_label.w = width - (options.padding.left
+ + options.padding.right
+ + options.axis.tickSize
+ + y_axis_label_width
+ + y_axis_tick_labels_width)
+ self.x_label.h = x_axis_label_height
+
+ self.y_tick_labels.x = self.y_label.x + self.y_label.w
+ self.y_tick_labels.y = self.y_label.y
+ self.y_tick_labels.w = y_axis_tick_labels_width
+ self.y_tick_labels.h = self.y_label.h
+
+ self.x_tick_labels.x = self.x_label.x
+ self.x_tick_labels.y = self.x_label.y - x_axis_tick_labels_height
+ self.x_tick_labels.w = self.x_label.w
+ self.x_tick_labels.h = x_axis_tick_labels_height
+
+ self.y_ticks.x = self.y_tick_labels.x + self.y_tick_labels.w
+ self.y_ticks.y = self.y_tick_labels.y
+ self.y_ticks.w = options.axis.tickSize
+ self.y_ticks.h = self.y_label.h
+
+ self.x_ticks.x = self.x_tick_labels.x
+ self.x_ticks.y = self.x_tick_labels.y - options.axis.tickSize
+ self.x_ticks.w = self.x_label.w
+ self.x_ticks.h = options.axis.tickSize
+
+ self.chart.x = self.y_ticks.x + self.y_ticks.w
+ self.chart.y = self.title.y + self.title.h
+ self.chart.w = self.x_ticks.w
+ self.chart.h = self.y_ticks.h
+
+ def render(self, cx):
+
+ def draw_area(area, r, g, b):
+ cx.rectangle(area.x, area.y, area.w, area.h)
+ cx.set_source_rgba(r, g, b, 0.5)
+ cx.fill()
+
+ cx.save()
+ for area, color in self._areas:
+ draw_area(area, *color)
+ cx.restore()
+
+ def _getAxisTickLabelsSize(self, cx, options, axis, ticks):
+ cx.save()
+ cx.select_font_face(options.axis.tickFont,
+ cairo.FONT_SLANT_NORMAL,
+ cairo.FONT_WEIGHT_NORMAL)
+ cx.set_font_size(options.axis.tickFontSize)
+
+ max_width = max_height = 0.0
+ if not axis.hide:
+ extents = [cx.text_extents(safe_unicode(
+ tick[1], options.encoding,
+ ))[2:4] # get width and height as a tuple
+ for tick in ticks]
+ if extents:
+ widths, heights = zip(*extents)
+ max_width, max_height = max(widths), max(heights)
+ if axis.rotate:
+ radians = math.radians(axis.rotate)
+ sin = math.sin(radians)
+ cos = math.cos(radians)
+ max_width, max_height = (
+ max_width * cos + max_height * sin,
+ max_width * sin + max_height * cos,
+ )
+ cx.restore()
+ return max_width, max_height
+
+
+class Option(dict):
+ """Useful dict that allow attribute-like access to its keys"""
+
+ def __getattr__(self, name):
+ if name in self.keys():
+ return self[name]
+ else:
+ raise AttributeError(name)
+
+ def merge(self, other):
+ """Recursive merge with other Option or dict object"""
+ for key, value in other.items():
+ if key in self:
+ if isinstance(self[key], Option):
+ self[key].merge(other[key])
+ else:
+ self[key] = other[key]
+
+
+DEFAULT_OPTIONS = Option(
+ axis=Option(
+ lineWidth=1.0,
+ lineColor='#0f0000',
+ tickSize=3.0,
+ labelColor='#666666',
+ labelFont='Tahoma',
+ labelFontSize=9,
+ tickFont='Tahoma',
+ tickFontSize=9,
+ x=Option(
+ hide=False,
+ ticks=None,
+ tickCount=10,
+ tickPrecision=1,
+ range=None,
+ rotate=None,
+ label=None,
+ interval=0,
+ showLines=False,
+ ),
+ y=Option(
+ hide=False,
+ ticks=None,
+ tickCount=10,
+ tickPrecision=1,
+ range=None,
+ rotate=None,
+ label=None,
+ interval=0,
+ showLines=True,
+ ),
+ ),
+ background=Option(
+ hide=False,
+ baseColor=None,
+ chartColor='#f5f5f5',
+ lineColor='#ffffff',
+ lineWidth=1.5,
+ ),
+ legend=Option(
+ opacity=0.8,
+ borderColor='#000000',
+ borderWidth=2,
+ hide=False,
+ legendFont='Tahoma',
+ legendFontSize=9,
+ position=Option(top=20, left=40, bottom=None, right=None),
+ ),
+ padding=Option(
+ left=10,
+ right=10,
+ top=10,
+ bottom=10,
+ ),
+ stroke=Option(
+ color='#ffffff',
+ hide=False,
+ shadow=True,
+ width=2
+ ),
+ yvals=Option(
+ show=False,
+ inside=False,
+ fontSize=11,
+ fontColor='#000000',
+ skipSmallValues=True,
+ snapToOrigin=False,
+ renderer=None
+ ),
+ fillOpacity=1.0,
+ shouldFill=True,
+ barWidthFillFraction=0.75,
+ pieRadius=0.4,
+ colorScheme=Option(
+ name='gradient',
+ args=Option(
+ initialColor=DEFAULT_COLOR,
+ colors=None,
+ ),
+ ),
+ title=None,
+ titleColor='#000000',
+ titleFont='Tahoma',
+ titleFontSize=12,
+ encoding='utf-8',
+)