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.
353 lines
12 KiB
353 lines
12 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 math
|
||
|
|
||
|
import cairo
|
||
|
|
||
|
from pycha.chart import Chart, Option, Layout, Area, get_text_extents
|
||
|
from pycha.color import hex2rgb
|
||
|
|
||
|
|
||
|
class PieChart(Chart):
|
||
|
|
||
|
def __init__(self, surface=None, options={}, debug=False):
|
||
|
super(PieChart, self).__init__(surface, options, debug)
|
||
|
self.slices = []
|
||
|
self.centerx = 0
|
||
|
self.centery = 0
|
||
|
self.layout = PieLayout(self.slices)
|
||
|
|
||
|
def _updateChart(self):
|
||
|
"""Evaluates measures for pie charts"""
|
||
|
slices = [dict(name=key,
|
||
|
value=(i, value[0][1]))
|
||
|
for i, (key, value) in enumerate(self.datasets)]
|
||
|
|
||
|
s = float(sum([slice['value'][1] for slice in slices]))
|
||
|
|
||
|
fraction = angle = 0.0
|
||
|
|
||
|
del self.slices[:]
|
||
|
for slice in slices:
|
||
|
if slice['value'][1] > 0:
|
||
|
angle += fraction
|
||
|
fraction = slice['value'][1] / s
|
||
|
self.slices.append(Slice(slice['name'], fraction,
|
||
|
slice['value'][0], slice['value'][1],
|
||
|
angle))
|
||
|
|
||
|
def _updateTicks(self):
|
||
|
"""Evaluates pie ticks"""
|
||
|
self.xticks = []
|
||
|
if self.options.axis.x.ticks:
|
||
|
lookup = dict([(slice.xval, slice) for slice in self.slices])
|
||
|
for tick in self.options.axis.x.ticks:
|
||
|
if not isinstance(tick, Option):
|
||
|
tick = Option(tick)
|
||
|
slice = lookup.get(tick.v, None)
|
||
|
label = tick.label or str(tick.v)
|
||
|
if slice is not None:
|
||
|
label += ' (%.1f%%)' % (slice.fraction * 100)
|
||
|
self.xticks.append((tick.v, label))
|
||
|
else:
|
||
|
for slice in self.slices:
|
||
|
label = '%s (%.1f%%)' % (slice.name, slice.fraction * 100)
|
||
|
self.xticks.append((slice.xval, label))
|
||
|
|
||
|
def _renderLines(self, cx):
|
||
|
"""Aux function for _renderBackground"""
|
||
|
# there are no lines in a Pie Chart
|
||
|
|
||
|
def _renderChart(self, cx):
|
||
|
"""Renders a pie chart"""
|
||
|
self.centerx = self.layout.chart.x + self.layout.chart.w * 0.5
|
||
|
self.centery = self.layout.chart.y + self.layout.chart.h * 0.5
|
||
|
|
||
|
cx.set_line_join(cairo.LINE_JOIN_ROUND)
|
||
|
|
||
|
if self.options.stroke.shadow and False:
|
||
|
cx.save()
|
||
|
cx.set_source_rgba(0, 0, 0, 0.15)
|
||
|
|
||
|
cx.new_path()
|
||
|
cx.move_to(self.centerx, self.centery)
|
||
|
cx.arc(self.centerx + 1, self.centery + 2,
|
||
|
self.layout.radius + 1, 0, math.pi * 2)
|
||
|
cx.line_to(self.centerx, self.centery)
|
||
|
cx.close_path()
|
||
|
cx.fill()
|
||
|
cx.restore()
|
||
|
|
||
|
cx.save()
|
||
|
for slice in self.slices:
|
||
|
if slice.isBigEnough():
|
||
|
cx.set_source_rgb(*self.colorScheme[slice.name])
|
||
|
if self.options.shouldFill:
|
||
|
slice.draw(cx, self.centerx, self.centery,
|
||
|
self.layout.radius)
|
||
|
cx.fill()
|
||
|
|
||
|
if not self.options.stroke.hide:
|
||
|
slice.draw(cx, self.centerx, self.centery,
|
||
|
self.layout.radius)
|
||
|
cx.set_line_width(self.options.stroke.width)
|
||
|
cx.set_source_rgb(*hex2rgb(self.options.stroke.color))
|
||
|
cx.stroke()
|
||
|
|
||
|
cx.restore()
|
||
|
|
||
|
if self.debug:
|
||
|
cx.set_source_rgba(1, 0, 0, 0.5)
|
||
|
px = max(cx.device_to_user_distance(1, 1))
|
||
|
for x, y in self.layout._lines:
|
||
|
cx.arc(x, y, 5 * px, 0, 2 * math.pi)
|
||
|
cx.fill()
|
||
|
cx.new_path()
|
||
|
cx.move_to(self.centerx, self.centery)
|
||
|
cx.line_to(x, y)
|
||
|
cx.stroke()
|
||
|
|
||
|
def _renderAxis(self, cx):
|
||
|
"""Renders the axis for pie charts"""
|
||
|
if self.options.axis.x.hide or not self.xticks:
|
||
|
return
|
||
|
|
||
|
self.xlabels = []
|
||
|
|
||
|
if self.debug:
|
||
|
px = max(cx.device_to_user_distance(1, 1))
|
||
|
cx.set_source_rgba(0, 0, 1, 0.5)
|
||
|
for x, y, w, h in self.layout.ticks:
|
||
|
cx.rectangle(x, y, w, h)
|
||
|
cx.stroke()
|
||
|
cx.arc(x + w / 2.0, y + h / 2.0, 5 * px, 0, 2 * math.pi)
|
||
|
cx.fill()
|
||
|
cx.arc(x, y, 2 * px, 0, 2 * math.pi)
|
||
|
cx.fill()
|
||
|
|
||
|
cx.select_font_face(self.options.axis.tickFont,
|
||
|
cairo.FONT_SLANT_NORMAL,
|
||
|
cairo.FONT_WEIGHT_NORMAL)
|
||
|
cx.set_font_size(self.options.axis.tickFontSize)
|
||
|
|
||
|
cx.set_source_rgb(*hex2rgb(self.options.axis.labelColor))
|
||
|
|
||
|
for i, tick in enumerate(self.xticks):
|
||
|
label = tick[1]
|
||
|
x, y, w, h = self.layout.ticks[i]
|
||
|
|
||
|
xb, yb, width, height, xa, ya = cx.text_extents(label)
|
||
|
|
||
|
# draw label with text tick[1]
|
||
|
cx.move_to(x - xb, y - yb)
|
||
|
cx.show_text(label)
|
||
|
self.xlabels.append(label)
|
||
|
|
||
|
|
||
|
class Slice(object):
|
||
|
|
||
|
def __init__(self, name, fraction, xval, yval, angle):
|
||
|
self.name = name
|
||
|
self.fraction = fraction
|
||
|
self.xval = xval
|
||
|
self.yval = yval
|
||
|
self.startAngle = 2 * angle * math.pi
|
||
|
self.endAngle = 2 * (angle + fraction) * math.pi
|
||
|
|
||
|
def __str__(self):
|
||
|
return ("<pycha.pie.Slice from %.2f to %.2f (%.2f%%)>" %
|
||
|
(self.startAngle, self.endAngle, self.fraction))
|
||
|
|
||
|
def isBigEnough(self):
|
||
|
return abs(self.startAngle - self.endAngle) > 0.001
|
||
|
|
||
|
def draw(self, cx, centerx, centery, radius):
|
||
|
cx.new_path()
|
||
|
cx.move_to(centerx, centery)
|
||
|
cx.arc(centerx, centery, radius, -self.endAngle, -self.startAngle)
|
||
|
cx.close_path()
|
||
|
|
||
|
def getNormalisedAngle(self):
|
||
|
normalisedAngle = (self.startAngle + self.endAngle) / 2
|
||
|
|
||
|
if normalisedAngle > math.pi * 2:
|
||
|
normalisedAngle -= math.pi * 2
|
||
|
elif normalisedAngle < 0:
|
||
|
normalisedAngle += math.pi * 2
|
||
|
|
||
|
return normalisedAngle
|
||
|
|
||
|
|
||
|
class PieLayout(Layout):
|
||
|
"""Set of chart areas for pie charts"""
|
||
|
|
||
|
def __init__(self, slices):
|
||
|
self.slices = slices
|
||
|
|
||
|
self.title = Area()
|
||
|
self.chart = Area()
|
||
|
|
||
|
self.ticks = []
|
||
|
self.radius = 0
|
||
|
|
||
|
self._areas = (
|
||
|
(self.title, (1, 126 / 255.0, 0)), # orange
|
||
|
(self.chart, (75 / 255.0, 75 / 255.0, 1.0)), # blue
|
||
|
)
|
||
|
|
||
|
self._lines = []
|
||
|
|
||
|
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]
|
||
|
|
||
|
lookup = dict([(slice.xval, slice) for slice in self.slices])
|
||
|
|
||
|
self.chart.x = self.title.x
|
||
|
self.chart.y = self.title.y + self.title.h
|
||
|
self.chart.w = self.title.w
|
||
|
self.chart.h = height - self.title.h - (options.padding.top
|
||
|
+ options.padding.bottom)
|
||
|
|
||
|
centerx = self.chart.x + self.chart.w * 0.5
|
||
|
centery = self.chart.y + self.chart.h * 0.5
|
||
|
|
||
|
self.radius = min(self.chart.w / 2.0, self.chart.h / 2.0)
|
||
|
for tick in xticks:
|
||
|
slice = lookup.get(tick[0], None)
|
||
|
width, height = get_text_extents(cx, tick[1],
|
||
|
options.axis.tickFont,
|
||
|
options.axis.tickFontSize,
|
||
|
options.encoding)
|
||
|
angle = slice.getNormalisedAngle()
|
||
|
radius = self._get_min_radius(angle, centerx, centery,
|
||
|
width, height)
|
||
|
self.radius = min(self.radius, radius)
|
||
|
|
||
|
# Now that we now the radius we move the ticks as close as we can
|
||
|
# to the circle
|
||
|
for i, tick in enumerate(xticks):
|
||
|
slice = lookup.get(tick[0], None)
|
||
|
angle = slice.getNormalisedAngle()
|
||
|
self.ticks[i] = self._get_tick_position(self.radius, angle,
|
||
|
self.ticks[i],
|
||
|
centerx, centery)
|
||
|
|
||
|
def _get_min_radius(self, angle, centerx, centery, width, height):
|
||
|
min_radius = None
|
||
|
|
||
|
# precompute some common values
|
||
|
tan = math.tan(angle)
|
||
|
half_width = width / 2.0
|
||
|
half_height = height / 2.0
|
||
|
offset_x = half_width * tan
|
||
|
offset_y = half_height / tan
|
||
|
|
||
|
def intersect_horizontal_line(y):
|
||
|
return centerx + (centery - y) / tan
|
||
|
|
||
|
def intersect_vertical_line(x):
|
||
|
return centery - tan * (x - centerx)
|
||
|
|
||
|
# computes the intersection between the rect that has
|
||
|
# that angle with the X axis and the bounding chart box
|
||
|
if 0.25 * math.pi <= angle < 0.75 * math.pi:
|
||
|
# intersects with the top rect
|
||
|
y = self.chart.y
|
||
|
x = intersect_horizontal_line(y)
|
||
|
self._lines.append((x, y))
|
||
|
|
||
|
x1 = x - half_width - offset_y
|
||
|
self.ticks.append((x1, self.chart.y, width, height))
|
||
|
|
||
|
min_radius = abs((y + height) - centery)
|
||
|
elif 0.75 * math.pi <= angle < 1.25 * math.pi:
|
||
|
# intersects with the left rect
|
||
|
x = self.chart.x
|
||
|
y = intersect_vertical_line(x)
|
||
|
self._lines.append((x, y))
|
||
|
|
||
|
y1 = y - half_height - offset_x
|
||
|
self.ticks.append((x, y1, width, height))
|
||
|
|
||
|
min_radius = abs(centerx - (x + width))
|
||
|
elif 1.25 * math.pi <= angle < 1.75 * math.pi:
|
||
|
# intersects with the bottom rect
|
||
|
y = self.chart.y + self.chart.h
|
||
|
x = intersect_horizontal_line(y)
|
||
|
self._lines.append((x, y))
|
||
|
|
||
|
x1 = x - half_width + offset_y
|
||
|
self.ticks.append((x1, y - height, width, height))
|
||
|
|
||
|
min_radius = abs((y - height) - centery)
|
||
|
else:
|
||
|
# intersects with the right rect
|
||
|
x = self.chart.x + self.chart.w
|
||
|
y = intersect_vertical_line(x)
|
||
|
self._lines.append((x, y))
|
||
|
|
||
|
y1 = y - half_height + offset_x
|
||
|
self.ticks.append((x - width, y1, width, height))
|
||
|
|
||
|
min_radius = abs((x - width) - centerx)
|
||
|
|
||
|
return min_radius
|
||
|
|
||
|
def _get_tick_position(self, radius, angle, tick, centerx, centery):
|
||
|
text_width, text_height = tick[2:4]
|
||
|
half_width = text_width / 2.0
|
||
|
half_height = text_height / 2.0
|
||
|
|
||
|
if 0 <= angle < 0.5 * math.pi:
|
||
|
# first quadrant
|
||
|
k1 = j1 = k2 = 1
|
||
|
j2 = -1
|
||
|
elif 0.5 * math.pi <= angle < math.pi:
|
||
|
# second quadrant
|
||
|
k1 = k2 = -1
|
||
|
j1 = j2 = 1
|
||
|
elif math.pi <= angle < 1.5 * math.pi:
|
||
|
# third quadrant
|
||
|
k1 = j1 = k2 = -1
|
||
|
j2 = 1
|
||
|
elif 1.5 * math.pi <= angle < 2 * math.pi:
|
||
|
# fourth quadrant
|
||
|
k1 = k2 = 1
|
||
|
j1 = j2 = -1
|
||
|
|
||
|
cx = radius * math.cos(angle) + k1 * half_width
|
||
|
cy = radius * math.sin(angle) + j1 * half_height
|
||
|
|
||
|
radius2 = math.sqrt(cx * cx + cy * cy)
|
||
|
|
||
|
tan = math.tan(angle)
|
||
|
x = math.sqrt((radius2 * radius2) / (1 + tan * tan))
|
||
|
y = tan * x
|
||
|
|
||
|
x = centerx + k2 * x
|
||
|
y = centery + j2 * y
|
||
|
|
||
|
return x - half_width, y - half_height, text_width, text_height
|