import math
import os
from pgmagick import *
# Space around text elements. Mostly used for vertical spacing
LEGEND_MARGIN = TITLE_MARGIN = 20.0
LABEL_MARGIN = 10.0
DEFAULT_MARGIN = 20.0
DEFAULT_TRANSPARENCY = 0.7
DEFAULT_TARGET_WIDTH = 800
DEFAULT_FONT = "Vera.ttf"
THOUSAND_SEPARATOR = ','
[docs]class Base(object):
"""This object is based on all Graph Object.
When you create new graph object, override this object::
from gruffy.base import Base
class NewGraph(Base):
pass
"""
#: Title of Graph.
title = None
#: Transparent flag (or value).
#: *False* is non-transparent rendering, *True* is default value
#: transparent, *float* value is value's transparent.
transparent = False
#: setting labels
x_axis_label = y_axis_label = None
def __init__(self, target_width=DEFAULT_TARGET_WIDTH):
if type(target_width) is not int:
geometric_width, geometric_height = target_width.split('x')
self.columns = float(geometric_width)
self.rows = float(geometric_height)
else:
self.columns = float(target_width)
self.rows = target_width * 0.75
self._initialize_ivars()
self._reset_themes()
self.theme_keynote()
def _initialize_ivars(self):
"""Internal for calculations"""
self.raw_columns = 800.0
self.raw_rows = 800.0 * (self.rows / self.columns)
self.column_count = 0
self.marker_count = None
self.maximum_value = self.minimum_value = None
self.has_gdata = False
self.gdata = []
self.labels = {}
self.labels_seen = {}
self.sort = True
self.scale = self.columns / self.raw_columns
if 'MAGICK_FONT_PATH' in os.environ:
vera_font_path = os.path.join(os.environ['MAGICK_FONT_PATH'],
DEFAULT_FONT)
else:
vera_font_path = 'Vera.ttf'
self.font = vera_font_path if os.path.exists(vera_font_path) else None
self.marker_font_size = 21.0
self.legend_font_size = 20.0
self.title_font_size = 36.0
self.top_margin = DEFAULT_MARGIN
self.bottom_margin = DEFAULT_MARGIN
self.left_margin = DEFAULT_MARGIN
self.right_margin = DEFAULT_MARGIN
self.legend_margin = LEGEND_MARGIN
self.title_margin = TITLE_MARGIN
self.legend_box_size = 20.0
self.no_data_message = "No Data"
self.hide_line_markers = False
self.hide_legend = False
self.hide_title = False
self.hide_line_numbers = False
self.center_labels_over_point = True
self.has_left_labels = False
self.additional_line_values = []
self.additional_line_colors = []
self.theme_options = {}
self.y_axis_increment = None
self.stacked = None
self.norm_data = None
def _reset_themes(self):
self.color_index = 0
self.labels_seen = {}
self.theme_options = {}
[docs] def theme_keynote(self):
"""Setting up Keynote like gradient theme.
"""
self.blue = '#6886B4'
self.yellow = '#FDD84E'
self.green = '#72AE6E'
self.red = '#D1695E'
self.purple = '#8A6EAF'
self.orange = '#EFAA43'
self.white = 'white'
self.colors = [self.yellow, self.blue, self.green, self.red,
self.purple, self.orange, self.white]
self.set_theme({'colors': self.colors,
'marker_color': 'white',
'font_color': 'white',
'background_colors': ['black', '#4a465a']})
[docs] def theme_37signals(self):
"""Setting up 37 signals like gradient theme.
"""
self.green = '#339933'
self.purple = '#cc99cc'
self.blue = '#336699'
self.yellow = '#FFF804'
self.red = '#ff0000'
self.orange = '#cf5910'
self.black = 'black'
self.colors = [self.yellow, self.blue, self.green, self.red,
self.purple, self.orange, self.black]
self.set_theme({'colors': self.colors,
'marker_color': 'black',
'font_color': 'black',
'background_colors': ['#d1edf5', 'white']})
[docs] def theme_rails_keynote(self):
"""A color scheme from the colors used on the 2005 Rails keynote
presentation at RubyConf.
"""
self.green = '#00ff00'
self.grey = '#333333'
self.orange = '#ff5d00'
self.red = '#f61100'
self.white = 'white'
self.light_grey = '#999999'
self.black = 'black'
self.colors = [self.green, self.grey, self.orange, self.red,
self.white, self.light_grey, self.black]
self.set_theme({'colors': self.colors,
'marker_color': 'white',
'font_color': 'white',
'background_colors': ['#0083a3', '#0083a3']})
[docs] def theme_odeo(self):
"""A color scheme similar to that used on the popular podcast site.
"""
self.grey = '#202020'
self.white = 'white'
self.dark_pink = '#a21764'
self.green = '#8ab438'
self.light_grey = '#999999'
self.dark_blue = '#3a5b87'
self.black = 'black'
self.colors = [self.grey, self.white, self.dark_blue,
self.dark_pink, self.green, self.light_grey, self.black]
self.set_theme({'colors': self.colors,
'marker_color': 'white',
'font_color': 'white',
'background_colors': ['#ff47a4', '#ff1f81']})
[docs] def theme_pastel(self):
"""Setting up pastel color theme."""
self.colors = ['#a9dada', # blue
'#aedaa9', # green
'#daaea9', # peach
'#dadaa9', # yellow
'#a9a9da', # dk purple
'#daaeda', # purple
'#dadada', # grey
]
self.set_theme({'colors': self.colors,
'marker_color': '#aea9a9',
'font_color': 'black',
'background_colors': 'white'})
[docs] def theme_django(self):
"""Setting up Django like theme"""
self.colors = ['#a9dada', '#aedaa9', '#94da3a',
'#487854', '#a9a9da', '#dadada']
self.set_theme({'colors': self.colors,
'marker_color': '#ab5603',
'font_color': 'white',
'background_colors': ['#092e20', '#234f32']})
[docs] def theme_greyscale(self):
"""Setting up black and white theme"""
self.colors = ['#282828', '#383838', '#686868',
'#989898', '#c8c8c8', '#e8e8e8']
self.set_theme({'colors': self.colors,
'marker_color': '#aea9a9',
'font_color': 'black',
'background_colors': 'white'})
def render_background(self):
if type(self.theme_options['background_colors']) is list:
colors = self.theme_options['background_colors']
self.base_image = self.render_gradiated_background(*colors)
elif type(self.theme_options['background_colors']) is str:
colors = self.theme_options['background_colors']
self.base_image = self.render_solid_background(colors)
else:
image = self.theme_options['background_image']
self.base_image = self.render_image_background(image)
def render_solid_background(self, color):
return Image(Geometry(int(self.columns), int(self.rows)), color)
def render_gradiated_background(self, top_color, bottom_color):
im = Image(Geometry(int(self.columns), int(self.rows)),
Color('transparent'))
im.read("gradient:%s-%s" % (top_color, bottom_color))
return im
def render_image_background(self, image_path, opacity=True):
image = Image(image_path)
if type(opacity) is int:
image.opacity(opacity)
elif opacity is True:
image.opacity(50)
return image
def set_theme(self, options):
self._reset_themes()
defaults = {
'colors': ['black', 'white'],
'additional_line_colors': [],
'marker_color': 'white',
'font_color': 'black',
'background_colors': None,
'background_image': None}
defaults.update(options)
self.theme_options = defaults
self.colors = self.theme_options['colors']
self.marker_color = self.theme_options['marker_color']
self.font_color = self.theme_options['font_color'] or self.marker_color
self.additional_line_colors = self.theme_options['additional_line_colors']
self.render_background()
def increment_color(self):
self.color_index = (self.color_index + 1) % len(self.colors)
return self.colors[self.color_index - 1]
[docs] def data(self, name, data_points=[], color=None):
"""Set up graph dataset
:param name: data name
:param data_points: list of data point
:param color: *force* settig color of graph data
"""
data_points = list(data_points)
self.gdata.append({'label': name,
'values': data_points,
'color': color or self.increment_color()})
if len(data_points) > self.column_count:
self.column_count = len(data_points)
for cnt, data_point in enumerate(data_points):
if data_points is None:
continue
if self.maximum_value is None and self.minimum_value is None:
self.maximum_value = self.minimum_value = data_point
# TODO Doesn't work with stacked bar graphs
if self.larger_than_max(data_point):
self.maximum_value = data_point
if self.maximum_value >= 0:
self.has_gdata = True
if self.less_than_min(data_point):
self.minimum_value = data_point
if self.minimum_value < 0:
self.has_gdata = True
def draw_line_markers(self):
if self.hide_line_markers:
return
self.base_image.draw(DrawableStrokeAntialias(False))
if self.y_axis_increment is None:
# Try to use a number of horizontal lines that will come out even.
#
# TODO Do the same for larger numbers...100, 75, 50, 25
if self.marker_count is None:
for lines in range(3, 7):
if self.spread % lines == 0.0:
self.marker_count = lines
break
self.marker_count = self.marker_count or 4
if self.spread > 0:
self.increment = self.significant(self.spread / self.marker_count)
else:
self.increment = 1
else:
# TODO Make this work for negative values
self.maximum_value = max([self.maximum_value.ceil,
self.y_axis_increment])
self.minimum_value = math.floor(self.minimum_value)
self.calculate_spread()
self.normalize(true)
self.marker_count = int(self.spread / self.y_axis_increment)
self.increment = self.y_axis_increment
self.increment_scaled = float(self.graph_height) / (self.spread / self.increment)
# Draw horizontal line markers and annotate with numbers
dl = DrawableList()
for index in range(self.marker_count + 1):
y = self.graph_top + self.graph_height - float(index) * self.increment_scaled
dl.append(DrawableFillColor(Color(self.marker_color)))
dl.append(DrawableLine(self.graph_left, y, self.graph_right, y))
marker_label = index * self.increment + float(self.minimum_value)
if not self.hide_line_numbers:
dl.append(DrawableFillColor(Color(self.font_color)))
font = self.font if self.font else DEFAULT_FONT
dl.append(DrawableFont(font, StyleType.NormalStyle, 400,
StretchType.NormalStretch))
dl.append(DrawableStrokeColor('transparent'))
marker_font_size = self.scale_fontsize(self.marker_font_size)
dl.append(DrawablePointSize(marker_font_size))
# Vertically center with 1.0 for the height
dl.append(DrawableGravity(GravityType.NorthWestGravity))
x = self.calculate_width(self.marker_font_size, marker_label) / 2
y = y + self.calculate_caps_height(marker_font_size) / 3
dl.append(DrawableText(self.graph_left - LABEL_MARGIN - x,
y, self.label(marker_label)))
dl.append(DrawableScaling(self.scale, self.scale))
dl.append(DrawableStrokeAntialias(True))
self.base_image.draw(dl)
def draw_label(self, x_offset, index):
if self.hide_line_markers:
return
if index in self.labels and index not in self.labels_seen:
y_offset = self.graph_bottom + LABEL_MARGIN
dl = DrawableList()
dl.append(DrawableFillColor(Color(self.font_color)))
dl.append(DrawableGravity(GravityType.NorthWestGravity))
font = self.font if self.font else DEFAULT_FONT
dl.append(DrawableFont(font, StyleType.NormalStyle, 400,
StretchType.NormalStretch))
dl.append(DrawableStrokeColor(Color('transparent')))
dl.append(DrawablePointSize(self.marker_font_size))
label_font_height = self.calculate_caps_height(self.marker_font_size) / 2
label_text_width = self.calculate_width(self.marker_font_size,
self.labels[index])
dl.append(DrawableText(x_offset - label_text_width / 2.0,
y_offset + label_font_height,
self.labels[index]))
dl.append(DrawableScaling(self.scale, self.scale))
self.base_image.draw(dl)
self.labels_seen[index] = 1
def draw_no_data(self):
dl = DrawableList()
dl.append(DrawableFillColor(Color(self.font_color)))
font = self.font if self.font else DEFAULT_FONT
dl.append(DrawableGravity(GravityType.CenterGravity))
dl.append(DrawableFillColor(Color(self.font_color)))
dl.append(DrawableFont(font, StyleType.NormalStyle, 800,
StretchType.NormalStretch))
dl.append(DrawablePointSize(self.scale_fontsize(80)))
dl.append(DrawableText(0, 0, self.no_data_message))
dl.append(DrawableScaling(self.scale, self.scale))
self.base_image.draw(dl)
def scale_fontsize(self, value):
return value * self.scale
def clip_value_if_greater_than(self, value, max_value):
return max_value if value > max_value else value
def larger_than_max(self, data_point):
return True if data_point > self.maximum_value else False
def less_than_min(self, data_point):
return True if data_point < self.minimum_value else False
def significant(self, inc):
if inc == 0:
return 1.0
factor = 1.0
while (inc < 10):
inc = inc * 10
factor = factor / 10
while (inc > 100):
inc = inc / 10
factor = factor * 10
res = math.floor(inc) * factor
if float(int(res)) == res:
return int(res)
else:
return res
def sort_norm_data(self):
def normcompare(a, b):
a_sum = sum(a['values'])
b_sum = sum(b['values'])
if a_sum > b_sum:
return 1
elif a_sum < b_sum:
return -1
return 0
self.norm_data.sort(normcompare)
self.norm_data.reverse()
def center(self, size):
return (self.raw_columns - size) / 2
def draw_axis_labels(self):
if self.x_axis_label:
dl = DrawableList()
# X Axis
# Centered vertically and horizontally by setting the
# height to 1.0 and the width to the width of the graph.
x_axis_label_y_coordinate = self.graph_bottom + \
LABEL_MARGIN * 2 + self.marker_caps_height
dl.append(DrawableFillColor(Color(self.font_color)))
font = self.font if self.font else DEFAULT_FONT
dl.append(DrawableFont(font, StyleType.NormalStyle, 400,
StretchType.NormalStretch))
dl.append(DrawableStrokeColor('transparent'))
dl.append(DrawablePointSize(self.marker_font_size))
dl.append(DrawableGravity(GravityType.NorthGravity))
# graph center
#dl.append(DrawableText(self.graph_left / 2.0,
# x_axis_label_y_coordinate,
# self.x_axis_label))
dl.append(DrawableText(0.0, x_axis_label_y_coordinate,
self.x_axis_label))
dl.append(DrawableScaling(self.scale, self.scale))
self.base_image.draw(dl)
if self.y_axis_label:
# Y Axis, rotated vertically
dl = DrawableList()
dl.append(DrawableFillColor(Color(self.font_color)))
font = self.font if self.font else DEFAULT_FONT
dl.append(DrawableFont(font, StyleType.NormalStyle, 400,
StretchType.NormalStretch))
dl.append(DrawableStrokeColor('transparent'))
fontsize = self.marker_font_size
dl.append(DrawablePointSize(fontsize))
dl.append(DrawableRotation(90))
dl.append(DrawableGravity(GravityType.WestGravity))
x = -(self.calculate_width(fontsize, self.y_axis_label) / 2.0)
y = -(self.left_margin + self.marker_caps_height / 2.0)
dl.append(DrawableText(x, y, self.y_axis_label))
dl.append(DrawableScaling(self.scale, self.scale))
dl.append(DrawableRotation(-90))
self.base_image.draw(dl)
def draw_legend(self):
if self.hide_legend:
return
self.legend_labels = [gdata['label'] for gdata in self.gdata]
legend_square_width = self.legend_box_size
dl = DrawableList()
font = self.font if self.font else DEFAULT_FONT
dl.append(DrawablePointSize(self.legend_font_size))
label_widths = [[]] # Used to calculate line wrap
for label in self.legend_labels:
metrics = TypeMetric()
self.base_image.fontTypeMetrics(str(label), metrics)
label_width = metrics.textWidth() + legend_square_width * 2.7
label_widths[-1].append(label_width)
if sum(label_widths[-1]) > (self.raw_columns * 0.9):
label_widths.append([label_widths[-1].pop()])
current_x_offset = self.center(sum(label_widths[0]))
if self.hide_title:
current_y_offset = self.top_margin + self.title_margin
else:
current_y_offset = self.top_margin + self.title_margin + self.title_caps_height
dl.append(DrawableStrokeColor('transparent'))
for index, legend_label in enumerate(self.legend_labels):
# Now draw box with color of this dataset
dl.append(DrawableFillColor(Color(self.gdata[index]['color'])))
dl.append(DrawableRectangle(current_x_offset,
current_y_offset - legend_square_width / 2.0,
current_x_offset + legend_square_width,
current_y_offset + legend_square_width / 2.0))
# Draw label
dl.append(DrawableFillColor(Color(self.font_color)))
font = self.font if self.font else DEFAULT_FONT
dl.append(DrawableFont(font, StyleType.NormalStyle, 400,
StretchType.NormalStretch))
dl.append(DrawablePointSize(self.legend_font_size))
dl.append(DrawableGravity(GravityType.NorthWestGravity))
x = current_x_offset + legend_square_width * 1.7
y = current_y_offset + self.legend_caps_height / 3
dl.append(DrawableText(x, y, str(legend_label)))
dl.append(DrawablePointSize(self.legend_font_size))
metrics = TypeMetric()
self.base_image.fontTypeMetrics(str(legend_label), metrics)
current_string_offset = metrics.textWidth() + legend_square_width * 2.7
# Handle wrapping
del(label_widths[0][0])
if len(label_widths[0]) == 0:
del(label_widths[0])
if len(label_widths) != 0:
current_x_offset = self.center(sum(label_widths[0]))
line_height = max([self.legend_caps_height, legend_square_width]) + self.legend_margin
if len(label_widths) > 0:
# Wrap to next line and shrink available graph dimensions
current_y_offset += line_height
self.graph_top += line_height
self.graph_height = self.graph_bottom - self.graph_top
else:
current_x_offset += current_string_offset
if len(dl):
dl.append(DrawableScaling(self.scale, self.scale))
self.base_image.draw(dl)
self.color_index = 0
def draw_title(self):
if self.hide_title or self.title is None:
return
dl = DrawableList()
dl.append(DrawableFillColor(Color(self.font_color)))
font = self.font if self.font else DEFAULT_FONT
dl.append(DrawableGravity(GravityType.NorthGravity))
dl.append(DrawableFont(font, StyleType.NormalStyle, 800,
StretchType.NormalStretch))
dl.append(DrawablePointSize(self.title_font_size))
y = self.top_margin + self.title_caps_height / 2.0
dl.append(DrawableText(0, y, self.title))
dl.append(DrawableScaling(self.scale, self.scale))
self.base_image.draw(dl)
def setup_drawing(self):
if not self.has_gdata:
self.draw_no_data()
return
self.normalize()
self.setup_graph_measurements()
if self.sort:
self.sort_norm_data()
self.draw_legend()
self.draw_line_markers()
self.draw_axis_labels()
self.draw_title()
# Make copy of data with values scaled between 0-100
def normalize(self, force=False):
if (self.norm_data is None) or force:
self.norm_data = []
if not self.has_gdata:
return
self.calculate_spread()
for data_row in self.gdata:
norm_data_points = []
for data_point in data_row['values']:
if data_point is None:
norm_data_points.append(None)
else:
norm_data_points.append(((float(data_point) - \
float(self.minimum_value)) / self.spread))
self.norm_data.append({'label': data_row['label'],
'values': norm_data_points,
'color': data_row['color']})
def calculate_spread(self):
self.spread = float(self.maximum_value) - float(self.minimum_value)
if self.spread <= 0:
self.spread = 1
def calculate_caps_height(self, font_size):
self.base_image.fontPointsize(font_size)
tm = TypeMetric()
self.base_image.fontTypeMetrics('X', tm)
return tm.textHeight()
def calculate_width(self, font_size, text):
self.base_image.fontPointsize(font_size)
tm = TypeMetric()
self.base_image.fontTypeMetrics(str(text), tm)
return tm.textWidth()
def label(self, value):
#if not self.marker_count:
# self.marker_count = 0
if not self.spread:
self.spread = 0
# TODO: fixme
if True:#(float(self.spread) % float(self.marker_count) == 0) or self.y_axis_increment is not None:
glabel = str(int(value))
elif self.spread > 10.0:
glabel = "%0i" % value
elif self.spread >= 3.0:
glabel = "%0.2f" % value
else:
glabel = str(value)
parts = glabel.split('.')
#import re
#parts[0] = re.subn("(\d)(?=(\d\d\d)+(?!\d))", "\\1%s" % THOUSAND_SEPARATOR, parts[0])
return '.'.join(parts)
def setup_graph_measurements(self):
if self.hide_line_markers:
self.marker_caps_height = 0
else:
self.marker_caps_height = self.calculate_caps_height(self.marker_font_size)
if self.hide_title:
self.title_caps_height = 0
else:
self.title_caps_height = self.calculate_caps_height(self.title_font_size)
if self.hide_legend:
self.legend_caps_height = 0
else:
self.legend_caps_height = self.calculate_caps_height(self.legend_font_size)
if self.hide_line_markers:
(self.graph_left, self.graph_right_margin, self.graph_bottom_margin) = \
[self.left_margin, self.right_margin, self.bottom_margin]
else:
longest_left_label_width = 0
if self.has_left_labels:
value = ""
longest_value = None
for memo in self.labels.values():
if len(str(value)) > len(str(memo)):
longest_value = value
else:
longest_value = memo
value = memo
longest_left_label_width = self.calculate_width(
self.marker_font_size, longest_value) * 1.25
else:
longest_left_label_width = self.calculate_width(
self.marker_font_size,
self.label(float(self.maximum_value)))
# Shift graph if left line numbers are hidden
if self.hide_line_numbers and not self.has_left_labels:
line_number_width = 0.0
else:
line_number_width = longest_left_label_width + LABEL_MARGIN * 2
if self.y_axis_label is None:
tmp = 0.0
else:
tmp = self.marker_caps_height + LABEL_MARGIN * 2
self.graph_left = self.left_margin + line_number_width + tmp
# Make space for half the width of the rightmost column label.
# Might be greater than the number of columns if between-style bar
# markers are used.
tmp = self.labels.keys()
tmp.sort()
if len(tmp):
last_label = int(tmp[-1])
else:
last_label = 0
if last_label >= (self.column_count - 1) and \
self.center_labels_over_point:
extra_room_for_long_label = \
self.calculate_width(self.marker_font_size,
self.labels[last_label])
else:
extra_room_for_long_label = 0
self.graph_right_margin = self.right_margin + extra_room_for_long_label
self.graph_bottom_margin = self.bottom_margin + self.marker_caps_height + LABEL_MARGIN
self.graph_right = self.raw_columns - self.graph_right_margin
self.graph_width = self.raw_columns - self.graph_left - self.graph_right_margin
# When self.hide title, leave a title_margin space for aesthetics.
# Same with self.hide_legend
if self.hide_title:
tmp = self.title_margin
else:
tmp = self.title_caps_height + self.title_margin
if self.hide_legend:
tmp += self.legend_margin
else:
tmp += self.legend_caps_height + self.legend_margin
self.graph_top = self.top_margin + tmp
if self.x_axis_label is None:
x_axis_label_height = 0.0
else:
x_axis_label_height = self.marker_caps_height + LABEL_MARGIN
self.graph_bottom = self.raw_rows - self.graph_bottom_margin - x_axis_label_height
self.graph_height = self.graph_bottom - self.graph_top
def draw(self):
# TODO: not implement
#if self.stacked:
# self.make_stacked()
self.setup_drawing()
[docs] def write(self, filename="graph.png"):
"""draw graph and save the graph image.
"""
self.draw()
self.base_image.write(filename)
[docs] def display(self):
"""wrapper for :func:`pgmagick.Image.display`
draw graph, and render graph.
"""
self.draw()
self.base_image.display()
[docs]class StackedMixin(object):
"""Stacked Graph Mix-in
When you create new stacked graph object, mixed in this object::
from gruffy.base import Base, StackedMixin
class NewStackedGraph(Base, StackedMixin):
pass
"""
[docs] def get_maximum_by_stack(self):
"""get sum of each stack"""
max_hash = {}
for data_set in self.gdata:
for i, data_point in enumerate(data_set['values']):
if i not in max_hash:
max_hash[i] = 0.0
max_hash[i] += float(data_point)
for key in max_hash.keys():
if max_hash[key] > self.maximum_value:
self.maximum_value = max_hash[key]
self.minimum_value = 0