You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
876 lines
30 KiB
876 lines
30 KiB
6 years ago
|
# 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)
|
||
|
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()
|
||
|
for key in keys:
|
||
|
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,
|
||
|
labelWidth=50.0,
|
||
|
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,
|
||
|
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',
|
||
|
)
|