From 42255d7649e4141a06fa9710d9396376f17b29e7 Mon Sep 17 00:00:00 2001 From: jaraco Date: Fri, 23 May 2008 21:39:08 +0000 Subject: [PATCH] Began Schedule implementation (it's still riddled with and design problems --- lib/SVG/Plot.py | 8 +- lib/SVG/Schedule.py | 368 ++++++++++++++++++++++++++++++++++++++++++++ lib/SVG/__init__.py | 2 +- lib/SVG/util.py | 49 ++++++ test/testing.py | 40 ++++- test/testing.rb | 45 ++++++ 6 files changed, 504 insertions(+), 8 deletions(-) create mode 100644 lib/SVG/Schedule.py create mode 100644 lib/SVG/util.py diff --git a/lib/SVG/Plot.py b/lib/SVG/Plot.py index 471f32b..659d441 100644 --- a/lib/SVG/Plot.py +++ b/lib/SVG/Plot.py @@ -2,16 +2,12 @@ import SVG from itertools import izip, count, chain +from util import float_range + def get_pairs(i): i = iter(i) while True: yield i.next(), i.next() -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 Plot(SVG.Graph): """=== For creating SVG plots of scalar data diff --git a/lib/SVG/Schedule.py b/lib/SVG/Schedule.py new file mode 100644 index 0000000..4761c7e --- /dev/null +++ b/lib/SVG/Schedule.py @@ -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 + + 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] \ No newline at end of file diff --git a/lib/SVG/__init__.py b/lib/SVG/__init__.py index a221aba..dd2d12e 100644 --- a/lib/SVG/__init__.py +++ b/lib/SVG/__init__.py @@ -1,7 +1,7 @@ #!python # -*- coding: UTF-8 -*- -__all__ = ('Plot', 'TimeSeries', 'Bar', 'Pie') +__all__ = ('Plot', 'TimeSeries', 'Bar', 'Pie', 'Schedule') from xml.dom import minidom as dom from operator import itemgetter diff --git a/lib/SVG/util.py b/lib/SVG/util.py new file mode 100644 index 0000000..959bb1f --- /dev/null +++ b/lib/SVG/util.py @@ -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 + \ No newline at end of file diff --git a/test/testing.py b/test/testing.py index 46f5b51..dec6467 100644 --- a/test/testing.py +++ b/test/testing.py @@ -92,4 +92,42 @@ g.__dict__.update(options) g.add_data({'data': [-2, 3, 1, 3, 1], 'title': 'Female'}) g.add_data({'data': [0, 2, 1, 5, 4], 'title': 'Male'}) -open('Pie.py.svg', 'w').write(g.burn()) \ No newline at end of file +open('Pie.py.svg', 'w').write(g.burn()) + +from SVG import Schedule + +title = "Billy's Schedule" +data1 = [ + "History 107", "5/19/04", "6/30/04", + "Algebra 011", "6/2/04", "8/11/04", + "Psychology 101", "6/28/04", "8/9/04", + "Acting 105", "7/7/04", "8/16/04" + ] + +g = Schedule.Schedule(dict( + width = 640, + height = 480, + graph_title = title, + show_graph_title = True, + key = False, + scale_x_integers = True, + scale_y_integers = True, + show_data_labels = True, + show_y_guidelines = False, + show_x_guidelines = True, + show_x_title = True, + x_title = "Time", + show_y_title = False, + rotate_x_labels = True, + rotate_y_labels = False, + x_label_format = "%m/%d", + timescale_divisions = "1 week", + add_popups = True, + popup_format = "%m/%d/%y", + area_fill = True, + min_y_value = 0, + )) + +g.add_data(dict(data=data1, title="Data")) + +f = open('Schedule.py.svg', 'w').write(g.burn()) diff --git a/test/testing.rb b/test/testing.rb index e84aa6d..2b91b4d 100644 --- a/test/testing.rb +++ b/test/testing.rb @@ -132,3 +132,48 @@ g.add_data({:data=>[0,2,1,5,4], :title=>'Male'}) f = File.new('Line.rb.svg', 'w') f.write(g.burn()) f.close() + +require 'SVG/Graph/Schedule' + + +title = "Billy's Schedule" +data1 = [ + "History 107", "5/19/04", "6/30/04", + "Algebra 011", "6/2/04", "8/11/04", + "Psychology 101", "6/28/04", "8/9/04", + "Acting 105", "7/7/04", "8/16/04" + ] + +g = SVG::Graph::Schedule.new( { + :width => 640, + :height => 480, + :graph_title => title, + :show_graph_title => true, +# :no_css => true, + :key => false, + :scale_x_integers => true, + :scale_y_integers => true, + :show_data_labels => true, + :show_y_guidelines => false, + :show_x_guidelines => true, + :show_x_title => true, + :x_title => "Time", + :show_y_title => false, + :rotate_x_labels => true, + :rotate_y_labels => false, + :x_label_format => "%m/%d", +# :timescale_divisions => "1 weeks", + :add_popups => true, + :popup_format => "%m/%d/%y", + :area_fill => true, + :min_y_value => 0, +}) + +g.add_data( + :data => data1, + :title => "Data" + ) + +f = File.new('Schedule.rb.svg', 'w') +f.write(g.burn()) +f.close()