import sys import math import datetime import cairo # required debian packages: # python-cairo __programm__ = "time-sequence-graph" __author__ = "Hagen Paul Pfeifer" __version__ = "0.1" __license__ = "Public Domain" # custom exceptions class ArgumentException(Exception): pass class ErroneousTimeArgument(Exception): pass class SequenceGrapher: UNIT_SCALAR = 0 UNIT_DATETIME = 1 class UnitNormalizer: def __init__(self): self._unit = "" def repr(self, val): return "%s %s" % (str(val), self._unit) def min(self, a, b): return min(a, b) def max(self, a, b): return max(a, b) def delta(self, start, end): if (start > end): raise "%s > %s" % (str(start), str(end)) return end - start class ScalarUnitNormalizer(UnitNormalizer): def __init__(self): SequenceGrapher.UnitNormalizer.__init__(self) def repr(self, date): return "%s" % (str(date)) class DateTimeUnitNormalizer(UnitNormalizer): def delta(self, start, end): delta = end - start res = delta.microseconds res += delta.seconds * 1000000 res += delta.days * 1000000 * 60 * 60 * 24 return res def min(self, left, right): if left < right: return left else: return right def max(self, left, right): if left < right: return right else: return left def repr(self, date): return "%d-%d-%d" % (date.year, date.month, date.day) class ColorTheme: def __init__(self): self.background = (52/255.0, 69/255.0, 85/255.0) self.foreground = self.hex("#444eee") self.timeline_background = (54/255.0, 66/255.0, 78/255.0) self.timeline_border = (124/255.0, 138/255.0, 150/255.0) self.timeline_borderwidth = 1 self.linecolor = self.hex("#ffeedd") self.label_background = (44/255.0, 56/255.0, 68/255.0) self.label_foreground = self.hex("#ffffff") self.label_font_color = self.hex("#eeeeee") self.label_font_size = 5 self.sub_timeline_color = self.hex("#E5633B") self.sub_timeline_width = 5 def hex(self, color): if color[0] == '#': color = color[1:] (r, g, b) = (int(color[:2], 16), int(color[2:4], 16), int(color[4:], 16)) return self.rgb((r, g, b)) def rgb(self, color): return (color[0] / 255.0, color[1] / 255.0, color[2] / 255.0) class Timeline(object): id = 0 def __init__(self): self.id = SequenceGrapher.Timeline.id SequenceGrapher.Timeline.id += 1 self.sub_timeline_id = 0 self.sub_timelines = dict() def register_sub_timeline(self, sub_timeline): self.sub_timelines[sub_timeline.id] = sub_timeline class SubTimeline(object): def __init__(self, timeline): self.id = timeline.sub_timeline_id timeline.sub_timeline_id += 1 class Event(object): id = 0 def __init__(self): self.id = SequenceGrapher.Event.id SequenceGrapher.Event.id += 1 class Connector(object): id = 0 def __init__(self): self.id = SequenceGrapher.Connector.id SequenceGrapher.Connector.id += 1 def __init__(self, unit=UNIT_SCALAR, output="graph.pdf"): self._fileoutpath = output # default colorscheme self.color = SequenceGrapher.ColorTheme() # px self.height = 1000 self.width = 600 self.margin = 10 # default is scalar self.normalizer = SequenceGrapher.ScalarUnitNormalizer() if unit == SequenceGrapher.UNIT_DATETIME: self.normalizer = SequenceGrapher.DateTimeUnitNormalizer() self._timelines = dict() self._events = dict() self._connectors = dict() self._labels = list() self._min = self._max = None self._label_text_width = 70 self._story_width = 20 self._story_margin = 20 self._y_offset_label_guard_offset = 0 self.debug = lambda x: x #sys.stderr.write self.out = sys.stdout.write self._scaling_warning = False def register_timeline(self, tl): self._timelines[tl.id] = tl def create_timeline(self, start=None, end=None, label=None): timeline = SequenceGrapher.Timeline() timeline.start = start timeline.end = end timeline.label = label self.register_timeline(timeline) return timeline def create_sub_timeline(self, timeline, start=None, end=None, label=None, color=None): sub_timeline = SequenceGrapher.SubTimeline(timeline) sub_timeline.start = start sub_timeline.end = end sub_timeline.label = label if not color: sub_timeline.color = self.color.sub_timeline_color else: sub_timeline_color = self.color.hex(color) timeline.register_sub_timeline(sub_timeline) return sub_timeline def register_event(self, ev): self._events[ev.id] = ev def create_event(self, time=None): event = SequenceGrapher.Event() event.time = time self.register_event(event) return event def register_connector(self, connector): self._connectors[connector.id] = connector def create_connector(self, tl1=None, tl2=None, time1=None, time2=None): connector = SequenceGrapher.Connector() connector.tl1 = tl1 connector.tl2 = tl2 # the semantic start and end do not exist, # but is handy here to avoid changing the # functions connector.start = time1 connector.end = time2 self.register_connector(connector) return connector def calculate_global_min_max(self): data_set = [self._timelines, self._events, self._connectors] for datum in data_set: for key in datum.iterkeys(): lmin = self.normalizer.min(datum[key].start, datum[key].end) lmax = self.normalizer.max(datum[key].start, datum[key].end) self._labels.append(datum[key].start) self._labels.append(datum[key].end) if not self._min: self._min = lmin else: self._min = self.normalizer.min(self._min, lmin) if not self._max: self._max = lmax else: self._max = self.normalizer.max(self._max, lmax) def calculate_number_stories(self): self._stories = 0 data_set = [self._timelines, self._events] for datum in data_set: for key in datum.iterkeys(): self._stories += 1 # ok, we know how many stories then we know # the whole width story_width = self._story_width * self._stories if self._stories > 1: story_width += self._story_margin * (self._stories - 1) # and because we know the paper size (and label size) # we now know how to align the block to the center self._content_start_position = ((self.width - self._label_text_width) - story_width) / 2 self._content_start_position += self._label_text_width def calculate_min_max_diff(self): self.normalized_diff = self.normalizer.delta(self._min, self._max) def calculate_offset(self, point): return int(float(self.normalizer.delta(self._min, point)) / self._scaling) + self.margin def setup_coordinates(self): self.calculate_global_min_max() self.calculate_min_max_diff() self.debug("min: %s\n" % (self._min)) self.debug("max: %s\n" % (self._max)) # ok, time to map these data to our # actual piece of piece of paper self._scaling = float(self.normalized_diff) / (self.height - (2 * self.margin)) self.debug("scaling factor: %.2lf\n" % (self._scaling)) self.calculate_number_stories() self.debug("stories: %d\n" % (self._stories)) def draw_sub_timelines(self, timeline, timeline_start, timeline_end): sub_timelines = timeline.sub_timelines for key in sub_timelines.iterkeys(): start = self.calculate_offset(sub_timelines[key].start) end = self.calculate_offset(sub_timelines[key].end) if start < timeline_start: raise ErroneousTimeArgument if end > timeline_end: raise ErroneousTimeArgument if start == end: # normally this SHOULD be invalid (start and # end at the same time. But remember: start and # and is already normalized, thus scaling can map # two event to the same time. We add one to make # the event visible if not self._scaling_warning: self.out("scaling to low, increase the image size\n") self._scaling_warning = True if end == timeline_end: start -= 1 else: end += 1 x_offset = timeline._right_position - self.color.sub_timeline_width self._cr.set_line_width(0) self._cr.rectangle(x_offset, start, self.color.sub_timeline_width - 1, end - start) self._cr.set_source_rgb(*sub_timelines[key].color) self._cr.stroke_preserve() self._cr.fill() def draw_timelines(self): x_offset = self._content_start_position for key in self._timelines.iterkeys(): start = self.calculate_offset(self._timelines[key].start) end = self.calculate_offset(self._timelines[key].end) self.debug("draw timeline: start: %5d end: %5d\n" % (start, end)) self._cr.set_line_width(self.color.timeline_borderwidth) self._cr.rectangle(x_offset, start, self._story_width, end - start) self._cr.set_source_rgb(*self.color.timeline_background) self._cr.stroke_preserve() self._cr.fill() self._cr.move_to(0, 0) self._cr.set_line_width(1) self._cr.rectangle(x_offset, start, self._story_width, end - start) self._cr.set_source_rgb(*self.color.timeline_border) self._cr.stroke() # save horizontal coordinates for connectors self._timelines[key]._left_position = x_offset self._timelines[key]._right_position = x_offset + self._story_width self.draw_sub_timelines(self._timelines[key], start, end) if self._timelines[key].label: text = self._timelines[key].label x_bearing, y_bearing, width, height = self._cr.text_extents(text)[:4] self._cr.save() self._cr.set_source_rgb(*self.color.label_font_color) self._cr.set_font_size(8) self._cr.move_to(x_offset + height, start + (width - (width/8))) self._cr.rotate(-math.pi / 2) self._cr.show_text(self._timelines[key].label) self._cr.stroke() self._cr.restore() x_offset += self._story_width + self._story_margin def draw_events(self): pass def draw_connectors(self): for key in self._connectors.iterkeys(): start = self.calculate_offset(self._connectors[key].start) end = self.calculate_offset(self._connectors[key].end) self.debug("draw connector: start: %5d end: %5d\n" % (start, end)) # calculate the horizontal position, depending # where the timelines are coordinated if self._connectors[key].tl1._left_position < self._connectors[key].tl2._left_position: horizontal_start = self._connectors[key].tl1._right_position horizontal_end = self._connectors[key].tl2._left_position else: horizontal_start = self._connectors[key].tl1._left_position horizontal_end = self._connectors[key].tl2._right_position self._cr.move_to(horizontal_start, start) self._cr.curve_to(horizontal_start + 20, start, horizontal_end - 20, end, horizontal_end, end) self._cr.set_line_width(1) self._cr.stroke() def draw_labels(self): for label in sorted(self._labels): text = self.normalizer.repr(label) y_offset = self.calculate_offset(label) x_off = 5 self._cr.set_font_size(self.color.label_font_size) x_bearing, y_bearing, width, height = self._cr.text_extents(text)[:4] if y_offset < self._y_offset_label_guard_offset: continue y_off = y_offset + height / 2 #100 - (height) - 10 self._cr.move_to(x_off, y_off) self._cr.set_source_rgb(*self.color.label_foreground) self._cr.show_text(text) self._cr.stroke() self._y_offset_label_guard_offset = y_offset + height * 2 def draw_label_box(self): self._cr.move_to(0, 0) self._cr.rectangle(0, 0, self._label_text_width, self.height) self._cr.set_source_rgb(*self.color.label_background) self._cr.fill() def draw_paper(self): self._surface = cairo.PDFSurface(self._fileoutpath, self.width, self.height) self._cr = cairo.Context(self._surface) self._cr.move_to(0, 0) self._cr.set_source_rgb(*self.color.background) # white self._cr.rectangle(0, 0, self.width, self.height) self._cr.fill() self._cr.stroke() def finalize(self): self._cr.show_page() self.out("create file %s\n" % (self._fileoutpath)) def draw(self): self.setup_coordinates() self.draw_paper() self.draw_label_box() self.draw_timelines() self.draw_connectors() self.draw_events() self.draw_labels() self.finalize()