mirror of https://github.com/Kozea/pygal.git
Florian Mounier
13 years ago
25 changed files with 0 additions and 2689 deletions
@ -1,22 +0,0 @@
|
||||
The MIT License |
||||
|
||||
Copyright © 2008 Jason R. Coombs |
||||
Copyright © 2011 Kozea |
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
of this software and associated documentation files (the "Software"), to deal |
||||
in the Software without restriction, including without limitation the rights |
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
copies of the Software, and to permit persons to whom the Software is |
||||
furnished to do so, subject to the following conditions: |
||||
|
||||
The above copyright notice and this permission notice shall be included in |
||||
all copies or substantial portions of the Software. |
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN |
||||
THE SOFTWARE. |
@ -1,7 +0,0 @@
|
||||
# -*- coding: utf-8 -*- |
||||
|
||||
""" |
||||
pygal package. |
||||
""" |
||||
|
||||
__all__ = ('graph', 'plot', 'time_series', 'bar', 'pie', 'schedule', 'util') |
@ -1,217 +0,0 @@
|
||||
# -*- coding: utf-8 -*- |
||||
from itertools import chain |
||||
from lxml import etree |
||||
from pygal.graph import Graph |
||||
from pygal.util import node |
||||
|
||||
__all__ = ('VerticalBar', 'HorizontalBar') |
||||
|
||||
|
||||
class Bar(Graph): |
||||
"A superclass for bar-style graphs. Do not instantiate directly." |
||||
|
||||
# gap between bars |
||||
bar_gap = True |
||||
# how to stack adjacent dataset series |
||||
# overlap - overlap bars with transparent colors |
||||
# top - stack bars on top of one another |
||||
# side - stack bars side-by-side |
||||
stack = 'side' |
||||
|
||||
scale_divisions = None |
||||
|
||||
stylesheet_names = Graph.stylesheet_names + ['bar.css'] |
||||
|
||||
def __init__(self, fields, *args, **kargs): |
||||
self.fields = fields |
||||
super(Bar, self).__init__(*args, **kargs) |
||||
|
||||
# adapted from Plot |
||||
def get_data_values(self): |
||||
min_value, max_value, scale_division = self.data_range() |
||||
result = tuple( |
||||
float_range(min_value, max_value + scale_division, scale_division)) |
||||
if self.scale_integers: |
||||
result = map(int, result) |
||||
return result |
||||
|
||||
# adapted from plot (very much like calling data_range('y')) |
||||
def data_range(self): |
||||
min_value = self.data_min() |
||||
max_value = self.data_max() |
||||
range = max_value - min_value |
||||
|
||||
data_pad = range / 20.0 or 10 |
||||
scale_range = (max_value + data_pad) - min_value |
||||
|
||||
scale_division = self.scale_divisions or (scale_range / 10.0) |
||||
|
||||
if self.scale_integers: |
||||
scale_division = round(scale_division) or 1 |
||||
|
||||
return min_value, max_value, scale_division |
||||
|
||||
def get_field_labels(self): |
||||
return self.fields |
||||
|
||||
def get_data_labels(self): |
||||
return map(str, self.get_data_values()) |
||||
|
||||
def data_max(self): |
||||
return max( |
||||
list(chain(*map(lambda set: set['data'], self.data))) + [0]) |
||||
# above is same as |
||||
# return max(map(lambda set: max(set['data']), self.data)) |
||||
|
||||
def data_min(self): |
||||
if not getattr(self, 'min_scale_value') is None: |
||||
return self.min_scale_value |
||||
min_value = min(list( |
||||
chain(*map(lambda set: set['data'], self.data))) + [0]) |
||||
return min_value |
||||
|
||||
def get_bar_gap(self, field_size): |
||||
bar_gap = 10 # default gap |
||||
if field_size < 10: |
||||
# adjust for narrow fields |
||||
bar_gap = field_size / 2 |
||||
# the following zero's out the gap if bar_gap is False |
||||
bar_gap = int(self.bar_gap) * bar_gap |
||||
return bar_gap |
||||
|
||||
|
||||
def float_range(start=0, stop=None, step=1): |
||||
"Much like the built-in function range, but accepts floats" |
||||
while start < stop: |
||||
yield float(start) |
||||
start += step |
||||
|
||||
|
||||
class VerticalBar(Bar): |
||||
""" Vertical bar graph """ |
||||
top_align = top_font = 1 |
||||
|
||||
def get_x_labels(self): |
||||
return self.get_field_labels() |
||||
|
||||
def get_y_labels(self): |
||||
return self.get_data_labels() |
||||
|
||||
def x_label_offset(self, width): |
||||
return width / 2.0 |
||||
|
||||
def draw_data(self): |
||||
min_value = self.data_min() |
||||
unit_size = ( |
||||
float(self.graph_height) - self.font_size * 2 * self.top_font) |
||||
divisor = ( |
||||
max(self.get_data_values()) - min(self.get_data_values())) |
||||
if divisor == 0: |
||||
unit_size = 0 |
||||
else: |
||||
unit_size /= divisor |
||||
|
||||
bar_gap = self.get_bar_gap(self.get_field_width()) |
||||
|
||||
bar_width = self.get_field_width() - bar_gap |
||||
if self.stack == 'side': |
||||
bar_width /= len(self.data) |
||||
|
||||
x_mod = (self.graph_width - bar_gap) / 2 |
||||
if self.stack == 'side': |
||||
x_mod -= bar_width / 2 |
||||
|
||||
bottom = self.graph_height |
||||
|
||||
for field_count, field in enumerate(self.fields): |
||||
for dataset_count, dataset in enumerate(self.data): |
||||
# cases (assume 0 = +ve): |
||||
# value min length |
||||
# +ve +ve value - min |
||||
# +ve -ve value - 0 |
||||
# -ve -ve value.abs - 0 |
||||
value = dataset['data'][field_count] |
||||
|
||||
left = self.get_field_width() * field_count |
||||
|
||||
length = (abs(value) - max(min_value, 0)) * unit_size |
||||
# top is 0 if value is negative |
||||
top = bottom - ((max(value, 0) - min_value) * unit_size) |
||||
if self.stack == 'side': |
||||
left += bar_width * dataset_count |
||||
|
||||
rect_group = node(self.graph, "g", |
||||
{'class': 'bar vbar'}) |
||||
node(rect_group, 'rect', { |
||||
'x': left, |
||||
'y': top, |
||||
'width': bar_width, |
||||
'height': length, |
||||
'class': 'fill fill%s' % (dataset_count + 1), |
||||
}) |
||||
|
||||
self.make_datapoint_text( |
||||
rect_group, left + bar_width / 2.0, top - 6, value) |
||||
|
||||
|
||||
class HorizontalBar(Bar): |
||||
""" Horizontal bar graph """ |
||||
rotate_y_labels = True |
||||
show_x_guidelines = True |
||||
show_y_guidelines = False |
||||
right_align = right_font = True |
||||
|
||||
def get_x_labels(self): |
||||
return self.get_data_labels() |
||||
|
||||
def get_y_labels(self): |
||||
return self.get_field_labels() |
||||
|
||||
def y_label_offset(self, height): |
||||
return height / -2.0 |
||||
|
||||
def draw_data(self): |
||||
min_value = self.data_min() |
||||
|
||||
unit_size = float(self.graph_width) |
||||
unit_size -= self.font_size * 2 * self.right_font |
||||
unit_size /= max(self.get_data_values()) - min(self.get_data_values()) |
||||
|
||||
bar_gap = self.get_bar_gap(self.get_field_height()) |
||||
|
||||
bar_height = self.get_field_height() - bar_gap |
||||
if self.stack == 'side': |
||||
bar_height /= len(self.data) |
||||
|
||||
y_mod = (bar_height / 2) + (self.font_size / 2) |
||||
|
||||
for field_count, field in enumerate(self.fields): |
||||
for dataset_count, dataset in enumerate(self.data): |
||||
value = dataset['data'][field_count] |
||||
|
||||
top = self.graph_height - ( |
||||
self.get_field_height() * (field_count + 1)) |
||||
if self.stack == 'side': |
||||
top += (bar_height * dataset_count) |
||||
# cases (assume 0 = +ve): |
||||
# value min length left |
||||
# +ve +ve value.abs - min minvalue.abs |
||||
# +ve -ve value.abs - 0 minvalue.abs |
||||
# -ve -ve value.abs - 0 minvalue.abs + value |
||||
length = (abs(value) - max(min_value, 0)) * unit_size |
||||
# left is 0 if value is negative |
||||
left = (abs(min_value) + min(value, 0)) * unit_size |
||||
|
||||
rect_group = node(self.graph, "g", |
||||
{'class': 'bar hbar'}) |
||||
node(rect_group, 'rect', { |
||||
'x': left, |
||||
'y': top, |
||||
'width': length, |
||||
'height': bar_height, |
||||
'class': 'fill fill%s' % (dataset_count + 1), |
||||
}) |
||||
|
||||
self.make_datapoint_text(rect_group, |
||||
left + length + 5, top + y_mod, value, |
||||
"text-anchor: start; ") |
@ -1,127 +0,0 @@
|
||||
/* Base styles for pygal graphs */ |
||||
|
||||
* { |
||||
font-family: helvetica; |
||||
} |
||||
.svgBackground{ |
||||
fill: #fff; |
||||
} |
||||
.graphBackground{ |
||||
fill: #e8eef6; |
||||
fill-opacity: 0.7; |
||||
} |
||||
|
||||
/* graphs titles */ |
||||
.mainTitle{ |
||||
text-anchor: middle; |
||||
fill: #000000; |
||||
font-size: %(title_font_size)dpx; |
||||
font-weight: normal; |
||||
} |
||||
.subTitle{ |
||||
text-anchor: middle; |
||||
fill: #999999; |
||||
font-size: %(subtitle_font_size)dpx; |
||||
font-weight: normal; |
||||
} |
||||
|
||||
.axis{ |
||||
stroke: #000000; |
||||
stroke-width: 1px; |
||||
} |
||||
|
||||
.guideLines{ |
||||
stroke: #fff; |
||||
stroke-width: 1px; |
||||
stroke-dasharray: 5,5; |
||||
} |
||||
|
||||
.xAxisLabels{ |
||||
font-family: monospace; |
||||
text-anchor: middle; |
||||
fill: #000000; |
||||
font-size: %(x_label_font_size)dpx; |
||||
font-weight: normal; |
||||
} |
||||
|
||||
.yAxisLabels{ |
||||
font-family: monospace; |
||||
text-anchor: end; |
||||
fill: #000000; |
||||
font-size: %(y_label_font_size)dpx; |
||||
font-weight: normal; |
||||
} |
||||
|
||||
.xAxisTitle{ |
||||
text-anchor: middle; |
||||
fill: #ff0000; |
||||
font-size: %(x_title_font_size)dpx; |
||||
font-weight: normal; |
||||
} |
||||
|
||||
.yAxisTitle{ |
||||
fill: #ff0000; |
||||
text-anchor: middle; |
||||
font-size: %(y_title_font_size)dpx; |
||||
font-weight: normal; |
||||
} |
||||
|
||||
.dataPointLabel{ |
||||
text-anchor:middle; |
||||
font-size: 10px; |
||||
font-weight: normal; |
||||
} |
||||
|
||||
.staggerGuideLine{ |
||||
fill: none; |
||||
stroke: #000000; |
||||
stroke-width: 0.5px; |
||||
} |
||||
|
||||
.keyText{ |
||||
fill: #000000; |
||||
text-anchor:start; |
||||
font-size: %(key_font_size)dpx; |
||||
font-weight: normal; |
||||
} |
||||
|
||||
|
||||
.bar .dataPointLabel, .dot .dataPointLabel { |
||||
fill-opacity: 0; |
||||
-webkit-transition: 250ms; |
||||
} |
||||
|
||||
.bar:hover .dataPointLabel, .dot:hover .dataPointLabel { |
||||
fill-opacity: 0.9; |
||||
fill: #000000; |
||||
} |
||||
|
||||
.upGradientLight { |
||||
stop-opacity: 0.6; |
||||
} |
||||
|
||||
.downGradientLight { |
||||
stop-opacity: 0.9; |
||||
} |
||||
|
||||
.key, .fill { |
||||
fill-opacity: 0.9; |
||||
stroke: #fff; |
||||
stroke-width: 2px; |
||||
-webkit-transition: 250ms; |
||||
} |
||||
|
||||
.fill:hover { |
||||
stroke: #ddd; |
||||
fill-opacity: 0.7; |
||||
} |
||||
|
||||
.line { |
||||
fill: none; |
||||
stroke-width: 1.5px; |
||||
-webkit-transition: 250ms; |
||||
} |
||||
|
||||
.line:hover { |
||||
stroke-width: 3px; |
||||
} |
@ -1,11 +0,0 @@
|
||||
.dataPointLabel{ |
||||
fill: #000000; |
||||
text-anchor:middle; |
||||
font-size: %(datapoint_font_size)spx; |
||||
fill-opacity: 0; |
||||
} |
||||
|
||||
.pie:hover .dataPointLabel { |
||||
fill-opacity: 0.9; |
||||
fill: #000000; |
||||
} |
@ -1,600 +0,0 @@
|
||||
# -*- coding: utf-8 -*- |
||||
|
||||
""" |
||||
pygal.graph |
||||
|
||||
The base module for `pygal` classes. |
||||
""" |
||||
|
||||
from operator import itemgetter |
||||
from itertools import islice |
||||
from logging import getLogger |
||||
import os |
||||
|
||||
from lxml import etree |
||||
from pygal.util import node |
||||
from pygal.util.boundary import (calculate_right_margin, calculate_left_margin, |
||||
calculate_bottom_margin, calculate_top_margin, |
||||
calculate_offsets_bottom) |
||||
|
||||
log = getLogger('pygal') |
||||
|
||||
|
||||
def sort_multiple(arrays): |
||||
"sort multiple lists (of equal size) " |
||||
"using the first list for the sort keys" |
||||
tuples = zip(*arrays) |
||||
tuples.sort() |
||||
return zip(*tuples) |
||||
|
||||
|
||||
class Graph(object): |
||||
""" |
||||
Base object for generating SVG Graphs |
||||
|
||||
This class is only used as a superclass of specialized charts. Do not |
||||
attempt to use this class directly, unless creating a new chart type. |
||||
|
||||
For examples of how to subclass this class, see the existing specific |
||||
subclasses, such as svn.charts.Pie. |
||||
|
||||
* pygal.bar |
||||
* pygal.line |
||||
* pygal.pie |
||||
* pygal.plot |
||||
* pygal.time_series |
||||
|
||||
""" |
||||
ratio = .7 |
||||
width = 600 |
||||
show_x_guidelines = False |
||||
show_y_guidelines = True |
||||
show_data_values = True |
||||
min_scale_value = None |
||||
show_x_labels = True |
||||
stagger_x_labels = False |
||||
x_label_rotation = 0 |
||||
step_x_labels = 1 |
||||
step_include_first_x_label = True |
||||
show_y_labels = True |
||||
rotate_y_labels = False |
||||
stagger_y_labels = False |
||||
step_include_first_y_label = True |
||||
step_y_labels = 1 |
||||
scale_integers = False |
||||
show_x_title = False |
||||
x_title = 'X Field names' |
||||
show_y_title = False |
||||
# 'bt' for bottom to top; 'tb' for top to bottom |
||||
y_title_text_direction = 'bt' |
||||
y_title = 'Y Scale' |
||||
show_graph_title = False |
||||
graph_title = 'Graph Title' |
||||
show_graph_subtitle = False |
||||
graph_subtitle = 'Graph Subtitle' |
||||
key = True |
||||
# 'bottom' or 'right', |
||||
key_position = 'right' |
||||
|
||||
font_size = 12 |
||||
title_font_size = 16 |
||||
subtitle_font_size = 14 |
||||
x_label_font_size = 12 |
||||
x_title_font_size = 14 |
||||
y_label_font_size = 12 |
||||
y_title_font_size = 14 |
||||
key_font_size = 10 |
||||
key_box_size = 10 |
||||
add_popups = False |
||||
|
||||
top_align = top_font = right_align = right_font = 0 |
||||
stylesheet_names = ['graph.css'] |
||||
compress = False |
||||
colors = [ |
||||
"#2a4269", "#476fb2", "#38588e", "#698bc3", |
||||
"#69c38b", "#588e38", "#47b26f", "#42692a", |
||||
"#1a3259", "#375fa2", "#28487e", "#597bb3", |
||||
"#59b37b", "#487e28", "#37a25f", "#32591a"] |
||||
|
||||
def __init__(self, config={}): |
||||
"""Initialize the graph object with the graph settings.""" |
||||
if self.__class__ is Graph: |
||||
raise NotImplementedError("Graph is an abstract base class") |
||||
self.load_config(config) |
||||
self.clear_data() |
||||
|
||||
@property |
||||
def height(self): |
||||
return int(self.width * self.ratio) |
||||
|
||||
def load_config(self, config): |
||||
self.__dict__.update(config) |
||||
|
||||
def add_data(self, conf): |
||||
""" |
||||
Add data to the graph object. May be called several times to add |
||||
additional data sets. |
||||
|
||||
>>> data_sales_02 = [12, 45, 21] # doctest: +SKIP |
||||
>>> graph.add_data({ # doctest: +SKIP |
||||
... 'data': data_sales_02, |
||||
... 'title': 'Sales 2002' |
||||
... }) # doctest: +SKIP |
||||
""" |
||||
self.validate_data(conf) |
||||
self.process_data(conf) |
||||
self.data.append(conf) |
||||
|
||||
def validate_data(self, conf): |
||||
try: |
||||
assert(isinstance(conf['data'], (tuple, list))) |
||||
except TypeError: |
||||
raise TypeError( |
||||
"conf should be dictionary with 'data' and other items") |
||||
except AssertionError: |
||||
if not hasattr(conf['data'], '__iter__'): |
||||
raise TypeError( |
||||
"conf['data'] should be tuple or list or iterable") |
||||
|
||||
def process_data(self, data): |
||||
pass |
||||
|
||||
def clear_data(self): |
||||
""" |
||||
This method removes all data from the object so that you can |
||||
reuse it to create a new graph but with the same config options. |
||||
|
||||
>>> graph.clear_data() # doctest: +SKIP |
||||
""" |
||||
self.data = [] |
||||
|
||||
def burn(self): |
||||
""" |
||||
Process the template with the data and |
||||
config which has been set and return the resulting SVG. |
||||
|
||||
Raises ValueError when no data set has |
||||
been added to the graph object. |
||||
""" |
||||
|
||||
log.info("Burning %s graph" % self.__class__.__name__) |
||||
|
||||
if not self.data: |
||||
raise ValueError("No data available") |
||||
|
||||
if hasattr(self, 'calculations'): |
||||
self.calculations() |
||||
|
||||
self.start_svg() |
||||
self.calculate_graph_dimensions() |
||||
self.foreground = etree.Element("g") |
||||
|
||||
self.draw_graph() |
||||
self.draw_titles() |
||||
self.draw_legend() |
||||
self.draw_data() |
||||
|
||||
self.graph.append(self.foreground) |
||||
|
||||
data = etree.tostring( |
||||
self.root, pretty_print=True, |
||||
xml_declaration=True, encoding='utf-8') |
||||
if self.compress: |
||||
import zlib |
||||
data = zlib.compress(data) |
||||
|
||||
return data |
||||
|
||||
def max_y_label_width_px(self): |
||||
""" |
||||
Calculate the width of the widest Y label. This will be the |
||||
character height if the Y labels are rotated. |
||||
""" |
||||
if self.rotate_y_labels: |
||||
return self.font_size |
||||
|
||||
def draw_graph(self): |
||||
""" |
||||
The central logic for drawing the graph. |
||||
|
||||
Sets self.graph (the 'g' element in the SVG root) |
||||
""" |
||||
transform = 'translate (%s %s)' % (self.border_left, self.border_top) |
||||
self.graph = node(self.root, 'g', transform=transform) |
||||
self.back = node(self.graph, 'g', {'class': 'back'}) |
||||
axis = node(self.foreground, 'g', {'class': 'axis'}) |
||||
node(self.back, 'rect', { |
||||
'x': 0, |
||||
'y': 0, |
||||
'width': self.graph_width, |
||||
'height': self.graph_height, |
||||
'class': 'graphBackground' |
||||
}) |
||||
|
||||
#Axis |
||||
node(axis, 'path', { |
||||
'd': 'M 0 0 v%s' % self.graph_height, |
||||
'class': 'axis', |
||||
'id': 'xAxis' |
||||
}) |
||||
node(axis, 'path', { |
||||
'd': 'M 0 %s h%s' % (self.graph_height, self.graph_width), |
||||
'class': 'axis', |
||||
'id': 'yAxis' |
||||
}) |
||||
|
||||
self.draw_x_labels() |
||||
self.draw_y_labels() |
||||
|
||||
def make_datapoint_text(self, group, x, y, value, style=None): |
||||
""" |
||||
Add text for a datapoint |
||||
""" |
||||
if not self.show_data_values: |
||||
return |
||||
|
||||
e = node(group, 'text', { |
||||
'x': x, |
||||
'y': y, |
||||
'class': 'dataPointLabel'}) |
||||
e.text = str(value) |
||||
if style: |
||||
e.set('style', style) |
||||
|
||||
def x_label_offset(self, width): |
||||
return 0 |
||||
|
||||
def draw_x_labels(self): |
||||
"Draw the X axis labels" |
||||
if not self.show_x_labels: |
||||
return |
||||
|
||||
log.debug("Drawing x labels") |
||||
self.xlabels = node(self.graph, 'g', {'class': 'xLabels'}) |
||||
labels = self.get_x_labels() |
||||
count = len(labels) |
||||
labels = enumerate(iter(labels)) |
||||
start = int(not self.step_include_first_x_label) |
||||
labels = islice(labels, start, None, self.step_x_labels) |
||||
map(self.draw_x_label, labels) |
||||
self.draw_x_guidelines(self.field_width(), count) |
||||
|
||||
def draw_x_label(self, label): |
||||
label_width = self.field_width() |
||||
index, label = label |
||||
text = node(self.xlabels, 'text', {'class': 'xAxisLabels'}) |
||||
text.text = label |
||||
|
||||
x = index * label_width + self.x_label_offset(label_width) |
||||
y = self.graph_height + self.x_label_font_size + 3 |
||||
|
||||
if self.stagger_x_labels and (index % 2): |
||||
stagger = self.x_label_font_size + 5 |
||||
y += stagger |
||||
graph_height = self.graph_height |
||||
node(self.xlabels, 'path', { |
||||
'd': 'M%f %f v%d' % (x, graph_height, stagger), |
||||
'class': 'staggerGuideLine' |
||||
}) |
||||
|
||||
text.set('x', str(x)) |
||||
text.set('y', str(y)) |
||||
|
||||
if self.x_label_rotation: |
||||
transform = 'rotate(%d %d %d) translate(0 -%d)' % \ |
||||
(-self.x_label_rotation, x, y - self.x_label_font_size, |
||||
self.x_label_font_size / 4) |
||||
text.set('transform', transform) |
||||
text.set('style', 'text-anchor: end') |
||||
else: |
||||
text.set('style', 'text-anchor: middle') |
||||
|
||||
def y_label_offset(self, height): |
||||
""" |
||||
Return an offset for drawing the y label. Currently returns 0. |
||||
""" |
||||
# Consider height/2 to center within the field. |
||||
return 0 |
||||
|
||||
def get_field_width(self): |
||||
divisor = (len(self.get_x_labels()) - self.right_align) |
||||
if divisor == 0: |
||||
return 0 |
||||
return float( |
||||
self.graph_width - self.font_size * 2 * self.right_font) / divisor |
||||
field_width = get_field_width |
||||
|
||||
def get_field_height(self): |
||||
divisor = (len(self.get_y_labels()) - self.top_align) |
||||
if divisor == 0: |
||||
return 0 |
||||
return float( |
||||
self.graph_height - self.font_size * 2 * self.top_font) / divisor |
||||
field_height = get_field_height |
||||
|
||||
def draw_y_labels(self): |
||||
"Draw the Y axis labels" |
||||
if not self.show_y_labels: |
||||
return |
||||
log.debug("Drawing y labels") |
||||
|
||||
self.ylabels = node(self.graph, 'g', {'class': 'yLabels'}) |
||||
labels = self.get_y_labels() |
||||
count = len(labels) |
||||
|
||||
labels = enumerate(iter(labels)) |
||||
start = int(not self.step_include_first_y_label) |
||||
labels = islice(labels, start, None, self.step_y_labels) |
||||
map(self.draw_y_label, labels) |
||||
self.draw_y_guidelines(self.field_height(), count) |
||||
|
||||
def get_y_offset(self): |
||||
result = self.graph_height + self.y_label_offset(self.field_height()) |
||||
if not self.rotate_y_labels: |
||||
result += self.font_size / 1.2 |
||||
return result |
||||
y_offset = property(get_y_offset) |
||||
|
||||
def draw_y_label(self, label): |
||||
label_height = self.field_height() |
||||
index, label = label |
||||
text = node(self.ylabels, 'text', {'class': 'yAxisLabels'}) |
||||
text.text = label |
||||
|
||||
y = self.y_offset - (label_height * index) |
||||
x = {True: 0, False: -3}[self.rotate_y_labels] |
||||
|
||||
if self.stagger_y_labels and (index % 2): |
||||
stagger = self.y_label_font_size + 5 |
||||
x -= stagger |
||||
node(self.ylabels, 'path', { |
||||
'd': 'M%f %f h%d' % (x, y, stagger), |
||||
'class': 'staggerGuideLine' |
||||
}) |
||||
|
||||
text.set('x', str(x)) |
||||
text.set('y', str(y)) |
||||
|
||||
if self.rotate_y_labels: |
||||
transform = 'translate(-%d 0) rotate (90 %d %d)' % \ |
||||
(self.font_size, x, y) |
||||
text.set('transform', transform) |
||||
text.set('style', 'text-anchor: middle') |
||||
else: |
||||
text.set('y', str(y - self.y_label_font_size / 2)) |
||||
text.set('style', 'text-anchor: end') |
||||
|
||||
def draw_x_guidelines(self, label_height, count): |
||||
"Draw the X-axis guidelines" |
||||
if not self.show_x_guidelines: |
||||
return |
||||
log.debug("Drawing x guidelines") |
||||
self.xguidelines = node(self.graph, 'g', {'class': 'xGuideLines'}) |
||||
# skip the first one |
||||
for count in range(1, count): |
||||
start = label_height * count |
||||
stop = self.graph_height |
||||
node(self.xguidelines, 'path', { |
||||
'd': 'M %s 0 v%s' % (start, stop), |
||||
'class': 'guideLines'}) |
||||
|
||||
def draw_y_guidelines(self, label_height, count): |
||||
"Draw the Y-axis guidelines" |
||||
if not self.show_y_guidelines: |
||||
return |
||||
log.debug("Drawing y guidelines") |
||||
self.yguidelines = node(self.graph, 'g', {'class': 'yGuideLines'}) |
||||
for count in range(1, count): |
||||
start = self.graph_height - label_height * count |
||||
stop = self.graph_width |
||||
node(self.yguidelines, 'path', { |
||||
'd': 'M 0 %s h%s' % (start, stop), |
||||
'class': 'guideLines'}) |
||||
|
||||
def draw_titles(self): |
||||
"Draws the graph title and subtitle" |
||||
log.debug("Drawing titles") |
||||
if self.show_graph_title: |
||||
self.draw_graph_title() |
||||
if self.show_graph_subtitle: |
||||
self.draw_graph_subtitle() |
||||
if self.show_x_title: |
||||
self.draw_x_title() |
||||
if self.show_y_title: |
||||
self.draw_y_title() |
||||
|
||||
def draw_graph_title(self): |
||||
text = node(self.root, 'text', { |
||||
'x': self.width / 2, |
||||
'y': self.title_font_size, |
||||
'class': 'mainTitle'}) |
||||
text.text = self.graph_title |
||||
|
||||
def draw_graph_subtitle(self): |
||||
y_subtitle_options = [self.subtitle_font_size, |
||||
self.title_font_size + 10] |
||||
y_subtitle = y_subtitle_options[self.show_graph_title] |
||||
text = node(self.root, 'text', { |
||||
'x': self.width / 2, |
||||
'y': y_subtitle, |
||||
'class': 'subTitle', |
||||
}) |
||||
text.text = self.graph_title |
||||
|
||||
def draw_x_title(self): |
||||
log.debug("Drawing x title") |
||||
y = self.graph_height + self.border_top + self.x_title_font_size |
||||
if self.show_x_labels: |
||||
y_size = self.x_label_font_size + 5 |
||||
if self.stagger_x_labels: |
||||
y_size *= 2 |
||||
y += y_size |
||||
x = self.width / 2 |
||||
|
||||
text = node(self.root, 'text', { |
||||
'x': x, |
||||
'y': y, |
||||
'class': 'xAxisTitle', |
||||
}) |
||||
text.text = self.x_title |
||||
|
||||
def draw_y_title(self): |
||||
log.debug("Drawing y title") |
||||
x = self.y_title_font_size |
||||
if self.y_title_text_direction == 'bt': |
||||
x += 3 |
||||
rotate = -90 |
||||
else: |
||||
x -= 3 |
||||
rotate = 90 |
||||
y = self.height / 2 |
||||
text = node(self.root, 'text', { |
||||
'x': x, |
||||
'y': y, |
||||
'class': 'yAxisTitle', |
||||
}) |
||||
text.text = self.y_title |
||||
text.set('transform', 'rotate(%d, %s, %s)' % (rotate, x, y)) |
||||
|
||||
def keys(self): |
||||
return map(itemgetter('title'), self.data) |
||||
|
||||
def draw_legend(self): |
||||
if not self.key: |
||||
return |
||||
log.debug("Drawing legend") |
||||
|
||||
group = node(self.root, 'g') |
||||
|
||||
for key_count, key_name in enumerate(self.keys()): |
||||
y_offset = (self.key_box_size * key_count) + (key_count * 5) |
||||
node(group, 'rect', { |
||||
'x': 0, |
||||
'y': y_offset, |
||||
'width': self.key_box_size, |
||||
'height': self.key_box_size, |
||||
'class': 'key key%s' % key_count, |
||||
}) |
||||
text = node(group, 'text', { |
||||
'x': self.key_box_size + 5, |
||||
'y': y_offset + self.key_box_size, |
||||
'class': 'keyText'}) |
||||
text.text = key_name |
||||
|
||||
if self.key_position == 'right': |
||||
x_offset = self.graph_width + self.border_left + 10 |
||||
y_offset = self.border_top + 20 |
||||
if self.key_position == 'bottom': |
||||
x_offset, y_offset = calculate_offsets_bottom(self) |
||||
group.set('transform', 'translate(%d %d)' % (x_offset, y_offset)) |
||||
|
||||
def add_defs(self, defs): |
||||
""" |
||||
Override and place code to add defs here. TODO: what are defs? |
||||
""" |
||||
for id in range(len(self.colors)): |
||||
idn = 'line-color-%d' % id |
||||
light = node(defs, 'linearGradient', { |
||||
'id': idn, |
||||
'x1': 0, |
||||
'x2': '50%', |
||||
'y1': 0, |
||||
'y2': '100%'}) |
||||
node(light, 'stop', |
||||
{'class': 'upGradientLight %s' % idn, 'offset': 0}) |
||||
node(light, 'stop', |
||||
{'class': 'downGradientLight %s' % idn, 'offset': '100%'}) |
||||
|
||||
shadow = node(defs, 'linearGradient', { |
||||
'id': 'shadow', |
||||
'x1': 0, |
||||
'x2': '100%', |
||||
'y1': 0, |
||||
'y2': 0}) |
||||
node(shadow, 'stop', |
||||
{'offset': 0, 'stop-color': '#aaa', 'stop-opacity': 0.7}) |
||||
node(shadow, 'stop', |
||||
{'offset': '1%', 'stop-color': '#fff', 'stop-opacity': 1}) |
||||
node(shadow, 'stop', |
||||
{'offset': '99%', 'stop-color': '#fff', 'stop-opacity': 1}) |
||||
node(shadow, 'stop', |
||||
{'offset': '100%', 'stop-color': '#aaa', 'stop-opacity': .7}) |
||||
|
||||
def start_svg(self): |
||||
"Base SVG Document Creation" |
||||
log.debug("Creating root node") |
||||
svg_ns = 'http://www.w3.org/2000/svg' |
||||
nsmap = { |
||||
None: svg_ns, |
||||
'xlink': 'http://www.w3.org/1999/xlink', |
||||
} |
||||
self.root = etree.Element("{%s}svg" % svg_ns, attrib={ |
||||
'viewBox': '0 0 %d %d' % (self.width, self.height) |
||||
}, nsmap=nsmap) |
||||
|
||||
if hasattr(self, 'style_sheet_href'): |
||||
pi = etree.ProcessingInstruction( |
||||
'xml-stylesheet', |
||||
'href="%s" type="text/css"' % self.style_sheet_href |
||||
) |
||||
self.root.addprevious(pi) |
||||
|
||||
comment_strings = ( |
||||
u'Generated with pygal ©Kozea 2011', |
||||
'Based upon SVG.Graph by Jason R. Coombs', |
||||
) |
||||
map(self.root.append, map(etree.Comment, comment_strings)) |
||||
|
||||
defs = node(self.root, 'defs') |
||||
self.add_defs(defs) |
||||
|
||||
if not hasattr(self, 'style_sheet_href'): |
||||
self.root.append(etree.Comment( |
||||
' include default stylesheet if none specified ')) |
||||
style = node(defs, 'style', type='text/css') |
||||
style.text = '' |
||||
opts = self.__dict__.copy() |
||||
opts.update(Graph.__dict__) |
||||
opts.update(self.__class__.__dict__) |
||||
for stylesheet in self.stylesheet_names: |
||||
with open( |
||||
os.path.join(os.path.dirname(__file__), 'css', |
||||
stylesheet)) as f: |
||||
style.text += f.read() % opts |
||||
for n, color in enumerate(self.colors): |
||||
style.text += ( |
||||
""" |
||||
.key%d, .fill%d, .dot%d { |
||||
fill: url(#line-color-%d); |
||||
} |
||||
.key%d, .line%d { |
||||
stroke: url(#line-color-%d); |
||||
} |
||||
|
||||
.line-color-%d { |
||||
stop-color: %s; |
||||
} |
||||
|
||||
""" % (n, n, n, n, n, n, n, n, color)) |
||||
|
||||
if hasattr(self, 'stylesheet_file'): |
||||
with open(self.stylesheet_file) as f: |
||||
style.text += f.read() % opts |
||||
|
||||
self.root.append(etree.Comment('SVG Background')) |
||||
node(self.root, 'rect', { |
||||
'width': self.width, |
||||
'height': self.height, |
||||
'x': 0, |
||||
'y': 0, |
||||
'class': 'svgBackground'}) |
||||
|
||||
def calculate_graph_dimensions(self): |
||||
log.debug("Computing sizes") |
||||
self.border_right = calculate_right_margin(self) |
||||
self.border_top = calculate_top_margin(self) |
||||
self.border_left = calculate_left_margin(self) |
||||
self.border_bottom = calculate_bottom_margin(self) |
||||
|
||||
self.graph_width = self.width - self.border_left - self.border_right |
||||
self.graph_height = self.height - self.border_top - self.border_bottom |
@ -1,172 +0,0 @@
|
||||
# -*- coding: utf-8 -*- |
||||
from operator import itemgetter, add |
||||
|
||||
from pygal.util import node, flatten, float_range |
||||
from pygal.graph import Graph |
||||
|
||||
|
||||
class Line(Graph): |
||||
"""Line Graph""" |
||||
|
||||
"""Show a small circle on the graph where the line goes from one point to |
||||
the next""" |
||||
show_data_points = True |
||||
show_data_values = True |
||||
"""Accumulates each data set. (i.e. Each point increased by sum of all |
||||
previous series at same point).""" |
||||
stacked = False |
||||
"Fill in the area under the plot" |
||||
area_fill = False |
||||
|
||||
scale_divisions = None |
||||
|
||||
# override some defaults |
||||
top_align = top_font = right_align = right_font = True |
||||
|
||||
stylesheet_names = Graph.stylesheet_names + ['plot.css'] |
||||
|
||||
def max_value(self): |
||||
data = map(itemgetter('data'), self.data) |
||||
if self.stacked: |
||||
data = self.get_cumulative_data() |
||||
return max(flatten(data)) |
||||
|
||||
def min_value(self): |
||||
if self.min_scale_value: |
||||
return self.min_scale_value |
||||
data = map(itemgetter('data'), self.data) |
||||
if self.stacked: |
||||
data = self.get_cumulative_data() |
||||
return min(flatten(data)) |
||||
|
||||
def get_cumulative_data(self): |
||||
"""Get the data as it will be charted. The first set will be |
||||
the actual first data set. The second will be the sum of the |
||||
first and the second, etc.""" |
||||
sets = map(itemgetter('data'), self.data) |
||||
if not sets: |
||||
return |
||||
sum = sets.pop(0) |
||||
yield sum |
||||
while sets: |
||||
sum = map(add, sets.pop(0)) |
||||
yield sum |
||||
|
||||
def get_x_labels(self): |
||||
return self.fields |
||||
|
||||
def calculate_left_margin(self): |
||||
super(self.__class__, self).calculate_left_margin() |
||||
label_left = len(self.fields[0]) / 2 * self.font_size * 0.6 |
||||
self.border_left = max(label_left, self.border_left) |
||||
|
||||
# TODO: cache this |
||||
def get_y_label_values(self): |
||||
max_value = self.max_value() |
||||
min_value = self.min_value() |
||||
range = max_value - min_value |
||||
|
||||
scale_division = self.scale_divisions or (.105 * range) |
||||
|
||||
if self.scale_integers: |
||||
scale_division = round(scale_division) |
||||
|
||||
scale_division = scale_division or 1 # prevent / 0 |
||||
|
||||
if max_value % scale_division == 0: |
||||
max_value += scale_division |
||||
labels = tuple(float_range(min_value, max_value, scale_division)) |
||||
if self.scale_integers: |
||||
labels = map(int, labels) |
||||
return labels |
||||
|
||||
def get_y_labels(self): |
||||
return map(str, self.get_y_label_values()) |
||||
|
||||
def calc_coords(self, field, value, width=None, height=None): |
||||
if width is None: |
||||
width = self.field_width |
||||
if height is None: |
||||
height = self.field_height |
||||
coords = dict( |
||||
x=width * field, |
||||
y=self.graph_height - value * height, |
||||
) |
||||
return coords |
||||
|
||||
def draw_data(self): |
||||
if len(self.data) == 0: |
||||
return |
||||
|
||||
min_value = self.min_value() |
||||
|
||||
field_height = self.graph_height - self.font_size * 2 * self.top_font |
||||
y_label_values = self.get_y_label_values() |
||||
y_label_span = max(y_label_values) - min(y_label_values) |
||||
|
||||
field_height /= float(y_label_span) or 1 |
||||
field_width = self.field_width() |
||||
|
||||
prev_sum = [0] * len(self.fields) |
||||
cum_sum = [-min_value] * len(self.fields) |
||||
|
||||
coord_format = lambda c: '%(x)s %(y)s' % c |
||||
lines = node(self.graph, "g", {'class': 'lines'}) |
||||
for line_n, data in reversed(list(enumerate(self.data))): |
||||
if not self.stacked: |
||||
cum_sum = [-min_value] * len(self.fields) |
||||
|
||||
cum_sum = map(add, cum_sum, data['data']) |
||||
get_coords = lambda (i, val): self.calc_coords( |
||||
i, val, field_width, field_height) |
||||
coords = map(get_coords, enumerate(cum_sum)) |
||||
paths = map(coord_format, coords) |
||||
line_path = ' '.join(paths) |
||||
linegroup = node(lines, "g", {'class': 'linegroup%d' % line_n}) |
||||
|
||||
if self.area_fill: |
||||
# to draw the area, we'll use the line above, followed by |
||||
# tracing the bottom from right to left |
||||
if self.stacked: |
||||
prev_sum_rev = list(enumerate(prev_sum)).reversed() |
||||
coords = map(get_coords, prev_sum_rev) |
||||
paths = map(coord_format, coords) |
||||
area_path = ' '.join(paths) |
||||
origin = paths[-1] |
||||
else: |
||||
area_path = "V%s" % self.graph_height |
||||
origin = coord_format(get_coords((0, 0))) |
||||
|
||||
node(linegroup, 'path', { |
||||
'class': 'fill fill%s' % line_n, |
||||
'd': ' '.join( |
||||
('M', origin, 'L', line_path, area_path, 'Z')), |
||||
}) |
||||
|
||||
# now draw the line itself |
||||
node(linegroup, 'path', { |
||||
'd': 'M%s L%s' % (paths[0], line_path), |
||||
'class': 'line line%s' % line_n, |
||||
}) |
||||
dots = node(linegroup, "g", |
||||
{'class': 'dots'}) |
||||
if self.show_data_points or self.show_data_values: |
||||
for i, value in enumerate(cum_sum): |
||||
dot = node(dots, "g", |
||||
{'class': 'dot'}) |
||||
if self.show_data_points: |
||||
node( |
||||
dot, |
||||
'circle', |
||||
{'class': 'dot%s' % line_n}, |
||||
cx=str(field_width * i), |
||||
cy=str(self.graph_height - value * field_height), |
||||
r='5', |
||||
) |
||||
self.make_datapoint_text( |
||||
dot, |
||||
field_width * i, |
||||
self.graph_height - value * field_height - 6, |
||||
value + min_value |
||||
) |
||||
prev_sum = list(cum_sum) |
@ -1,280 +0,0 @@
|
||||
import math |
||||
import itertools |
||||
from pygal.util import node |
||||
from pygal.graph import Graph |
||||
|
||||
|
||||
def robust_add(a, b): |
||||
"Add numbers a and b, treating None as 0" |
||||
if a is None: |
||||
a = 0 |
||||
if b is None: |
||||
b = 0 |
||||
return a + b |
||||
|
||||
RADIANS = math.pi / 180 |
||||
|
||||
|
||||
class Pie(Graph): |
||||
""" |
||||
A presentation-quality SVG pie graph |
||||
|
||||
Synopsis |
||||
======== |
||||
|
||||
from pygal.pie import Pie |
||||
fields = ['Jan', 'Feb', 'Mar'] |
||||
|
||||
data_sales_02 = [12, 45, 21] |
||||
|
||||
graph = Pie(dict( |
||||
height = 500, |
||||
width = 300, |
||||
fields = fields)) |
||||
graph.add_data({'data': data_sales_02, 'title': 'Sales 2002'}) |
||||
print "Content-type" image/svg+xml\r\n\r\n' |
||||
print graph.burn() |
||||
|
||||
Description |
||||
=========== |
||||
This object aims to allow you to easily create high quality |
||||
SVG pie graphs. You can either use the default style sheet |
||||
or supply your own. Either way there are many options which can |
||||
be configured to give you control over how the graph is |
||||
generated - with or without a key, display percent on pie chart, |
||||
title, subtitle etc. |
||||
""" |
||||
|
||||
"if true, displays a drop shadow for the chart" |
||||
show_shadow = False |
||||
"Sets the offset of the shadow from the pie chart" |
||||
shadow_offset = 10 |
||||
|
||||
show_data_labels = True |
||||
"If true, display the actual field values in the data labels" |
||||
show_actual_values = False |
||||
|
||||
("If true, display the percentage value of" |
||||
"each pie wedge in the data labels") |
||||
show_percent = True |
||||
|
||||
"If true, display the labels in the key" |
||||
show_key_data_labels = True |
||||
"If true, display the actual value of the field in the key" |
||||
show_key_actual_values = True |
||||
"If true, display the percentage value of the wedges in the key" |
||||
show_key_percent = False |
||||
|
||||
"If true, explode the pie (put space between the wedges)" |
||||
expanded = False |
||||
"If true, expand the largest pie wedge" |
||||
expand_greatest = False |
||||
"The amount of space between expanded wedges" |
||||
expand_gap = 10 |
||||
|
||||
show_x_labels = False |
||||
show_y_labels = False |
||||
|
||||
"The font size of the data point labels" |
||||
datapoint_font_size = 12 |
||||
|
||||
stylesheet_names = Graph.stylesheet_names + ['pie.css'] |
||||
|
||||
def add_data(self, data_descriptor): |
||||
""" |
||||
Add a data set to the graph |
||||
|
||||
>>> graph.add_data({data:[1,2,3,4]}) # doctest: +SKIP |
||||
|
||||
Note that a 'title' key is ignored. |
||||
|
||||
Multiple calls to add_data will sum the elements, and the pie will |
||||
display the aggregated data. e.g. |
||||
|
||||
>>> graph.add_data({data:[1,2,3,4]}) # doctest: +SKIP |
||||
>>> graph.add_data({data:[2,3,5,7]}) # doctest: +SKIP |
||||
|
||||
is the same as: |
||||
|
||||
>>> graph.add_data({data:[3,5,8,11]}) # doctest: +SKIP |
||||
|
||||
If data is added of with differing lengths, the corresponding |
||||
values will be assumed to be zero. |
||||
|
||||
>>> graph.add_data({data:[1,2,3,4]}) # doctest: +SKIP |
||||
>>> graph.add_data({data:[5,7]}) # doctest: +SKIP |
||||
|
||||
is the same as: |
||||
|
||||
>>> graph.add_data({data:[5,7]}) # doctest: +SKIP |
||||
>>> graph.add_data({data:[1,2,3,4]}) # doctest: +SKIP |
||||
|
||||
and |
||||
|
||||
>>> graph.add_data({data:[6,9,3,4]}) # doctest: +SKIP |
||||
""" |
||||
pairs = itertools.izip_longest(self.data, data_descriptor['data']) |
||||
self.data = list(itertools.starmap(robust_add, pairs)) |
||||
|
||||
# def add_defs(self, defs): |
||||
# "Add svg definitions" |
||||
# node( |
||||
# defs, |
||||
# 'filter', |
||||
# id='dropshadow', |
||||
# width='1.2', |
||||
# height='1.2', |
||||
# ) |
||||
# node( |
||||
# defs, |
||||
# 'feGaussianBlur', |
||||
# stdDeviation='4', |
||||
# result='blur', |
||||
# ) |
||||
|
||||
def draw_graph(self): |
||||
"Here we don't need the graph (consider refactoring)" |
||||
pass |
||||
|
||||
def get_y_labels(self): |
||||
"Definitely consider refactoring" |
||||
return [''] |
||||
|
||||
def get_x_labels(self): |
||||
"Okay. I'll refactor after this" |
||||
return [''] |
||||
|
||||
def keys(self): |
||||
total = sum(self.data) |
||||
|
||||
def key(field, value): |
||||
result = [field] |
||||
result.append('[%s]' % value) |
||||
if self.show_key_percent: |
||||
percent = str(round((value / total * 100))) + '%' |
||||
result.append(percent) |
||||
return ' '.join(result) |
||||
return map(key, self.fields, self.data) |
||||
|
||||
def draw_data(self): |
||||
self.graph = node(self.root, 'g') |
||||
background = node(self.graph, 'g') |
||||
# midground is somewhere between the background and the foreground |
||||
midground = node(self.graph, 'g') |
||||
|
||||
is_expanded = (self.expanded or self.expand_greatest) |
||||
diameter = min(self.graph_width, self.graph_height) |
||||
# the following assumes int(True)==1 and int(False)==0 |
||||
diameter -= self.expand_gap * int(is_expanded) |
||||
diameter -= self.datapoint_font_size * int(self.show_data_labels) |
||||
diameter -= 10 * int(self.show_shadow) |
||||
radius = diameter / 2.0 |
||||
|
||||
xoff = (self.width - diameter) / 2 |
||||
yoff = (self.height - self.border_bottom - diameter) |
||||
yoff -= 10 * int(self.show_shadow) |
||||
transform = 'translate(%s %s)' % (xoff, yoff) |
||||
self.graph.set('transform', transform) |
||||
|
||||
total = sum(self.data) |
||||
max_value = max(self.data) |
||||
|
||||
percent_scale = 100.0 / total |
||||
|
||||
prev_percent = 0 |
||||
rad_mult = 3.6 * RADIANS |
||||
for index, (field, value) in enumerate(zip(self.fields, self.data)): |
||||
percent = percent_scale * value |
||||
|
||||
radians = prev_percent * rad_mult |
||||
x_start = radius + (math.sin(radians) * radius) |
||||
y_start = radius - (math.cos(radians) * radius) |
||||
radians = (prev_percent + percent) * rad_mult |
||||
x_end = radius + (math.sin(radians) * radius) |
||||
y_end = radius - (math.cos(radians) * radius) |
||||
percent_greater_fifty = int(percent >= 50) |
||||
path = "M%s,%s L%s,%s A%s,%s 0, %s, 1, %s %s Z" % ( |
||||
radius, radius, x_start, y_start, radius, radius, |
||||
percent_greater_fifty, x_end, y_end) |
||||
|
||||
wedge_group = node(self.foreground, "g", |
||||
{'class': 'pie'}) |
||||
wedge = node( |
||||
wedge_group, |
||||
'path', { |
||||
'd': path, |
||||
'class': 'fill fill%s' % (index + 1)} |
||||
) |
||||
|
||||
translate = None |
||||
tx = 0 |
||||
ty = 0 |
||||
half_percent = prev_percent + percent / 2 |
||||
radians = half_percent * rad_mult |
||||
|
||||
if self.show_shadow: |
||||
shadow = node( |
||||
background, |
||||
'path', |
||||
d=path, |
||||
filter='url(#dropshadow)', |
||||
style='fill: #ccc; stroke: none', |
||||
) |
||||
clear = node( |
||||
midground, |
||||
'path', |
||||
d=path, |
||||
# note, this probably only works when the background |
||||
# is also #fff |
||||
# consider getting the style from the stylesheet |
||||
style="fill:#fff; stroke:none;", |
||||
) |
||||
|
||||
if self.expanded or (self.expand_greatest and value == max_value): |
||||
tx = (math.sin(radians) * self.expand_gap) |
||||
ty = -(math.cos(radians) * self.expand_gap) |
||||
translate = "translate(%s %s)" % (tx, ty) |
||||
wedge.set('transform', translate) |
||||
clear.set('transform', translate) |
||||
|
||||
if self.show_shadow: |
||||
shadow_tx = self.shadow_offset + tx |
||||
shadow_ty = self.shadow_offset + ty |
||||
translate = 'translate(%s %s)' % (shadow_tx, shadow_ty) |
||||
shadow.set('transform', translate) |
||||
|
||||
if self.show_data_labels and value != 0: |
||||
label = [] |
||||
if self.show_key_data_labels: |
||||
label.append(field) |
||||
if self.show_actual_values: |
||||
label.append('[%s]' % value) |
||||
if self.show_percent: |
||||
label.append('%d%%' % round(percent)) |
||||
label = ' '.join(label) |
||||
|
||||
msr = math.sin(radians) |
||||
mcr = math.cos(radians) |
||||
tx = radius + (msr * radius) |
||||
ty = radius - (mcr * radius) |
||||
|
||||
if self.expanded or ( |
||||
self.expand_greatest and value == max_value): |
||||
tx += (msr * self.expand_gap) |
||||
ty -= (mcr * self.expand_gap) |
||||
|
||||
label_node = node( |
||||
wedge_group, |
||||
'text', |
||||
{ |
||||
'x': tx, |
||||
'y': ty, |
||||
'class': 'dataPointLabel', |
||||
} |
||||
) |
||||
label_node.text = label |
||||
|
||||
prev_percent += percent |
||||
|
||||
def round(self, val, to): |
||||
return round(val, to) |
@ -1,276 +0,0 @@
|
||||
# -*- coding: utf-8 -*- |
||||
|
||||
"plot.py" |
||||
|
||||
import sys |
||||
from itertools import izip, count, chain |
||||
from lxml import etree |
||||
|
||||
from pygal.util import node, float_range |
||||
from pygal.graph import Graph |
||||
|
||||
|
||||
def get_pairs(i): |
||||
i = iter(i) |
||||
while True: |
||||
yield i.next(), i.next() |
||||
|
||||
# I'm not sure how this is more beautiful than ugly. |
||||
if sys.version >= '3': |
||||
def apply(func): |
||||
return func() |
||||
|
||||
|
||||
class Plot(Graph): |
||||
"""Graph of scalar data.""" |
||||
|
||||
top_align = right_align = top_font = right_font = 1 |
||||
|
||||
# Determines the scaling for the Y axis divisions. |
||||
# graph.scale_y_divisions = 0.5 |
||||
# would cause the graph to attempt to generate labels stepped by 0.5; EG: |
||||
# 0, 0.5, 1, 1.5, 2, ... |
||||
scale_y_divisions = None |
||||
# Make the X axis labels integers |
||||
scale_x_integers = False |
||||
# Make the Y axis labels integers |
||||
scale_y_integers = False |
||||
# Fill the area under the line |
||||
area_fill = False |
||||
# Show a small circle on the graph where the line |
||||
# goes from one point to the next. |
||||
show_data_points = True |
||||
# Indicate whether the lines should be drawn between points |
||||
draw_lines_between_points = True |
||||
# Set the minimum value of the X axis |
||||
min_x_value = None |
||||
# Set the minimum value of the Y axis |
||||
min_y_value = None |
||||
# Set the maximum value of the X axis |
||||
max_x_value = None |
||||
# Set the maximum value of the Y axis |
||||
max_y_value = None |
||||
|
||||
stacked = False |
||||
|
||||
stylesheet_names = Graph.stylesheet_names + ['plot.css'] |
||||
|
||||
@apply |
||||
def scale_x_divisions(): |
||||
"""Determines the scaling for the X axis divisions. |
||||
|
||||
graph.scale_x_divisions = 2 |
||||
would cause the graph to attempt |
||||
to generate labels stepped by 2; EG: |
||||
0,2,4,6,8...""" |
||||
|
||||
def fget(self): |
||||
return getattr(self, '_scale_x_divisions', None) |
||||
|
||||
def fset(self, val): |
||||
self._scale_x_divisions = val |
||||
return property(**locals()) |
||||
|
||||
def validate_data(self, data): |
||||
if len(data['data']) % 2 != 0: |
||||
raise ValueError( |
||||
"Expecting x,y pairs for data points for %s." % |
||||
self.__class__.__name__) |
||||
|
||||
def process_data(self, data): |
||||
pairs = list(get_pairs(data['data'])) |
||||
pairs.sort() |
||||
data['data'] = zip(*pairs) |
||||
|
||||
def calculate_left_margin(self): |
||||
super(Plot, self).calculate_left_margin() |
||||
label_left = len(str( |
||||
self.get_x_labels()[0])) / 2 * self.font_size * 0.6 |
||||
self.border_left = max(label_left, self.border_left) |
||||
|
||||
def calculate_right_margin(self): |
||||
super(Plot, self).calculate_right_margin() |
||||
label_right = len(str( |
||||
self.get_x_labels()[-1])) / 2 * self.font_size * 0.6 |
||||
self.border_right = max(label_right, self.border_right) |
||||
|
||||
def data_max(self, axis): |
||||
data_index = getattr(self, '%s_data_index' % axis) |
||||
max_value = max(chain( |
||||
*map(lambda set: set['data'][data_index], self.data))) |
||||
# above is same as |
||||
#max_value = max(map(lambda set: |
||||
# max(set['data'][data_index]), self.data)) |
||||
spec_max = getattr(self, 'max_%s_value' % axis) |
||||
# Python 3 doesn't allow comparing None to int, so use -∞ |
||||
if spec_max is None: |
||||
spec_max = float('-Inf') |
||||
max_value = max(max_value, spec_max) |
||||
return max_value |
||||
|
||||
def data_min(self, axis): |
||||
data_index = getattr(self, '%s_data_index' % axis) |
||||
min_value = min(chain( |
||||
*map(lambda set: set['data'][data_index], self.data))) |
||||
spec_min = getattr(self, 'min_%s_value' % axis) |
||||
if spec_min is not None: |
||||
min_value = min(min_value, spec_min) |
||||
return min_value |
||||
|
||||
x_data_index = 0 |
||||
y_data_index = 1 |
||||
|
||||
def data_range(self, axis): |
||||
side = {'x': 'right', 'y': 'top'}[axis] |
||||
|
||||
min_value = self.data_min(axis) |
||||
max_value = self.data_max(axis) |
||||
range = max_value - min_value |
||||
|
||||
side_pad = range / 20.0 or 10 |
||||
scale_range = (max_value + side_pad) - min_value |
||||
|
||||
scale_division = getattr( |
||||
self, 'scale_%s_divisions' % axis) or (scale_range / 10.0) |
||||
|
||||
if getattr(self, 'scale_%s_integers' % axis): |
||||
scale_division = round(scale_division) or 1 |
||||
|
||||
return min_value, max_value, scale_division |
||||
|
||||
def x_range(self): |
||||
return self.data_range('x') |
||||
|
||||
def y_range(self): |
||||
return self.data_range('y') |
||||
|
||||
def get_data_values(self, axis): |
||||
min_value, max_value, scale_division = self.data_range(axis) |
||||
return tuple(float_range(*self.data_range(axis))) |
||||
|
||||
def get_x_values(self): |
||||
return self.get_data_values('x') |
||||
|
||||
def get_y_values(self): |
||||
return self.get_data_values('y') |
||||
|
||||
def get_x_labels(self): |
||||
return map(str, self.get_x_values()) |
||||
|
||||
def get_y_labels(self): |
||||
return map(str, self.get_y_values()) |
||||
|
||||
def field_size(self, axis): |
||||
size = {'x': 'width', 'y': 'height'}[axis] |
||||
side = {'x': 'right', 'y': 'top'}[axis] |
||||
values = getattr(self, 'get_%s_values' % axis)() |
||||
max_d = self.data_max(axis) |
||||
dx = ( |
||||
float(max_d - values[-1]) / (values[-1] - values[-2]) |
||||
if len(values) > 1 else max_d |
||||
) |
||||
graph_size = getattr(self, 'graph_%s' % size) |
||||
side_font = getattr(self, '%s_font' % side) |
||||
side_align = getattr(self, '%s_align' % side) |
||||
result = ((float(graph_size) - self.font_size * 2 * side_font) / |
||||
(len(values) + dx - side_align)) |
||||
return result |
||||
|
||||
def field_width(self): |
||||
return self.field_size('x') |
||||
|
||||
def field_height(self): |
||||
return self.field_size('y') |
||||
|
||||
def draw_data(self): |
||||
self.load_transform_parameters() |
||||
for line, data in izip(count(1), self.data): |
||||
x_start, y_start = self.transform_output_coordinates( |
||||
(data['data'][self.x_data_index][0], |
||||
data['data'][self.y_data_index][0]) |
||||
) |
||||
data_points = zip(*data['data']) |
||||
graph_points = self.get_graph_points(data_points) |
||||
lpath = self.get_lpath(graph_points) |
||||
if self.area_fill: |
||||
graph_height = self.graph_height |
||||
node(self.graph, 'path', { |
||||
'd': 'M%f %f %s V%f Z' % ( |
||||
x_start, graph_height, lpath, graph_height), |
||||
'class': 'fill%d' % line}) |
||||
if self.draw_lines_between_points: |
||||
node(self.graph, 'path', { |
||||
'd': 'M%f %f %s' % (x_start, y_start, lpath), |
||||
'class': 'line%d' % line}) |
||||
self.draw_data_points(line, data_points, graph_points) |
||||
self._draw_constant_lines() |
||||
del self.__transform_parameters |
||||
|
||||
def add_constant_line(self, value, label=None, style=None): |
||||
self.constant_lines = getattr(self, 'constant_lines', []) |
||||
self.constant_lines.append((value, label, style)) |
||||
|
||||
def _draw_constant_lines(self): |
||||
if hasattr(self, 'constant_lines'): |
||||
map(self.__draw_constant_line, self.constant_lines) |
||||
|
||||
def __draw_constant_line(self, value_label_style): |
||||
"Draw a constant line on the y-axis with the label" |
||||
value, label, style = value_label_style |
||||
start = self.transform_output_coordinates((0, value))[1] |
||||
stop = self.graph_width |
||||
path = node(self.graph, 'path', { |
||||
'd': 'M 0 %s h%s' % (start, stop), |
||||
'class': 'constantLine'}) |
||||
if style: |
||||
path.set('style', style) |
||||
text = node(self.graph, 'text', { |
||||
'x': 2, |
||||
'y': start - 2, |
||||
'class': 'constantLine'}) |
||||
text.text = label |
||||
|
||||
def load_transform_parameters(self): |
||||
"Cache the parameters necessary to transform x & y coordinates" |
||||
x_min, x_max, x_div = self.x_range() |
||||
y_min, y_max, y_div = self.y_range() |
||||
x_step = ((float(self.graph_width) - self.font_size * 2) / |
||||
(x_max - x_min)) |
||||
y_step = ((float(self.graph_height) - self.font_size * 2) / |
||||
(y_max - y_min)) |
||||
self.__transform_parameters = dict(vars()) |
||||
del self.__transform_parameters['self'] |
||||
|
||||
def get_graph_points(self, data_points): |
||||
return map(self.transform_output_coordinates, data_points) |
||||
|
||||
def get_lpath(self, points): |
||||
points = map(lambda p: "%f %f" % p, points) |
||||
return 'L' + ' '.join(points) |
||||
|
||||
def transform_output_coordinates(self, (x, y)): |
||||
x_min = self.__transform_parameters['x_min'] |
||||
x_step = self.__transform_parameters['x_step'] |
||||
y_min = self.__transform_parameters['y_min'] |
||||
y_step = self.__transform_parameters['y_step'] |
||||
x = (x - x_min) * x_step |
||||
y = self.graph_height - (y - y_min) * y_step |
||||
return x, y |
||||
|
||||
def draw_data_points(self, line, data_points, graph_points): |
||||
if not self.show_data_points and not self.show_data_values: |
||||
return |
||||
|
||||
for ((dx, dy), (gx, gy)) in izip(data_points, graph_points): |
||||
if self.show_data_points: |
||||
node(self.graph, 'circle', { |
||||
'cx': gx, |
||||
'cy': gy, |
||||
'r': '2.5', |
||||
'class': 'dataPoint%s' % line}) |
||||
if self.show_data_values: |
||||
self.add_popup(gx, gy, self.format(dx, dy)) |
||||
self.make_datapoint_text(gx, gy - 6, dy) |
||||
|
||||
def format(self, x, y): |
||||
return '(%0.2f, %0.2f)' % (x, y) |
@ -1,216 +0,0 @@
|
||||
# -*- coding: utf-8 -*- |
||||
import re |
||||
|
||||
from dateutil.parser import parse |
||||
from dateutil.relativedelta import relativedelta |
||||
from lxml import etree |
||||
|
||||
from pygal.util import (node, grouper, date_range, |
||||
divide_timedelta_float, TimeScale) |
||||
from pygal.graph import Graph |
||||
|
||||
__all__ = ('Schedule') |
||||
|
||||
|
||||
class Schedule(Graph): |
||||
""" |
||||
Graph of temporal scalar data. |
||||
""" |
||||
|
||||
# The format string to be used to format the X axis labels |
||||
x_label_format = '%Y-%m-%d %H:%M:%S' |
||||
|
||||
# Use this to set the spacing between dates on the axis. The value |
||||
# must be of the form |
||||
# "\d+ ?((year|month|week|day|hour|minute|second)s?)?" |
||||
# e.g. |
||||
# graph.timescale_divisions = '2 weeks' |
||||
# graph.timescale_divisions = '1 month' |
||||
# graph.timescale_divisions = '3600 seconds' |
||||
# easier would be '1 hour' |
||||
timescale_divisions = None |
||||
|
||||
# The formatting used for the popups. See x_label_format |
||||
popup_format = '%Y-%m-%d %H:%M:%S' |
||||
|
||||
_min_x_value = None |
||||
scale_x_divisions = False |
||||
scale_x_integers = False |
||||
bar_gap = True |
||||
|
||||
stylesheet_names = Graph.stylesheet_names + ['bar.css'] |
||||
|
||||
def add_data(self, data): |
||||
""" |
||||
Add data to the plot. |
||||
Note that the data must be in time,value pairs, |
||||
and that the date format |
||||
may be any date that is parseable by ParseDate. |
||||
Also note that, in this example, we're mixing scales; the data from d1 |
||||
will probably not be discernable if both data sets are |
||||
plotted on the same graph, since d1 is too granular. |
||||
""" |
||||
# The ruby version does something different here, throwing out |
||||
# any previously added data. |
||||
super(Schedule, self).add_data(data) |
||||
|
||||
# copied from Bar |
||||
# TODO, refactor this into a common base class (or mix-in) |
||||
def get_bar_gap(self, field_size): |
||||
bar_gap = 10 # default gap |
||||
if field_size < 10: |
||||
# adjust for narrow fields |
||||
bar_gap = field_size / 2 |
||||
# the following zero's out the gap if bar_gap is False |
||||
bar_gap = int(self.bar_gap) * bar_gap |
||||
return bar_gap |
||||
|
||||
def validate_data(self, conf): |
||||
super(Schedule, self).validate_data(conf) |
||||
msg = "Data supplied must be (title, from, to) tripples!" |
||||
assert len(conf['data']) % 3 == 0, msg |
||||
|
||||
def process_data(self, conf): |
||||
super(Schedule, self).process_data(conf) |
||||
data = conf['data'] |
||||
triples = grouper(3, data) |
||||
|
||||
labels, begin_dates, end_dates = zip(*triples) |
||||
|
||||
begin_dates = map(self.parse_date, begin_dates) |
||||
end_dates = map(self.parse_date, end_dates) |
||||
|
||||
# reconstruct the triples in a new order |
||||
reordered_triples = zip(begin_dates, end_dates, labels) |
||||
|
||||
# because of the reordering, this will sort by begin_date |
||||
# then end_date, then label. |
||||
reordered_triples.sort() |
||||
|
||||
conf['data'] = reordered_triples |
||||
|
||||
def parse_date(self, date_string): |
||||
return parse(date_string) |
||||
|
||||
def set_min_x_value(self, value): |
||||
if isinstance(value, basestring): |
||||
value = self.parse_date(value) |
||||
self._min_x_value = value |
||||
|
||||
def get_min_x_value(self): |
||||
return self._min_x_value |
||||
|
||||
min_x_value = property(get_min_x_value, set_min_x_value) |
||||
|
||||
def format(self, x, y): |
||||
return x.strftime(self.popup_format) |
||||
|
||||
def get_x_labels(self): |
||||
format = lambda x: x.strftime(self.x_label_format) |
||||
return map(format, self.get_x_values()) |
||||
|
||||
def y_label_offset(self, height): |
||||
return height / -2.0 |
||||
|
||||
def get_y_labels(self): |
||||
# ruby version uses the last data supplied |
||||
last = -1 |
||||
data = self.data[last]['data'] |
||||
begin_dates, start_dates, labels = zip(*data) |
||||
return labels |
||||
|
||||
def draw_data(self): |
||||
bar_gap = self.get_bar_gap(self.get_field_height()) |
||||
|
||||
subbar_height = self.get_field_height() - bar_gap |
||||
|
||||
x_min, x_max, div = self._x_range() |
||||
x_range = x_max - x_min |
||||
width = (float(self.graph_width) - self.font_size * 2) |
||||
# time_scale |
||||
#scale /= x_range |
||||
scale = TimeScale(width, x_range) |
||||
|
||||
# ruby version uses the last data supplied |
||||
last = -1 |
||||
data = self.data[last]['data'] |
||||
|
||||
for index, (x_start, x_end, label) in enumerate(data): |
||||
count = index + 1 # index is 0-based, count is 1-based |
||||
y = self.graph_height - (self.get_field_height() * count) |
||||
bar_width = scale * (x_end - x_start) |
||||
bar_start = scale * (x_start - x_min) |
||||
|
||||
node(self.graph, 'rect', { |
||||
'x': bar_start, |
||||
'y': y, |
||||
'width': bar_width, |
||||
'height': subbar_height, |
||||
'class': 'fill%s' % (count + 1), |
||||
}) |
||||
|
||||
def _x_range(self): |
||||
# ruby version uses teh last data supplied |
||||
last = -1 |
||||
data = self.data[last]['data'] |
||||
|
||||
start_dates, end_dates, labels = zip(*data) |
||||
all_dates = start_dates + end_dates |
||||
max_value = max(all_dates) |
||||
if not self.min_x_value is None: |
||||
all_dates.append(self.min_x_value) |
||||
min_value = min(all_dates) |
||||
range = max_value - min_value |
||||
right_pad = divide_timedelta_float( |
||||
range, 20.0) or relativedelta(days=10) |
||||
scale_range = (max_value + right_pad) - min_value |
||||
|
||||
#scale_division = self.scale_x_divisions or (scale_range / 10.0) |
||||
# todo, remove timescale_x_divisions and use scale_x_divisions only |
||||
# but as a time delta |
||||
scale_division = divide_timedelta_float(scale_range, 10.0) |
||||
|
||||
# this doesn't make sense, because x is a timescale |
||||
#if self.scale_x_integers: |
||||
# scale_division = min(round(scale_division), 1) |
||||
|
||||
return min_value, max_value, scale_division |
||||
|
||||
def get_x_values(self): |
||||
x_min, x_max, scale_division = self._x_range() |
||||
if self.timescale_divisions: |
||||
pattern = re.compile('(\d+) ?(\w+)') |
||||
m = pattern.match(self.timescale_divisions) |
||||
if not m: |
||||
raise (ValueError, |
||||
"Invalid timescale_divisions: %s" % |
||||
self.timescale_divisions) |
||||
|
||||
magnitude = int(m.group(1)) |
||||
units = m.group(2) |
||||
|
||||
parameter = self.lookup_relativedelta_parameter(units) |
||||
|
||||
delta = relativedelta(**{parameter: magnitude}) |
||||
|
||||
scale_division = delta |
||||
|
||||
return date_range(x_min, x_max, scale_division) |
||||
|
||||
def lookup_relativedelta_parameter(self, unit_string): |
||||
from util import reverse_mapping, flatten_mapping |
||||
unit_string = unit_string.lower() |
||||
mapping = dict( |
||||
years=('years', 'year', 'yrs', 'yr'), |
||||
months=('months', 'month', 'mo'), |
||||
weeks=('weeks', 'week', 'wks', 'wk'), |
||||
days=('days', 'day'), |
||||
hours=('hours', 'hour', 'hr', 'hrs', 'h'), |
||||
minutes=('minutes', 'minute', 'min', 'mins', 'm'), |
||||
seconds=('seconds', 'second', 'sec', 'secs', 's'), |
||||
) |
||||
mapping = reverse_mapping(mapping) |
||||
mapping = flatten_mapping(mapping) |
||||
if not unit_string in mapping: |
||||
raise ValueError("%s doesn't match any supported time/date unit") |
||||
return mapping[unit_string] |
@ -1,199 +0,0 @@
|
||||
#!/usr/bin/env python |
||||
import pygal.plot |
||||
import re |
||||
import pkg_resources |
||||
pkg_resources.require("python-dateutil>=1.1") |
||||
from dateutil.parser import parse |
||||
from dateutil.relativedelta import relativedelta |
||||
from time import mktime |
||||
import datetime |
||||
fromtimestamp = datetime.datetime.fromtimestamp |
||||
from .util import float_range |
||||
|
||||
|
||||
class Plot(pygal.plot.Plot): |
||||
"""=== For creating SVG plots of scalar temporal data |
||||
|
||||
= Synopsis |
||||
|
||||
import SVG.TimeSeries |
||||
|
||||
# Data sets are x,y pairs |
||||
data1 = ["6/17/72", 11, "1/11/72", 7, "4/13/04 17:31", 11, |
||||
"9/11/01", 9, "9/1/85", 2, |
||||
"9/1/88", 1, "1/15/95", 13] |
||||
data2 = ["8/1/73", 18, "3/1/77", 15, "10/1/98", 4, |
||||
"5/1/02", 14, "3/1/95", 6, |
||||
"8/1/91", 12, "12/1/87", 6, |
||||
"5/1/84", 17, "10/1/80", 12] |
||||
|
||||
graph = SVG::Graph::TimeSeries.new({ |
||||
:width => 640, |
||||
:height => 480, |
||||
:graph_title => title, |
||||
:show_graph_title => true, |
||||
:no_css => true, |
||||
:key => true, |
||||
:scale_x_integers => true, |
||||
:scale_y_integers => true, |
||||
:min_x_value => 0, |
||||
:min_y_value => 0, |
||||
:show_data_labels => true, |
||||
:show_x_guidelines => true, |
||||
:show_x_title => true, |
||||
:x_title => "Time", |
||||
:show_y_title => true, |
||||
:y_title => "Ice Cream Cones", |
||||
:y_title_text_direction => :bt, |
||||
:stagger_x_labels => true, |
||||
:x_label_format => "%m/%d/%y", |
||||
}) |
||||
|
||||
graph.add_data({ |
||||
:data => projection |
||||
:title => 'Projected', |
||||
}) |
||||
|
||||
graph.add_data({ |
||||
:data => actual, |
||||
:title => 'Actual', |
||||
}) |
||||
|
||||
print graph.burn() |
||||
|
||||
= Description |
||||
|
||||
Produces a graph of temporal scalar data. |
||||
|
||||
= Examples |
||||
|
||||
http://www.germane-software/repositories/public/SVG/test/timeseries.rb |
||||
|
||||
= Notes |
||||
|
||||
The default stylesheet handles upto 10 data sets, if you |
||||
use more you must create your own stylesheet and add the |
||||
additional settings for the extra data sets. You will know |
||||
if you go over 10 data sets as they will have no style and |
||||
be in black. |
||||
|
||||
Unlike the other types of charts, data sets must contain x,y pairs: |
||||
|
||||
["12:30", 2] # A data set with 1 point: ("12:30",2) |
||||
["01:00",2, "14:20",6] # A data set with 2 points: ("01:00",2) and |
||||
# ("14:20",6) |
||||
|
||||
Note that multiple data sets within |
||||
the same chart can differ in length, |
||||
and that the data in the datasets needn't be in order; |
||||
they will be ordered by the plot along the X-axis. |
||||
|
||||
The dates must be parseable by ParseDate, but otherwise can be |
||||
any order of magnitude (seconds within the hour, or years) |
||||
|
||||
= See also |
||||
|
||||
* SVG::Graph::Graph |
||||
* SVG::Graph::BarHorizontal |
||||
* SVG::Graph::Bar |
||||
* SVG::Graph::Line |
||||
* SVG::Graph::Pie |
||||
* SVG::Graph::Plot |
||||
|
||||
== Author |
||||
|
||||
Sean E. Russell <serATgermaneHYPHENsoftwareDOTcom> |
||||
|
||||
Copyright 2004 Sean E. Russell |
||||
This software is available under the Ruby license[LICENSE.txt] |
||||
""" |
||||
popup_format = x_label_format = '%Y-%m-%d %H:%M:%S' |
||||
__doc_popup_format_ = ("The formatting usped for the popups." |
||||
" See x_label_format") |
||||
__doc_x_label_format_ = ("The format string used to format " |
||||
"the X axis labels. See strftime.") |
||||
|
||||
timescale_divisions = None |
||||
__doc_timescale_divisions_ = """Use this to set the spacing |
||||
between dates on the axis. The value must be of the form |
||||
"\d+ ?(days|weeks|months|years|hours|minutes|seconds)?" |
||||
|
||||
EG: |
||||
|
||||
graph.timescale_divisions = "2 weeks" |
||||
|
||||
will cause the chart to try to divide the X axis up into segments of |
||||
two week periods.""" |
||||
|
||||
def add_data(self, data): |
||||
"""Add data to the plot. |
||||
d1 = ["12:30", 2] # A data set with 1 point: ("12:30",2) |
||||
d2 = ["01:00",2, "14:20",6] # A data set with 2 points: |
||||
# ("01:00",2) and ("14:20",6) |
||||
graph.add_data( |
||||
:data => d1, |
||||
:title => 'One' |
||||
) |
||||
graph.add_data( |
||||
:data => d2, |
||||
:title => 'Two' |
||||
) |
||||
|
||||
Note that the data must be in time,value pairs, |
||||
and that the date format |
||||
may be any date that is parseable by ParseDate.""" |
||||
super(Plot, self).add_data(data) |
||||
|
||||
def process_data(self, data): |
||||
super(Plot, self).process_data(data) |
||||
# the date should be in the first element, so parse it out |
||||
data['data'][0] = map(self.parse_date, data['data'][0]) |
||||
|
||||
_min_x_value = pygal.plot.Plot.min_x_value |
||||
|
||||
def get_min_x_value(self): |
||||
return self._min_x_value |
||||
|
||||
def set_min_x_value(self, date): |
||||
self._min_x_value = self.parse_date(date) |
||||
min_x_value = property(get_min_x_value, set_min_x_value) |
||||
|
||||
def format(self, x, y): |
||||
return fromtimestamp(x).strftime(self.popup_format) |
||||
|
||||
def get_x_labels(self): |
||||
return map(lambda t: fromtimestamp(t).strftime(self.x_label_format), |
||||
self.get_x_values()) |
||||
|
||||
def get_x_values(self): |
||||
result = self.get_x_timescale_division_values() |
||||
if result: |
||||
return result |
||||
return tuple(float_range(*self.x_range())) |
||||
|
||||
def get_x_timescale_division_values(self): |
||||
if not self.timescale_divisions: |
||||
return |
||||
min, max, scale_division = self.x_range() |
||||
m = re.match( |
||||
'(?P<amount>\d+) ?(?P<division_units>' |
||||
'days|weeks|months|years|hours|minutes|seconds)?', |
||||
self.timescale_divisions) |
||||
# copy amount and division_units into the local namespace |
||||
division_units = m.groupdict()['division_units'] or 'days' |
||||
amount = int(m.groupdict()['amount']) |
||||
if not amount: |
||||
return |
||||
delta = relativedelta(**{division_units: amount}) |
||||
result = tuple(self.get_time_range(min, max, delta)) |
||||
return result |
||||
|
||||
def get_time_range(self, start, stop, delta): |
||||
start, stop = map(fromtimestamp, (start, stop)) |
||||
current = start |
||||
while current <= stop: |
||||
yield mktime(current.timetuple()) |
||||
current += delta |
||||
|
||||
def parse_date(self, date_string): |
||||
return mktime(parse(date_string).timetuple()) |
@ -1,187 +0,0 @@
|
||||
# -*- coding: utf-8 -*- |
||||
from lxml import etree |
||||
import itertools |
||||
import datetime |
||||
|
||||
|
||||
def node(parent, tag, params=None, **extras): |
||||
"""Make a etree node""" |
||||
params = params or {} |
||||
for key, value in params.items(): |
||||
if not isinstance(value, basestring): |
||||
params[key] = str(value) |
||||
|
||||
return etree.SubElement(parent, tag, params, **extras) |
||||
|
||||
|
||||
def grouper(n, iterable, padvalue=None): |
||||
""" |
||||
>>> tuple(grouper(3, 'abcdefg', 'x')) |
||||
(('a', 'b', 'c'), ('d', 'e', 'f'), ('g', 'x', 'x')) |
||||
""" |
||||
return itertools.izip( |
||||
*[itertools.chain(iterable, |
||||
itertools.repeat(padvalue, n - 1))] * n) |
||||
|
||||
|
||||
def reverse_mapping(mapping): |
||||
""" |
||||
For every key, value pair, return the mapping for the |
||||
equivalent value, key pair |
||||
>>> reverse_mapping({'a': 'b'}) == {'b': 'a'} |
||||
True |
||||
""" |
||||
keys, values = zip(*mapping.items()) |
||||
return dict(zip(values, keys)) |
||||
|
||||
|
||||
def flatten_mapping(mapping): |
||||
""" |
||||
For every key that has an __iter__ method, assign the values |
||||
to a key for each. |
||||
>>> flatten_mapping({'ab': 3, ('c','d'): 4}) == {'ab': 3, 'c': 4, 'd': 4} |
||||
True |
||||
""" |
||||
return dict(flatten_items(mapping.items())) |
||||
|
||||
|
||||
def flatten_items(items): |
||||
for keys, value in items: |
||||
if hasattr(keys, '__iter__'): |
||||
for key in keys: |
||||
yield (key, value) |
||||
else: |
||||
yield (keys, value) |
||||
|
||||
|
||||
def float_range(start=0, stop=None, step=1): |
||||
""" |
||||
Much like the built-in function range, but accepts floats |
||||
>>> tuple(float_range(0, 9, 1.5)) |
||||
(0.0, 1.5, 3.0, 4.5, 6.0, 7.5) |
||||
""" |
||||
start = float(start) |
||||
while start < stop: |
||||
yield start |
||||
start += step |
||||
|
||||
|
||||
def date_range(start=None, stop=None, step=None): |
||||
""" |
||||
Much like the built-in function range, but works with dates |
||||
>>> my_range = tuple(date_range(datetime.datetime(2005,12,21), datetime.datetime(2005,12,25))) |
||||
>>> datetime.datetime(2005,12,21) in my_range |
||||
True |
||||
>>> datetime.datetime(2005,12,22) in my_range |
||||
True |
||||
>>> datetime.datetime(2005,12,25) in my_range |
||||
False |
||||
""" |
||||
if step is None: |
||||
step = datetime.timedelta(days=1) |
||||
if start is None: |
||||
start = datetime.datetime.now() |
||||
while start < stop: |
||||
yield start |
||||
start += step |
||||
|
||||
|
||||
# copied from jaraco.datetools |
||||
def divide_timedelta_float(td, divisor): |
||||
""" |
||||
Meant to work around the limitation that Python datetime doesn't support |
||||
floats as divisors or multiplicands to datetime objects |
||||
>>> one_day = datetime.timedelta(days=1) |
||||
>>> half_day = datetime.timedelta(days=.5) |
||||
>>> divide_timedelta_float(one_day, 2.0) == half_day |
||||
True |
||||
>>> divide_timedelta_float(one_day, 2) == half_day |
||||
False |
||||
""" |
||||
# td is comprised of days, seconds, microseconds |
||||
dsm = [getattr(td, attr) for attr in ('days', 'seconds', 'microseconds')] |
||||
dsm = map(lambda elem: elem / divisor, dsm) |
||||
return datetime.timedelta(*dsm) |
||||
|
||||
|
||||
def get_timedelta_total_microseconds(td): |
||||
seconds = td.days * 86400 + td.seconds |
||||
microseconds = td.microseconds + seconds * (10 ** 6) |
||||
return microseconds |
||||
|
||||
|
||||
def divide_timedelta(td1, td2): |
||||
""" |
||||
Get the ratio of two timedeltas |
||||
>>> one_day = datetime.timedelta(days=1) |
||||
>>> one_hour = datetime.timedelta(hours=1) |
||||
>>> divide_timedelta(one_hour, one_day) == 1/24.0 |
||||
True |
||||
""" |
||||
|
||||
td1_total = float(get_timedelta_total_microseconds(td1)) |
||||
td2_total = float(get_timedelta_total_microseconds(td2)) |
||||
return td1_total / td2_total |
||||
|
||||
|
||||
class TimeScale(object): |
||||
"Describes a scale factor based on time instead of a scalar" |
||||
def __init__(self, width, range): |
||||
self.width = width |
||||
self.range = range |
||||
|
||||
def __mul__(self, delta): |
||||
scale = divide_timedelta(delta, self.range) |
||||
return scale * self.width |
||||
|
||||
|
||||
# the following three functions were copied from jaraco.util.iter_ |
||||
# todo, factor out caching capability |
||||
class iterable_test(dict): |
||||
"Test objects for iterability, caching the result by type" |
||||
def __init__(self, ignore_classes=(basestring,)): |
||||
"""ignore_classes must include basestring, because if a string |
||||
is iterable, so is a single character, and the routine runs |
||||
into an infinite recursion""" |
||||
assert basestring in ignore_classes, ( |
||||
'basestring must be in ignore_classes') |
||||
self.ignore_classes = ignore_classes |
||||
|
||||
def __getitem__(self, candidate): |
||||
return dict.get(self, type(candidate)) or self._test(candidate) |
||||
|
||||
def _test(self, candidate): |
||||
try: |
||||
if isinstance(candidate, self.ignore_classes): |
||||
raise TypeError |
||||
iter(candidate) |
||||
result = True |
||||
except TypeError: |
||||
result = False |
||||
self[type(candidate)] = result |
||||
return result |
||||
|
||||
|
||||
def iflatten(subject, test=None): |
||||
if test is None: |
||||
test = iterable_test() |
||||
if not test[subject]: |
||||
yield subject |
||||
else: |
||||
for elem in subject: |
||||
for subelem in iflatten(elem, test): |
||||
yield subelem |
||||
|
||||
|
||||
def flatten(subject, test=None): |
||||
"""flatten an iterable with possible nested iterables. |
||||
Adapted from |
||||
http://mail.python.org/pipermail/python-list/2003-November/233971.html |
||||
>>> flatten(['a','b',['c','d',['e','f'],'g'],'h']) == ['a','b','c','d','e','f','g','h'] |
||||
True |
||||
|
||||
Note this will normally ignore string types as iterables. |
||||
>>> flatten(['ab', 'c']) |
||||
['ab', 'c'] |
||||
""" |
||||
return list(iflatten(subject, test)) |
@ -1,108 +0,0 @@
|
||||
# -*- coding: utf-8 -*- |
||||
import math |
||||
|
||||
pi = math.pi |
||||
|
||||
|
||||
def cos(angle): |
||||
return math.cos(angle * pi / 180) |
||||
|
||||
|
||||
def sin(angle): |
||||
return math.sin(angle * pi / 180) |
||||
|
||||
|
||||
def calculate_right_margin(graph): |
||||
""" |
||||
Calculate the margin in pixels to the right of the plot area, |
||||
setting border_right. |
||||
""" |
||||
br = 7 |
||||
if graph.key and graph.key_position == 'right': |
||||
max_key_len = max(map(len, graph.keys())) |
||||
br += max_key_len * graph.key_font_size * 0.6 |
||||
br += graph.key_box_size |
||||
br += 10 # Some padding around the box |
||||
return br |
||||
|
||||
|
||||
def calculate_top_margin(graph): |
||||
""" |
||||
Calculate the margin in pixels above the plot area, setting |
||||
border_top. |
||||
""" |
||||
bt = 10 |
||||
if graph.show_graph_title: |
||||
bt += graph.title_font_size |
||||
if graph.show_graph_subtitle: |
||||
bt += graph.subtitle_font_size |
||||
return bt |
||||
|
||||
|
||||
def calculate_bottom_margin(graph): |
||||
""" |
||||
Calculate the margin in pixels below the plot area, setting |
||||
border_bottom. |
||||
""" |
||||
bb = 7 |
||||
if graph.key and graph.key_position == 'bottom': |
||||
bb += len(graph.data) * (graph.font_size + 5) |
||||
bb += 10 |
||||
if graph.show_x_labels: |
||||
max_x_label_height_px = graph.x_label_font_size |
||||
if graph.x_label_rotation: |
||||
label_lengths = map(len, graph.get_x_labels()) |
||||
max_x_label_len = reduce(max, label_lengths, 0) |
||||
max_x_label_height_px *= max_x_label_len * 0.6 |
||||
max_x_label_height_px *= sin(graph.x_label_rotation) |
||||
bb += max_x_label_height_px + graph.y_label_font_size |
||||
if graph.stagger_x_labels: |
||||
bb += max_x_label_height_px + 10 |
||||
if graph.show_x_title: |
||||
bb += graph.x_title_font_size + 5 |
||||
return bb |
||||
|
||||
|
||||
def calculate_left_margin(graph): |
||||
""" |
||||
Calculates the margin to the left of the plot area, setting |
||||
border_left. |
||||
""" |
||||
bl = 7 |
||||
# Check for Y labels |
||||
if graph.rotate_y_labels: |
||||
max_y_label_height_px = graph.y_label_font_size |
||||
else: |
||||
label_lengths = map(len, graph.get_y_labels()) |
||||
max_y_label_len = max(label_lengths) |
||||
max_y_label_height_px = (0.6 * max_y_label_len * |
||||
graph.y_label_font_size) |
||||
if graph.show_y_labels: |
||||
bl += max_y_label_height_px |
||||
if graph.stagger_y_labels: |
||||
bl += max_y_label_height_px + 10 |
||||
if graph.show_y_title: |
||||
bl += graph.y_title_font_size + 5 |
||||
if graph.x_label_rotation: |
||||
first_x_label_width = ( |
||||
graph.x_label_font_size * len( |
||||
(graph.get_x_labels() or [[0]])[0]) * 0.6) |
||||
bl = max(bl, first_x_label_width * cos(graph.x_label_rotation)) |
||||
return bl |
||||
|
||||
|
||||
def calculate_offsets_bottom(graph): |
||||
x_offset = graph.border_left + 20 |
||||
y_offset = graph.border_top + graph.graph_height + 5 |
||||
if graph.show_x_labels: |
||||
max_x_label_height_px = graph.x_label_font_size |
||||
if graph.x_label_rotation: |
||||
longest_label_length = max(map(len, graph.get_x_labels())) |
||||
max_x_label_height_px *= longest_label_length |
||||
max_x_label_height_px *= sin(graph.x_label_rotation) |
||||
y_offset += max_x_label_height_px |
||||
if graph.stagger_x_labels: |
||||
y_offset += max_x_label_height_px + 5 |
||||
if graph.show_x_title: |
||||
y_offset += graph.x_title_font_size + 5 |
||||
return x_offset, y_offset |
@ -1,8 +0,0 @@
|
||||
[egg_info] |
||||
|
||||
[nosetests] |
||||
with-doctest=1 |
||||
|
||||
[pytest] |
||||
addopts = --doctest-modules |
||||
norecursedirs = build |
@ -1,54 +0,0 @@
|
||||
#!python |
||||
|
||||
import os |
||||
import sys |
||||
from setuptools import find_packages |
||||
|
||||
from distutils.cmd import Command |
||||
|
||||
|
||||
class DisabledTestCommand(Command): |
||||
user_options = [] |
||||
|
||||
def __init__(self, dist): |
||||
raise RuntimeError( |
||||
"test command not supported on pygal." |
||||
" Use setup.py nosetests instead") |
||||
|
||||
|
||||
setup_params = dict( |
||||
name="pygal", |
||||
description="Python svg graph abstract layer", |
||||
author="Jason R. Coombs, Kozea", |
||||
author_email="jaraco@jaraco.com, gayoub@kozea.fr", |
||||
url="https://github.com/Kozea/pygal", |
||||
packages=find_packages(), |
||||
zip_safe=True, |
||||
include_package_data=True, |
||||
install_requires=[ |
||||
'lxml>=2.0', |
||||
], |
||||
package_data={'pygal': ['css/*']}, |
||||
license="MIT", |
||||
classifiers=[ |
||||
"Development Status :: 5 - Production/Stable", |
||||
"Intended Audience :: Developers", |
||||
"Intended Audience :: Science/Research", |
||||
"Programming Language :: Python :: 2.6", |
||||
"Programming Language :: Python :: 2.7", |
||||
"Programming Language :: Python :: 3", |
||||
"License :: OSI Approved :: MIT License", |
||||
], |
||||
entry_points={ |
||||
}, |
||||
# Don't use setup.py test - nose doesn't support it |
||||
# see http://code.google.com/p/python-nose/issues/detail?id=219 |
||||
cmdclass=dict( |
||||
test=DisabledTestCommand, |
||||
), |
||||
use_2to3=True, |
||||
) |
||||
|
||||
if __name__ == '__main__': |
||||
from setuptools import setup |
||||
setup(**setup_params) |
@ -1,119 +0,0 @@
|
||||
# -*- coding: utf-8 -*- |
||||
from flask import Flask, Response, render_template, url_for |
||||
from log_colorizer import make_colored_stream_handler |
||||
from logging import getLogger, INFO, WARN, DEBUG |
||||
from moulinrouge.data import labels, series |
||||
from pygal.bar import VerticalBar, HorizontalBar |
||||
from pygal.line import Line |
||||
from pygal.pie import Pie |
||||
import string |
||||
import random |
||||
|
||||
|
||||
def random_label(): |
||||
chars = string.letters + string.digits + u' àéèçêâäëï' |
||||
return ''.join( |
||||
[random.choice(chars) |
||||
for i in range( |
||||
random.randrange(4, 30))]) |
||||
|
||||
|
||||
def random_value(): |
||||
return random.randrange(0, 15, 1) |
||||
|
||||
|
||||
def generate_vbar(**opts): |
||||
g = VerticalBar(labels, opts) |
||||
for serie, values in series.items(): |
||||
g.add_data({'data': values, 'title': serie}) |
||||
|
||||
return Response(g.burn(), mimetype='image/svg+xml') |
||||
|
||||
|
||||
def create_app(): |
||||
"""Creates the pygal test web app""" |
||||
|
||||
app = Flask(__name__) |
||||
handler = make_colored_stream_handler() |
||||
getLogger('werkzeug').addHandler(handler) |
||||
getLogger('werkzeug').setLevel(INFO) |
||||
getLogger('pygal').addHandler(handler) |
||||
getLogger('pygal').setLevel(DEBUG) |
||||
|
||||
@app.route("/") |
||||
def index(): |
||||
return render_template('index.jinja2') |
||||
|
||||
@app.route("/all-<type>.svg") |
||||
def all_svg(type): |
||||
series = random.randrange(1, 10) |
||||
data = random.randrange(1, 10) |
||||
|
||||
labels = [random_label() for i in range(data)] |
||||
|
||||
if type == 'vbar': |
||||
g = VerticalBar(labels) |
||||
elif type == 'hbar': |
||||
g = HorizontalBar(labels) |
||||
elif type == 'pie': |
||||
series = 1 |
||||
g = Pie({'fields': labels}) |
||||
elif type == 'line': |
||||
g = Line({'fields': labels}) |
||||
|
||||
for i in range(series): |
||||
values = [random_value() for i in range(data)] |
||||
g.add_data({'data': values, 'title': random_label()}) |
||||
|
||||
return Response(g.burn(), mimetype='image/svg+xml') |
||||
|
||||
@app.route("/all") |
||||
def all(): |
||||
width, height = 600, 400 |
||||
svgs = [url_for('all_svg', type=type) for type in |
||||
('vbar', 'hbar', 'line', 'pie')] |
||||
return render_template('svgs.jinja2', |
||||
svgs=svgs, |
||||
width=width, |
||||
height=height) |
||||
|
||||
@app.route("/rotation[<int:angle>].svg") |
||||
def rotation_svg(angle): |
||||
return generate_vbar( |
||||
show_graph_title=True, |
||||
graph_title="Rotation %d" % angle, |
||||
x_label_rotation=angle) |
||||
|
||||
@app.route("/rotation") |
||||
def rotation(): |
||||
width, height = 375, 245 |
||||
svgs = [url_for('rotation_svg', angle=angle) |
||||
for angle in range(0, 91, 5)] |
||||
return render_template('svgs.jinja2', |
||||
svgs=svgs, |
||||
width=width, |
||||
height=height) |
||||
|
||||
@app.route("/bigline.svg") |
||||
def big_line_svg(): |
||||
g = Line({'fields': ['a', 'b', 'c', 'd']}) |
||||
g.width = 1500 |
||||
g.area_fill = True |
||||
g.scale_divisions = 50 |
||||
# values = [120, 50, 42, 100] |
||||
# g.add_data({'data': values, 'title': '1'}) |
||||
values = [11, 50, 133, 2] |
||||
g.add_data({'data': values, 'title': '2'}) |
||||
|
||||
return Response(g.burn(), mimetype='image/svg+xml') |
||||
|
||||
@app.route("/bigline") |
||||
def big_line(): |
||||
width, height = 900, 800 |
||||
svgs = [url_for('big_line_svg')] |
||||
return render_template('svgs.jinja2', |
||||
svgs=svgs, |
||||
width=width, |
||||
height=height) |
||||
|
||||
return app |
@ -1,11 +0,0 @@
|
||||
# -*- coding: utf-8 -*- |
||||
|
||||
labels = ['AURSAUTRAUIA', |
||||
'dpvluiqhu enuie', |
||||
'su sru a nanan a', |
||||
'09_28_3023_98120398', |
||||
u'éàé瀮ð{æə|&'] |
||||
series = { |
||||
'Female': [4, 2, 3, 0, 2], |
||||
'Male': [5, 1, 1, 3, 2] |
||||
} |
@ -1,9 +0,0 @@
|
||||
html, body, section, figure { |
||||
margin: 0; |
||||
padding: 0; |
||||
} |
||||
|
||||
figure { |
||||
float: left; |
||||
border: 1px solid #ccc; |
||||
} |
@ -1,24 +0,0 @@
|
||||
$(function () { |
||||
$('figure figcaption').append( |
||||
$('<button>') |
||||
.text('⟳') |
||||
.click(function() { |
||||
var $fig, $embed, w, h, src; |
||||
$fig = $(this).closest('figure'); |
||||
$embed = $fig.find('embed'); |
||||
w = $embed.width(); |
||||
h = $embed.height(); |
||||
src = $embed.attr('src'); |
||||
$embed.remove(); |
||||
$fig.prepend( |
||||
$('<embed>') |
||||
.attr({ |
||||
src: src, |
||||
type: 'image/svg+xml', |
||||
width: w, |
||||
height: h |
||||
}) |
||||
); |
||||
}) |
||||
); |
||||
}); |
@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html> |
||||
<html> |
||||
<head> |
||||
<title>Moulin rouge - pygal test platform</title> |
||||
<script src="http://code.jquery.com/jquery.min.js" type="text/javascript"></script> |
||||
<script type="text/javascript" src="{{ url_for('static', filename='js.js') }}"></script> |
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css.css') }}" type="text/css" /> |
||||
</head> |
||||
<body> |
||||
<section> |
||||
{% block section %} |
||||
{% endblock section %} |
||||
</section> |
||||
</body> |
||||
</html> |
@ -1,7 +0,0 @@
|
||||
{% extends '_layout.jinja2' %} |
||||
|
||||
{% block section %} |
||||
<a href="{{ url_for('all') }}">All types</a> |
||||
<a href="{{ url_for('rotation') }}">Rotations test</a> |
||||
<a href="{{ url_for('big_line') }}">Big line</a> |
||||
{% endblock section %} |
@ -1,10 +0,0 @@
|
||||
{% extends '_layout.jinja2' %} |
||||
|
||||
{% block section %} |
||||
{% for svg in svgs %} |
||||
<figure> |
||||
<embed src="{{ svg }}" type="image/svg+xml" width="{{ width }}" height="{{ height }}" /> |
||||
<figcaption></figcaption> |
||||
</figure> |
||||
{% endfor %} |
||||
{% endblock section %} |
Loading…
Reference in new issue