Created
June 12, 2020 06:58
-
-
Save plexus/441daeb17c225c35ae7319c4a054db86 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/local/bin/python | |
# https://github.com/jasonreisman/Timeline | |
# adapted for python3 | |
# | |
# apt-get install python3-parsedatetime python3-svgwrite python3-tk | |
import parsedatetime | |
import svgwrite | |
import datetime | |
import json | |
import os.path | |
import sys | |
from tkinter import font | |
import tkinter | |
class Colors: | |
black = '#000000' | |
gray = '#C0C0C0' | |
class Timeline: | |
def __init__(self, filename): | |
# load timeline data | |
s = '' | |
with open(filename) as f: | |
s = f.read() | |
self.data = json.loads(s) | |
assert 'width' in self.data, 'width property must be set' | |
assert 'start' in self.data, 'start property must be set' | |
assert 'end' in self.data, 'end property must be set' | |
# create drawing | |
self.width = self.data['width'] | |
self.drawing = svgwrite.Drawing() | |
self.drawing['width'] = self.width | |
self.g_axis = self.drawing.g() | |
# figure out timeline boundaries | |
self.cal = parsedatetime.Calendar() | |
self.start_date = self.datetime_from_string(self.data['start']) | |
self.end_date = self.datetime_from_string(self.data['end']) | |
delta = self.end_date[0] - self.start_date[0] | |
padding = datetime.timedelta(seconds=0.1*delta.total_seconds()) | |
self.date0 = self.start_date[0] - padding | |
self.date1 = self.end_date[0] + padding | |
self.total_secs = (self.date1 - self.date0).total_seconds() | |
# set up some params | |
self.callout_size = (10, 15, 10) # width, height, increment | |
self.text_fudge = (3, 1.5) | |
self.tick_format = self.data.get('tick_format', None) | |
self.markers = {} | |
# initialize Tk so that font metrics will work | |
self.tk_root = tkinter.Tk() | |
self.fonts = {} | |
# max_label_height stores the max height of all axis labels | |
# and is used in the final height computation in build(self) | |
self.max_label_height = 0 | |
def build(self): | |
# MAGIC NUMBER: y_era | |
# draw era label and markers at this height | |
y_era = 10 | |
# create main axis and callouts, | |
# keeping track of how high the callouts are | |
self.create_main_axis() | |
y_callouts = self.create_callouts() | |
# determine axis position so that axis + callouts don't overlap with eras | |
y_axis = y_era + self.callout_size[1] - y_callouts | |
# determine height so that eras, callouts, axis, and labels just fit | |
height = y_axis + self.max_label_height + 4*self.text_fudge[1] | |
# create eras and labels using axis height and overall height | |
self.create_eras(y_era, y_axis, height) | |
self.create_era_axis_labels() | |
# translate the axis group and add it to the drawing | |
self.g_axis.translate(0, y_axis) | |
self.drawing.add(self.g_axis) | |
# finally set the height on the drawing | |
self.drawing['height'] = height | |
def save(self, filename): | |
self.drawing.saveas(filename) | |
def to_string(self): | |
return self.drawing.tostring() | |
def datetime_from_string(self, s): | |
dt, flag = self.cal.parse(s) | |
if flag in (1, 2): | |
dt = datetime.datetime(*dt[:6]) | |
else: | |
dt = datetime.datetime(*dt[:6]) | |
return dt, flag | |
def create_eras(self, y_era, y_axis, height): | |
if 'eras' not in self.data: | |
return | |
# create eras | |
eras_data = self.data['eras'] | |
markers = {} | |
for era in eras_data: | |
# extract era data | |
name = era[0] | |
t0 = self.datetime_from_string(era[1]) | |
t1 = self.datetime_from_string(era[2]) | |
fill = era[3] if len(era) > 3 else Colors.gray | |
# get marker objects | |
start_marker, end_marker = self.get_markers(fill) | |
assert start_marker is not None | |
assert end_marker is not None | |
# create boundary lines | |
percent_width0 = (t0[0] - self.date0).total_seconds()/self.total_secs | |
percent_width1 = (t1[0] - self.date0).total_seconds()/self.total_secs | |
x0 = int(percent_width0*self.width + 0.5) | |
x1 = int(percent_width1*self.width + 0.5) | |
rect = self.drawing.add(self.drawing.rect((x0, 0), (x1-x0, height))) | |
rect.fill(fill, None, 0.15) | |
line0 = self.drawing.add(self.drawing.line((x0,0), (x0, y_axis), stroke=fill, stroke_width=0.5)) | |
line0.dasharray([5, 5]) | |
line1 = self.drawing.add(self.drawing.line((x1,0), (x1, y_axis), stroke=fill, stroke_width=0.5)) | |
line1.dasharray([5, 5]) | |
# create horizontal arrows and text | |
horz = self.drawing.add(self.drawing.line((x0, y_era), (x1, y_era), stroke=fill, stroke_width=0.75)) | |
horz['marker-start'] = start_marker.get_funciri() | |
horz['marker-end'] = end_marker.get_funciri() | |
self.drawing.add(self.drawing.text(name, insert=(0.5*(x0 + x1), y_era - self.text_fudge[1]), stroke='none', fill=fill, font_family="Helevetica", font_size="6pt", text_anchor="middle")) | |
def get_markers(self, color): | |
# create or get marker objects | |
start_marker, end_marker = None, None | |
if color in self.markers: | |
start_marker, end_marker = self.markers[color] | |
else: | |
start_marker = self.drawing.marker(insert=(0,3), size=(10,10), orient='auto') | |
start_marker.add(self.drawing.path("M6,0 L6,7 L0,3 L6,0", fill=color)) | |
self.drawing.defs.add(start_marker) | |
end_marker = self.drawing.marker(insert=(6,3), size=(10,10), orient='auto') | |
end_marker.add(self.drawing.path("M0,0 L0,7 L6,3 L0,0", fill=color)) | |
self.drawing.defs.add(end_marker) | |
self.markers[color] = (start_marker, end_marker) | |
return start_marker, end_marker | |
def create_main_axis(self): | |
# draw main line | |
self.g_axis.add(self.drawing.line((0, 0), (self.width, 0), stroke=Colors.black, stroke_width=3)) | |
# add tickmarks | |
self.add_axis_label(self.start_date, str(self.start_date[0]), tick=True) | |
self.add_axis_label(self.end_date, str(self.end_date[0]), tick=True) | |
if 'num_ticks' in self.data: | |
delta = self.end_date[0] - self.start_date[0] | |
secs = delta.total_seconds() | |
num_ticks = self.data['num_ticks'] | |
for j in range(1, num_ticks): | |
tick_delta = datetime.timedelta(seconds=j*secs/num_ticks) | |
tickmark_date = self.start_date[0] + tick_delta | |
self.add_axis_label([tickmark_date], str(tickmark_date), tick=True) | |
def create_era_axis_labels(self): | |
if 'eras' not in self.data: | |
return | |
eras_data = self.data['eras'] | |
for era in eras_data: | |
# extract era data | |
t0 = self.datetime_from_string(era[1]) | |
t1 = self.datetime_from_string(era[2]) | |
# add marks on axis | |
self.add_axis_label(t0, str(t0[0]), tick=False, fill=Colors.black) | |
self.add_axis_label(t1, str(t1[0]), tick=False, fill=Colors.black) | |
def add_axis_label(self, dt, label, **kwargs): | |
if self.tick_format: | |
label = dt[0].strftime(self.tick_format) | |
percent_width = (dt[0] - self.date0).total_seconds()/self.total_secs | |
if percent_width < 0 or percent_width > 1: | |
return | |
x = int(percent_width*self.width + 0.5) | |
dy = 5 | |
# add tick on line | |
add_tick = kwargs.get('tick', True) | |
if add_tick: | |
stroke = kwargs.get('stroke', Colors.black) | |
self.g_axis.add(self.drawing.line((x,-dy), (x,dy), stroke=stroke, stroke_width=2)) | |
# add label | |
fill = kwargs.get('fill', Colors.gray) | |
transform = "rotate(180, %i, 0)" % (x) | |
self.g_axis.add(self.drawing.text(label, insert=(x, -2*dy), stroke='none', fill=fill, font_family='Helevetica', font_size='6pt', text_anchor='end', writing_mode='tb', transform=transform)) | |
h = self.get_text_metrics('Helevetica', 6, label)[0] + 2*dy | |
self.max_label_height = max(self.max_label_height, h) | |
def create_callouts(self): | |
min_y = float('inf') | |
if 'callouts' not in self.data: | |
return | |
callouts_data = self.data['callouts'] | |
# sort callouts | |
sorted_dates = [] | |
inv_callouts = {} | |
for callout in callouts_data: | |
event = callout[0] | |
event_date = self.datetime_from_string(callout[1]) | |
event_color = callout[2] if len(callout) > 2 else Colors.black | |
sorted_dates.append(event_date) | |
if event_date not in inv_callouts: | |
inv_callouts[event_date] = [] | |
inv_callouts[event_date].append((event, event_color)) | |
sorted_dates.sort() | |
# add callouts, one by one, making sure they don't overlap | |
prev_x = [float('-inf')] | |
prev_level = [-1] | |
for event_date in sorted_dates: | |
event, event_color = inv_callouts[event_date].pop() | |
num_sec = (event_date[0] - self.date0).total_seconds() | |
percent_width = num_sec/self.total_secs | |
if percent_width < 0 or percent_width > 1: | |
continue | |
x = int(percent_width*self.width + 0.5) | |
# figure out what 'level" to make the callout on | |
k = 0 | |
i = len(prev_x) - 1 | |
left = x - (self.get_text_metrics('Helevetica', 6, event)[0] + self.callout_size[0] + self.text_fudge[0]) | |
while left < prev_x[i] and i >= 0: | |
k = max(k, prev_level[i] + 1) | |
i -= 1 | |
y = 0 - self.callout_size[1] - k*self.callout_size[2] | |
min_y = min(min_y, y) | |
#self.drawing.add(self.drawing.circle((left, y), stroke='red', stroke_width=2)) | |
path_data = 'M%i,%i L%i,%i L%i,%i' % (x, 0, x, y, x - self.callout_size[0], y) | |
self.g_axis.add(self.drawing.path(path_data, stroke=event_color, stroke_width=1, fill='none')) | |
self.g_axis.add(self.drawing.text(event, insert=(x - self.callout_size[0] - self.text_fudge[0], y + self.text_fudge[1]), stroke='none', fill=event_color, font_family='Helevetica', font_size='6pt', text_anchor='end')) | |
self.add_axis_label(event_date, str(event_date[0]), tick=False, fill=Colors.black) | |
self.g_axis.add(self.drawing.circle((x, 0), r=4, stroke=event_color, stroke_width=1, fill='white')) | |
prev_x.append(x) | |
prev_level.append(k) | |
return min_y | |
def get_text_metrics(self, family, size, text): | |
font_ = None | |
key = (family, size) | |
if key in self.fonts: | |
font_ = self.fonts[key] | |
else: | |
font_ = font.Font(family=family, size=size) | |
self.fonts[key] = font_ | |
assert font is not None | |
w, h = (font_.measure(text), font_.metrics("linespace")) | |
return w, h | |
def usage(): | |
print('Usage: ./make_timeline.py in.json > out.svg') | |
sys.exit(-1) | |
if __name__ == '__main__': | |
if len(sys.argv) < 2: | |
print('missing input filename') | |
usage() | |
filename = sys.argv[1] | |
if not os.path.isfile(filename): | |
print('file %s not found' % filename) | |
sys.exit(-1) | |
timeline = Timeline(filename) | |
timeline.build() | |
print(timeline.to_string()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment