mirror of https://github.com/Kozea/pygal.git
Python to generate nice looking SVG graph
http://pygal.org/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
310 lines
8.8 KiB
310 lines
8.8 KiB
#!python |
|
import re |
|
|
|
from dateutil.parser import parse |
|
from dateutil.relativedelta import relativedelta |
|
from lxml import etree |
|
|
|
from svg.charts.graph import Graph |
|
from util import grouper, date_range, divide_timedelta_float, TimeScale |
|
|
|
__all__ = ('Schedule') |
|
|
|
class Schedule(Graph): |
|
""" |
|
# === For creating SVG plots of scalar temporal data |
|
|
|
= Synopsis |
|
|
|
require 'SVG/Graph/Schedule' |
|
|
|
# Data sets are label, start, end tripples. |
|
data1 = [ |
|
"Housesitting", "6/17/04", "6/19/04", |
|
"Summer Session", "6/15/04", "8/15/04", |
|
] |
|
|
|
graph = SVG::Graph::Schedule.new( { |
|
:width => 640, |
|
:height => 480, |
|
:graph_title => title, |
|
:show_graph_title => true, |
|
:no_css => 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", |
|
:stagger_x_labels => true, |
|
:stagger_y_labels => true, |
|
:x_label_format => "%m/%d/%y", |
|
}) |
|
|
|
graph.add_data({ |
|
:data => data1, |
|
:title => 'Data', |
|
}) |
|
|
|
print graph.burn() |
|
|
|
= Description |
|
|
|
Produces a graph of temporal scalar data. |
|
|
|
= Examples |
|
|
|
http://www.germane-software/repositories/public/SVG/test/schedule.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. |
|
|
|
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 |
|
* SVG::Graph::TimeSeries |
|
|
|
== Author |
|
|
|
Sean E. Russell <serATgermaneHYPHENsoftwareDOTcom> |
|
|
|
Copyright 2004 Sean E. Russell |
|
This software is available under the Ruby license[LICENSE.txt] |
|
|
|
""" |
|
|
|
"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. |
|
|
|
# A data set with 1 point: Lunch from 12:30 to 14:00 |
|
d1 = [ "Lunch", "12:30", "14:00" ] |
|
# A data set with 2 points: "Cats" runs from 5/11/03 to 7/15/04, and |
|
# "Henry V" runs from 6/12/03 to 8/20/03 |
|
d2 = [ "Cats", "5/11/03", "7/15/04", |
|
"Henry V", "6/12/03", "8/20/03" ] |
|
|
|
graph.add_data( |
|
:data => d1, |
|
:title => 'Meetings' |
|
) |
|
graph.add_data( |
|
:data => d2, |
|
:title => 'Plays' |
|
) |
|
|
|
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): |
|
print 'attempting to parse %s as date' % 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 |
|
|
|
y_mod = (subbar_height / 2) + (self.font_size / 2) |
|
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) |
|
|
|
etree.SubElement(self.graph, 'rect', { |
|
'x': str(bar_start), |
|
'y': str(y), |
|
'width': str(bar_width), |
|
'height': str(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] |