Browse Source

Finished the first functional implementation of the Schedule.

Improved unit tests, including doctests.
Added several methods to util to assist with date processing.
pull/8/head
jaraco 17 years ago
parent
commit
27d46b3532
  1. 74
      lib/SVG/Schedule.py
  2. 36
      lib/SVG/__init__.py
  3. 75
      lib/SVG/util.py
  4. 5
      setup.cfg
  5. 5
      setup.py
  6. 2
      test/testing.py
  7. 8
      test/testing.rb

74
lib/SVG/Schedule.py

@ -5,7 +5,7 @@ from dateutil.parser import parse
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from SVG import Graph from SVG import Graph
from util import grouper, date_range from util import grouper, date_range, divide_timedelta_float, TimeScale
__all__ = ('Schedule') __all__ = ('Schedule')
@ -141,8 +141,8 @@ class Schedule(Graph):
will probably not be discernable if both data sets are plotted on the same will probably not be discernable if both data sets are plotted on the same
graph, since d1 is too granular. graph, since d1 is too granular.
""" """
# note, the only reason this method is overridden is to change the # The ruby version does something different here, throwing out
# docstring # any previously added data.
super(Schedule, self).add_data(data) super(Schedule, self).add_data(data)
# copied from Bar # copied from Bar
@ -166,7 +166,7 @@ class Schedule(Graph):
data = conf['data'] data = conf['data']
triples = grouper(3, data) triples = grouper(3, data)
begin_dates, end_dates, labels = zip(*triples) labels, begin_dates, end_dates = zip(*triples)
begin_dates = map(self.parse_date, begin_dates) begin_dates = map(self.parse_date, begin_dates)
end_dates = map(self.parse_date, end_dates) end_dates = map(self.parse_date, end_dates)
@ -174,7 +174,10 @@ class Schedule(Graph):
# reconstruct the triples in a new order # reconstruct the triples in a new order
reordered_triples = zip(begin_dates, end_dates, labels) 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() reordered_triples.sort()
conf['data'] = reordered_triples conf['data'] = reordered_triples
def parse_date(self, date_string): def parse_date(self, date_string):
@ -202,26 +205,34 @@ class Schedule(Graph):
return height / -2.0 return height / -2.0
def get_y_labels(self): def get_y_labels(self):
data = self.data['data'] # ruby version uses the last data supplied
last = -1
data = self.data[last]['data']
begin_dates, start_dates, labels = zip(*data) begin_dates, start_dates, labels = zip(*data)
return labels return labels
def draw_data(self): def draw_data(self):
bar_gap = self.get_bar_gap() bar_gap = self.get_bar_gap(self.get_field_height())
subbar_height = self.field_height - bar_gap subbar_height = self.get_field_height() - bar_gap
y_mod = (subbar_height / 2) + (font_size / 2) y_mod = (subbar_height / 2) + (self.font_size / 2)
x_min,x_max,div = self.x_range() x_min,x_max,div = self._x_range()
x_range = x_max - x_min x_range = x_max - x_min
scale = (float(self.graph_width) - self.font_size*2) width = (float(self.graph_width) - self.font_size*2)
scale /= x_range # 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(self.data['data']): for index, (x_start, x_end, label) in enumerate(data):
count = index + 1 # index is 0-based, count is 1-based count = index + 1 # index is 0-based, count is 1-based
y = self.graph_height - (self.field_height*count) y = self.graph_height - (self.get_field_height()*count)
bar_width = (x_end-x_stant)*scale bar_width = scale*(x_end-x_start)
bar_start = (x_start-x_min)*scale bar_start = scale*(x_start-x_min)
rect = self._create_element('rect', { rect = self._create_element('rect', {
'x': str(bar_start), 'x': str(bar_start),
@ -310,17 +321,24 @@ class Schedule(Graph):
""" """
def _x_range(self): def _x_range(self):
start_dates, end_dates, labels = zip(*self.data['data']) # 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 all_dates = start_dates + end_dates
max_value = max(all_dates) max_value = max(all_dates)
if not self.min_x_value is None: if not self.min_x_value is None:
all_dates.append(self.min_x_value) all_dates.append(self.min_x_value)
min_value = min(all_dates) min_value = min(all_dates)
range = relativedelta(max_value, min_value) range = max_value - min_value
right_pad = (range / 20.0) or relativedelta(days=10) right_pad = divide_timedelta_float(range, 20.0) or relativedelta(days=10)
scale_range = (max_value + right_pad) - min_value scale_range = (max_value + right_pad) - min_value
scale_division = self.scale_x_divisions or (scale_range / 10.0) #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 # this doesn't make sense, because x is a timescale
#if self.scale_x_integers: #if self.scale_x_integers:
@ -330,24 +348,22 @@ class Schedule(Graph):
def get_x_values(self): def get_x_values(self):
x_min, x_max, scale_division = self._x_range() x_min, x_max, scale_division = self._x_range()
if timescale_divisions: if self.timescale_divisions:
pattern = re.compile('(\d+) ?(\w+)') pattern = re.compile('(\d+) ?(\w+)')
m = pattern.match(timescale_divisions) m = pattern.match(self.timescale_divisions)
if not m: if not m:
raise ValueError, "Invalid timescale_divisions: %s" % timescale_divisions raise ValueError, "Invalid timescale_divisions: %s" % self.timescale_divisions
magnitude = int(m.groups(1)) magnitude = int(m.group(1))
units = m.groups(2) units = m.group(2)
parameter = self.lookup_relativedelta_parameter(units) parameter = self.lookup_relativedelta_parameter(units)
delta = relativedelta(**{parameter:magnitude}) delta = relativedelta(**{parameter:magnitude})
return daterange(x_min, x_max, delta) scale_division = delta
else:
# I think much of the code is assuming x is an integer (seconds) return date_range(x_min, x_max, scale_division)
# 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): def lookup_relativedelta_parameter(self, unit_string):
from util import reverse_mapping, flatten_mapping from util import reverse_mapping, flatten_mapping

36
lib/SVG/__init__.py

@ -365,7 +365,7 @@ Copyright © 2008 Jason R. Coombs
if self.rotate_x_labels: if self.rotate_x_labels:
transform = 'rotate(90 %d %d) translate(0 -%d)' % \ transform = 'rotate(90 %d %d) translate(0 -%d)' % \
(x, y-self.x_label_font_size, x_label_font_size/4) (x, y-self.x_label_font_size, self.x_label_font_size/4)
text.setAttribute('transform', transform) text.setAttribute('transform', transform)
text.setAttribute('style', 'text-anchor: start') text.setAttribute('style', 'text-anchor: start')
else: else:
@ -479,25 +479,25 @@ Copyright © 2008 Jason R. Coombs
raise NotImplementedError raise NotImplementedError
def draw_x_title(self): def draw_x_title(self):
raise NotImplementedError raise NotImplementedError
def draw_y_title(self): def draw_y_title(self):
x = self.y_title_font_size x = self.y_title_font_size
if self.y_title_text_direction=='bt': if self.y_title_text_direction=='bt':
x += 3 x += 3
rotate = -90 rotate = -90
else: else:
x -= 3 x -= 3
rotate = 90 rotate = 90
y = self.height / 2 y = self.height / 2
text = self._create_element('text', { text = self._create_element('text', {
'x': str(x), 'x': str(x),
'y': str(y), 'y': str(y),
'class': 'yAxisTitle', 'class': 'yAxisTitle',
}) })
text.appendChild(self._doc.createTextNode(self.y_title)) text.appendChild(self._doc.createTextNode(self.y_title))
text.setAttribute('transform', 'rotate(%(rotate)d, %(x)s, %(y)s)' % vars()) text.setAttribute('transform', 'rotate(%(rotate)d, %(x)s, %(y)s)' % vars())
self.root.appendChild(text) self.root.appendChild(text)
def keys(self): def keys(self):
return map(itemgetter('title'), self.data) return map(itemgetter('title'), self.data)

75
lib/SVG/util.py

@ -1,18 +1,24 @@
#!python #!python
from itertools import chain, repeat, izip from itertools import chain, repeat, izip
import datetime
# from itertools recipes (python documentation) # from itertools recipes (python documentation)
def grouper(n, iterable, padvalue=None): def grouper(n, iterable, padvalue=None):
"grouper(3, 'abcdefg', 'x') --> ('a','b','c'), ('d','e','f'), ('g','x','x')" """
>>> tuple(grouper(3, 'abcdefg', 'x'))
(('a', 'b', 'c'), ('d', 'e', 'f'), ('g', 'x', 'x'))
"""
return izip(*[chain(iterable, repeat(padvalue, n-1))]*n) return izip(*[chain(iterable, repeat(padvalue, n-1))]*n)
def reverse_mapping(mapping): def reverse_mapping(mapping):
""" """
For every key, value pair, return the mapping for the For every key, value pair, return the mapping for the
equivalent value, key pair equivalent value, key pair
>>> reverse_mapping({'a': 'b'}) == {'b': 'a'}
True
""" """
keys, values = mapping.items() keys, values = zip(*mapping.items())
return dict(zip(values, keys)) return dict(zip(values, keys))
def flatten_mapping(mapping): def flatten_mapping(mapping):
@ -33,17 +39,74 @@ def flatten_items(items):
yield (keys, value) yield (keys, value)
def float_range(start=0, stop=None, step=1): def float_range(start=0, stop=None, step=1):
"Much like the built-in function range, but accepts floats" """
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) start = float(start)
while start < stop: while start < stop:
yield start yield start
start += step start += step
def date_range(start=None, stop=None, step=None): 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) 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() if start is None: start = datetime.datetime.now()
while start < stop: while start < stop:
yield start yield start
start += step 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

5
setup.cfg

@ -1,3 +1,6 @@
[egg_info] [egg_info]
tag_build = dev tag_build = dev
tag_svn_revision = true tag_svn_revision = true
[nosetests]
with-doctest=1

5
setup.py

@ -29,5 +29,10 @@ SVG Charting library based on the Ruby SVG::Graph
], ],
entry_points = { entry_points = {
}, },
tests_require=[
'nose>=0.10',
],
test_suite = "nose.collector",
) )

2
test/testing.py

@ -115,7 +115,7 @@ g = Schedule.Schedule(dict(
show_data_labels = True, show_data_labels = True,
show_y_guidelines = False, show_y_guidelines = False,
show_x_guidelines = True, show_x_guidelines = True,
show_x_title = True, # show_x_title = True, # not yet implemented
x_title = "Time", x_title = "Time",
show_y_title = False, show_y_title = False,
rotate_x_labels = True, rotate_x_labels = True,

8
test/testing.rb

@ -143,6 +143,10 @@ data1 = [
"Psychology 101", "6/28/04", "8/9/04", "Psychology 101", "6/28/04", "8/9/04",
"Acting 105", "7/7/04", "8/16/04" "Acting 105", "7/7/04", "8/16/04"
] ]
title2 = "Another Schedule"
data2 = [
"Just one period", "5/19/04", "6/30/04"
]
g = SVG::Graph::Schedule.new( { g = SVG::Graph::Schedule.new( {
:width => 640, :width => 640,
@ -173,6 +177,10 @@ g.add_data(
:data => data1, :data => data1,
:title => "Data" :title => "Data"
) )
g.add_data(
:data => data2,
:title => title2
)
f = File.new('Schedule.rb.svg', 'w') f = File.new('Schedule.rb.svg', 'w')
f.write(g.burn()) f.write(g.burn())

Loading…
Cancel
Save