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 SVG import Graph
from util import grouper, date_range
from util import grouper, date_range, divide_timedelta_float, TimeScale
__all__ = ('Schedule')
@ -141,8 +141,8 @@ class Schedule(Graph):
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
# The ruby version does something different here, throwing out
# any previously added data.
super(Schedule, self).add_data(data)
# copied from Bar
@ -166,7 +166,7 @@ class Schedule(Graph):
data = conf['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)
end_dates = map(self.parse_date, end_dates)
@ -174,7 +174,10 @@ class Schedule(Graph):
# 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):
@ -202,26 +205,34 @@ class Schedule(Graph):
return height / -2.0
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)
return labels
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)
x_min,x_max,div = self.x_range()
y_mod = (subbar_height / 2) + (self.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
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(self.data['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.field_height*count)
bar_width = (x_end-x_stant)*scale
bar_start = (x_start-x_min)*scale
y = self.graph_height - (self.get_field_height()*count)
bar_width = scale*(x_end-x_start)
bar_start = scale*(x_start-x_min)
rect = self._create_element('rect', {
'x': str(bar_start),
@ -310,17 +321,24 @@ class Schedule(Graph):
"""
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
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)
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)
#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:
@ -330,24 +348,22 @@ class Schedule(Graph):
def get_x_values(self):
x_min, x_max, scale_division = self._x_range()
if timescale_divisions:
if self.timescale_divisions:
pattern = re.compile('(\d+) ?(\w+)')
m = pattern.match(timescale_divisions)
m = pattern.match(self.timescale_divisions)
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))
units = m.groups(2)
magnitude = int(m.group(1))
units = m.group(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)
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

36
lib/SVG/__init__.py

@ -365,7 +365,7 @@ Copyright © 2008 Jason R. Coombs
if self.rotate_x_labels:
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('style', 'text-anchor: start')
else:
@ -479,25 +479,25 @@ Copyright © 2008 Jason R. Coombs
raise NotImplementedError
def draw_x_title(self):
raise NotImplementedError
raise NotImplementedError
def draw_y_title(self):
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 = self._create_element('text', {
'x': str(x),
'y': str(y),
'class': 'yAxisTitle',
})
text.appendChild(self._doc.createTextNode(self.y_title))
text.setAttribute('transform', 'rotate(%(rotate)d, %(x)s, %(y)s)' % vars())
self.root.appendChild(text)
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 = self._create_element('text', {
'x': str(x),
'y': str(y),
'class': 'yAxisTitle',
})
text.appendChild(self._doc.createTextNode(self.y_title))
text.setAttribute('transform', 'rotate(%(rotate)d, %(x)s, %(y)s)' % vars())
self.root.appendChild(text)
def keys(self):
return map(itemgetter('title'), self.data)

75
lib/SVG/util.py

@ -1,18 +1,24 @@
#!python
from itertools import chain, repeat, izip
import datetime
# from itertools recipes (python documentation)
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)
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 = mapping.items()
keys, values = zip(*mapping.items())
return dict(zip(values, keys))
def flatten_mapping(mapping):
@ -33,17 +39,74 @@ def flatten_items(items):
yield (keys, value)
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)
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)
"""
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

5
setup.cfg

@ -1,3 +1,6 @@
[egg_info]
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 = {
},
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_y_guidelines = False,
show_x_guidelines = True,
show_x_title = True,
# show_x_title = True, # not yet implemented
x_title = "Time",
show_y_title = False,
rotate_x_labels = True,

8
test/testing.rb

@ -143,6 +143,10 @@ data1 = [
"Psychology 101", "6/28/04", "8/9/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( {
:width => 640,
@ -173,6 +177,10 @@ g.add_data(
:data => data1,
:title => "Data"
)
g.add_data(
:data => data2,
:title => title2
)
f = File.new('Schedule.rb.svg', 'w')
f.write(g.burn())

Loading…
Cancel
Save