mirror of https://github.com/Kozea/pygal.git
jaraco
17 years ago
6 changed files with 504 additions and 8 deletions
@ -0,0 +1,368 @@
|
||||
#!python |
||||
import re |
||||
|
||||
from dateutil.parser import parse |
||||
from dateutil.relativedelta import relativedelta |
||||
|
||||
from SVG import Graph |
||||
from util import grouper, date_range |
||||
|
||||
__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 |
||||
|
||||
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. |
||||
""" |
||||
# note, the only reason this method is overridden is to change the |
||||
# docstring |
||||
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) |
||||
|
||||
begin_dates, end_dates, labels = 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) |
||||
|
||||
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): |
||||
data = self.data['data'] |
||||
begin_dates, start_dates, labels = zip(*data) |
||||
return labels |
||||
|
||||
def draw_data(self): |
||||
bar_gap = self.get_bar_gap() |
||||
|
||||
subbar_height = self.field_height - bar_gap |
||||
|
||||
y_mod = (subbar_height / 2) + (font_size / 2) |
||||
x_min,x_max,div = self.x_range() |
||||
x_range = x_max - x_min |
||||
scale = (float(self.graph_width) - self.font_size*2) |
||||
scale /= x_range |
||||
|
||||
for index, (x_start, x_end, label) in enumerate(self.data['data']): |
||||
count = index + 1 # index is 0-based, count is 1-based |
||||
y = self.graph_height - (self.field_height*count) |
||||
bar_width = (x_end-x_stant)*scale |
||||
bar_start = (x_start-x_min)*scale |
||||
|
||||
rect = self._create_element('rect', { |
||||
'x': str(bar_start), |
||||
'y': str(y), |
||||
'width': str(bar_width), |
||||
'height': str(subbar_height), |
||||
'class': 'fill%s' % (count+1), # TODO: doublecheck that +1 is correct (that's what's in the Ruby code) |
||||
}) |
||||
self.graph.appendChild(rect) |
||||
|
||||
def get_css(self): |
||||
return """\ |
||||
/* default fill styles for multiple datasets (probably only use a single dataset on this graph though) */ |
||||
.key1,.fill1{ |
||||
fill: #ff0000; |
||||
fill-opacity: 0.5; |
||||
stroke: none; |
||||
stroke-width: 0.5px; |
||||
} |
||||
.key2,.fill2{ |
||||
fill: #0000ff; |
||||
fill-opacity: 0.5; |
||||
stroke: none; |
||||
stroke-width: 1px; |
||||
} |
||||
.key3,.fill3{ |
||||
fill: #00ff00; |
||||
fill-opacity: 0.5; |
||||
stroke: none; |
||||
stroke-width: 1px; |
||||
} |
||||
.key4,.fill4{ |
||||
fill: #ffcc00; |
||||
fill-opacity: 0.5; |
||||
stroke: none; |
||||
stroke-width: 1px; |
||||
} |
||||
.key5,.fill5{ |
||||
fill: #00ccff; |
||||
fill-opacity: 0.5; |
||||
stroke: none; |
||||
stroke-width: 1px; |
||||
} |
||||
.key6,.fill6{ |
||||
fill: #ff00ff; |
||||
fill-opacity: 0.5; |
||||
stroke: none; |
||||
stroke-width: 1px; |
||||
} |
||||
.key7,.fill7{ |
||||
fill: #00ffff; |
||||
fill-opacity: 0.5; |
||||
stroke: none; |
||||
stroke-width: 1px; |
||||
} |
||||
.key8,.fill8{ |
||||
fill: #ffff00; |
||||
fill-opacity: 0.5; |
||||
stroke: none; |
||||
stroke-width: 1px; |
||||
} |
||||
.key9,.fill9{ |
||||
fill: #cc6666; |
||||
fill-opacity: 0.5; |
||||
stroke: none; |
||||
stroke-width: 1px; |
||||
} |
||||
.key10,.fill10{ |
||||
fill: #663399; |
||||
fill-opacity: 0.5; |
||||
stroke: none; |
||||
stroke-width: 1px; |
||||
} |
||||
.key11,.fill11{ |
||||
fill: #339900; |
||||
fill-opacity: 0.5; |
||||
stroke: none; |
||||
stroke-width: 1px; |
||||
} |
||||
.key12,.fill12{ |
||||
fill: #9966FF; |
||||
fill-opacity: 0.5; |
||||
stroke: none; |
||||
stroke-width: 1px; |
||||
} |
||||
""" |
||||
|
||||
def _x_range(self): |
||||
start_dates, end_dates, labels = zip(*self.data['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 = relativedelta(max_value, min_value) |
||||
right_pad = (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) |
||||
|
||||
# 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 timescale_divisions: |
||||
pattern = re.compile('(\d+) ?(\w+)') |
||||
m = pattern.match(timescale_divisions) |
||||
if not m: |
||||
raise ValueError, "Invalid timescale_divisions: %s" % timescale_divisions |
||||
|
||||
magnitude = int(m.groups(1)) |
||||
units = m.groups(2) |
||||
|
||||
parameter = self.lookup_relativedelta_parameter(units) |
||||
|
||||
delta = relativedelta(**{parameter:magnitude}) |
||||
|
||||
return daterange(x_min, x_max, delta) |
||||
else: |
||||
# I think much of the code is assuming x is an integer (seconds) |
||||
# The whole logic needs to be revisited for this purpose. |
||||
return 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] |
@ -0,0 +1,49 @@
|
||||
#!python |
||||
|
||||
from itertools import chain, repeat, izip |
||||
|
||||
# from itertools recipes (python documentation) |
||||
def grouper(n, iterable, padvalue=None): |
||||
"grouper(3, 'abcdefg', 'x') --> ('a','b','c'), ('d','e','f'), ('g','x','x')" |
||||
return izip(*[chain(iterable, repeat(padvalue, n-1))]*n) |
||||
|
||||
def reverse_mapping(mapping): |
||||
""" |
||||
For every key, value pair, return the mapping for the |
||||
equivalent value, key pair |
||||
""" |
||||
keys, values = 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" |
||||
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 floats" |
||||
if step is None: step = relativedelta(days=1) |
||||
if start is None: start = datetime.datetime.now() |
||||
while start < stop: |
||||
yield start |
||||
start += step |
||||
|
Loading…
Reference in new issue