Skip to content

Instantly share code, notes, and snippets.

@asciipip
Created April 7, 2025 15:32
Show Gist options
  • Save asciipip/8ef2c1f5f3d082ede6061343361e013f to your computer and use it in GitHub Desktop.
Save asciipip/8ef2c1f5f3d082ede6061343361e013f to your computer and use it in GitHub Desktop.
Code to draw a timeline with dependencies. Kind of like a Gantt chart, but with differences I thought fit my usage better.
#!/usr/bin/env python3
"""
This is a sanitized version of the code I used to generate https://static.aperiodic.net/name-change/timeline.svg .
You need PyCairo installed.
"""
import cmath
import collections
import datetime
import enum
import functools
import math
import cairo
# NOTE: Export to PNG with Inkscape at 112 DPI.
OUTPUT_FILENAME = 'timeline.svg'
GRAPH_WIDTH = 8 * 72
GRAPH_HEIGHT = 5 * 72
GRAPH_TOP_MARGIN = 1 * 72
GRAPH_BOTTOM_MARGIN = 0.25 * 72
GRAPH_MARGIN = 0.65 * 72
# Could be automatically calculated, but I have it here for ease of
# processing.
MAX_GROUPS = 19
# Range of the chart. While this was in process, I used
# `datetime.date.today()` for END_DATE.
START_DATE = datetime.date(2024, 11, 1)
END_DATE = datetime.date(2025, 4, 5)
DAY_WIDTH = GRAPH_WIDTH / (END_DATE - START_DATE).days
EVENT_RADIUS = DAY_WIDTH * 2 / 5
SPAN_THICKNESS = EVENT_RADIUS * 0.75
DEPENDENCY_THICKNESS = SPAN_THICKNESS / 3
DAY_SHADE = 0.95
MONTH_SHADE = 0.75
DEPENDENCY_SHADE = 0.25
FONT = 'Lato'
FONT_SIZE_TITLE = 16
FONT_SIZE_LABEL = 10
FONT_SIZE_LEGEND = 9
FONT_SIZE_ANNOTATION = 7
HOLIDAYS = set([
datetime.date(2024, 11, 11), # Veterans Day
datetime.date(2024, 11, 28), # Thanksgiving
datetime.date(2024, 12, 25), # Christmas
datetime.date(2025, 1, 1), # New Year's Day
datetime.date(2025, 1, 20), # MLK Day/Inauguration Day
datetime.date(2025, 2, 17), # Presidents Day
# The next holiday is May 26, Memorial Day. Let's hope nothing takes
# _that_ long.
])
Halign = enum.Enum('Halign', ['LEFT', 'CENTER', 'RIGHT'])
Valign = enum.Enum('Valign', ['TOP', 'MIDDLE', 'BASELINE', 'BOTTOM'])
ActionType = enum.Enum('ActionType', ['SELF', 'REMOTE', 'MAIL', 'SCHEDULE', 'CHANGE_EFFECTED'])
def date_to_x(dt):
days_elapsed = (dt - START_DATE).days
return DAY_WIDTH * days_elapsed
def group_to_y(group_no):
return GRAPH_HEIGHT / MAX_GROUPS * group_no
@functools.total_ordering
class Group:
def __init__(self, name, is_done, dependency):
self.name = name
self.is_done = is_done
self.dependency = dependency
self.events = []
self.group_no = -1
if dependency is not None:
dependency.add_dependent(self)
def __repr__(self):
return '<Group({}/{} {})>'.format(self.start_date, self.end_date, self.name)
def __lt__(self, other):
return \
(self.start_date, self.end_date if self.end_date is not None else END_DATE, self.total_dependent_count) < \
(other.start_date, other.end_date if other.end_date is not None else END_DATE, other.total_dependent_count)
def set_group_no(self, group_no):
self.group_no = group_no
next_group_no = self.group_no + 1
for dependent in self.dependents:
dependent.set_group_no(next_group_no)
next_group_no += 1 + dependent.total_dependent_count
def add_event(self, child):
self.events.append(child)
self.events.sort()
child.set_group(self)
@property
def start_date(self):
return self.events[0].date
@property
def end_date(self):
if self.is_done:
return self.events[-1].date
else:
return None
@property
def left(self):
return date_to_x(self.start_date)
@property
def right(self):
return date_to_x(self.end_date if self.end_date is not None else END_DATE)
@property
def y(self):
return group_to_y(self.group_no)
@property
def dependents(self):
result = []
for event in self.events:
result.extend(event.dependents)
return sorted(result)
@property
def total_dependent_count(self):
count = 0
for event in self.events:
for dependent in event.dependents:
count += 1 + dependent.total_dependent_count
return count
@functools.total_ordering
class Event:
def __init__(self, date, name, action_type=ActionType.SELF, annotate=False, annotate_halign=0.5, annotate_above=True):
"""annotate_halign gives the fraction of the text that should be
drawn to the left of the event date. 0 means the text is entirely
on the right side of the event. The default value, 0.5, centers
the text over the date. 1 means the text is entirely on the left
side of the event."""
self.date = date
self.name = name
self.action_type = action_type
self.annotate = annotate
self.annotate_halign = annotate_halign
self.annotate_above = annotate_above
self.dependents = []
def __repr__(self):
return "<Event({} {})>".format(self.date, self.name)
def __lt__(self, other):
return (self.date, self.name) < (other.date, other.name)
@property
def x(self):
return date_to_x(self.date)
@property
def y(self):
return self.group.y
def set_group(self, group):
self.group = group
def add_dependent(self, dependent):
self.dependents.append(dependent)
def main():
canvas_width = GRAPH_WIDTH + 2 * GRAPH_MARGIN
canvas_height = GRAPH_HEIGHT + GRAPH_TOP_MARGIN + GRAPH_BOTTOM_MARGIN
surface = cairo.SVGSurface(OUTPUT_FILENAME, canvas_width, canvas_height)
ctx = cairo.Context(surface)
ctx.rectangle(0, 0, canvas_width, canvas_height)
ctx.set_source_rgb(1, 1, 1)
ctx.fill()
ctx.translate(GRAPH_MARGIN, GRAPH_TOP_MARGIN)
ctx.set_source_rgb(0, 0, 0)
ctx.select_font_face(FONT)
ctx.set_font_size(FONT_SIZE_LABEL)
draw_decorations(ctx)
draw_key(ctx)
group_1 = Group('Name of group 1', True, None)
group_1.add_event(Event(datetime.date(2024, 11, 7), 'Action 1', annotate=True, annotate_halign=0))
group_1.add_event(Event(datetime.date(2024, 12, 12), 'Action 2', action_type=ActionType.REMOTE))
event_g1_a3 = Event(datetime.date(2024, 12, 13), 'Action 3 (dependency)', annotate=True, annotate_halign=0.3)
group_1.add_event(event_g1_a3)
group_1.add_event(Event(datetime.date(2024, 12, 16), 'Uncertified court order', action_type=ActionType.MAIL))
group_2 = Group('Second Group', True, event_g1_a3)
group_2.add_event(Event(datetime.date(2024, 12, 13), 'Action 1', action_type=ActionType.SCHEDULE))
event_g2_a2 = Event(datetime.date(2024, 12, 23), 'Action 2', annotate=True)
group_2.add_event(event_g2_a2)
group_2.add_event(Event(datetime.date(2025, 1, 2), 'Action 3', action_type=ActionType.MAIL, annotate=True))
# Must explicitly set group number 1 on the first group in the
# sequence.
group_1.set_group_no(1)
draw_group(ctx, group_1)
def draw_decorations(ctx):
pattern_surface = cairo.SVGSurface(None, GRAPH_WIDTH, GRAPH_HEIGHT)
pattern_context = cairo.Context(pattern_surface)
pattern_context.set_source_rgb(DAY_SHADE, DAY_SHADE, DAY_SHADE)
pattern_context.set_line_width(2 * max(GRAPH_WIDTH, GRAPH_HEIGHT))
pattern_context.set_dash([DAY_WIDTH * math.sqrt(2) / 4, DAY_WIDTH * math.sqrt(2) / 4])
pattern_context.move_to(-DAY_WIDTH / 2, group_to_y(0.5))
pattern_context.line_to(max(GRAPH_WIDTH - DAY_WIDTH / 2, GRAPH_HEIGHT + group_to_y(0.5)), max(GRAPH_WIDTH - DAY_WIDTH / 2, GRAPH_HEIGHT + group_to_y(0.5)))
pattern_context.stroke()
pattern = cairo.SurfacePattern(pattern_surface)
ctx.save()
ctx.save()
ctx.set_font_size(FONT_SIZE_TITLE)
ctx.select_font_face(FONT, cairo.FontSlant.NORMAL)
draw_text(ctx, GRAPH_WIDTH / 2, -GRAPH_TOP_MARGIN / 2, 'Name Change Process Timeline', halign=Halign.CENTER, valign=Valign.MIDDLE)
ctx.restore()
ctx.select_font_face(FONT, cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD)
ctx.set_line_width(DAY_WIDTH)
day = START_DATE
while day <= END_DATE:
if day.isoweekday() >= 6 or day in HOLIDAYS:
if day.isoweekday() >= 6:
ctx.set_source_rgb(DAY_SHADE, DAY_SHADE, DAY_SHADE)
elif day in HOLIDAYS:
ctx.set_source(pattern)
ctx.move_to(date_to_x(day), group_to_y(0))
ctx.line_to(date_to_x(day), GRAPH_HEIGHT - group_to_y(0.5))
ctx.stroke()
day += datetime.timedelta(days=1)
ctx.set_line_width(0.5)
month_start = START_DATE.replace(day=1)
while month_start < END_DATE:
month_start_x = date_to_x(month_start)
month_end = min((month_start + datetime.timedelta(days=32)).replace(day=1), END_DATE)
month_end_x = date_to_x(month_end)
month_middle_x = (month_start_x + month_end_x) / 2
ctx.set_source_rgb(0, 0, 0)
draw_text(ctx, month_middle_x, -6, month_start.strftime('%b'), halign=Halign.CENTER)
ctx.set_source_rgb(MONTH_SHADE, MONTH_SHADE, MONTH_SHADE)
ctx.move_to(month_start_x - DAY_WIDTH / 2, group_to_y(0))
ctx.line_to(month_start_x - DAY_WIDTH / 2, GRAPH_HEIGHT - group_to_y(0.5))
ctx.stroke()
month_start = (month_start + datetime.timedelta(days=32)).replace(day=1)
ctx.restore()
def draw_key(ctx):
ctx.save()
ctx.set_font_size(FONT_SIZE_LEGEND)
key_margin = DAY_WIDTH
line_height = 1.1
font_ascent, font_descent, font_height, font_max_x_advance, font_max_y_advance = ctx.font_extents()
draw_key_label(ctx,
key_margin + EVENT_RADIUS,
GRAPH_HEIGHT - group_to_y(0.5) - key_margin - EVENT_RADIUS,
'Other action, not done by me',
ActionType.REMOTE)
draw_key_label(ctx,
key_margin + EVENT_RADIUS,
GRAPH_HEIGHT - group_to_y(0.5) - key_margin - font_height * line_height - EVENT_RADIUS,
'Item received in the mail',
ActionType.MAIL)
draw_key_label(ctx,
key_margin + EVENT_RADIUS,
GRAPH_HEIGHT - group_to_y(0.5) - key_margin - 2 * font_height * line_height - EVENT_RADIUS,
'Change made in organization’s records',
ActionType.CHANGE_EFFECTED)
draw_key_label(ctx,
key_margin + EVENT_RADIUS,
GRAPH_HEIGHT - group_to_y(0.5) - key_margin - 3 * font_height * line_height - EVENT_RADIUS,
'Action done by me',
ActionType.SELF)
draw_key_label(ctx,
key_margin + EVENT_RADIUS,
GRAPH_HEIGHT - group_to_y(0.5) - key_margin - 4 * font_height * line_height - EVENT_RADIUS,
'Future action scheduled',
ActionType.SCHEDULE)
ctx.restore()
def draw_key_label(ctx, x, y, label, action_type):
draw_action_symbol(ctx, x, y, action_type)
e_extents = ctx.text_extents('e')
label_y = y + e_extents.height / 2
draw_text(ctx, x + 2.5 * EVENT_RADIUS, label_y, label, halign=Halign.LEFT, valign=Valign.BASELINE)
def draw_group(ctx, group):
for dependency in group.dependents[:-1]:
draw_dependency(ctx, dependency, draw_left_bar=False)
if len(group.dependents) > 0:
draw_dependency(ctx, group.dependents[-1], draw_left_bar=True)
draw_span(ctx, group)
draw_group_label(ctx, group)
for event in group.events:
draw_event(ctx,event)
for dependency in group.dependents:
draw_group(ctx, dependency)
def draw_span(ctx, group):
if group.is_done:
end_date = group.end_date
else:
end_date = END_DATE
ctx.save()
ctx.set_line_width(SPAN_THICKNESS)
ctx.move_to(group.left, group.y)
ctx.line_to(group.right, group.y)
ctx.stroke()
if not group.is_done:
ctx.move_to(group.right, group.y - SPAN_THICKNESS / 2)
ctx.line_to(group.right, group.y + SPAN_THICKNESS / 2)
ctx.line_to(group.right + SPAN_THICKNESS * 1.5, group.y)
ctx.fill()
ctx.restore()
def draw_dependency(ctx, group, draw_left_bar):
ctx.save()
ctx.set_source_rgb(DEPENDENCY_SHADE, DEPENDENCY_SHADE, DEPENDENCY_SHADE)
ctx.set_line_width(DEPENDENCY_THICKNESS)
if draw_left_bar:
ctx.move_to(group.dependency.x, group.dependency.y)
ctx.line_to(group.dependency.x, group.y)
else:
ctx.move_to(group.dependency.x, group.y)
if group.dependency.x != group.left:
ctx.line_to(group.left, group.y)
ctx.stroke()
ctx.restore()
def draw_group_label(ctx, group):
fe = ctx.font_extents()
space_extents = ctx.text_extents('e')
if group.dependency is None or (group.dependency.x == group.events[0].x and group.events[0].action_type != ActionType.SCHEDULE):
label_x = group.left - DAY_WIDTH / 2 - space_extents.width
else:
label_x = group.dependency.x - space_extents.width
# The label text looks best when the dependency centerline evenly
# divides the lower case letters horizontally. The best way to get
# that seems to be to pick a lowercase letter with no ascenders or
# descenders and see how tall it is. We'll use "e".
e_extents = ctx.text_extents('e')
label_y = group.y + e_extents.height / 2
halign = Halign.RIGHT
valign = Valign.BASELINE
draw_text(ctx, label_x, label_y, group.name, halign=halign, valign=valign)
def draw_event(ctx, event):
if event.annotate:
ctx.save()
ctx.set_font_size(FONT_SIZE_ANNOTATION)
font_ascent, font_descent, font_height, font_max_x_advance, font_max_y_advance = ctx.font_extents()
te = ctx.text_extents(event.name)
if event.annotate_above:
annotate_y = group_to_y(event.group.group_no - 0.5) + font_ascent / 2
marker_y = annotate_y + font_descent
else:
annotate_y = group_to_y(event.group.group_no + 0.5) + font_ascent / 2
marker_y = annotate_y - font_ascent * 1.1
marker_x = event.x
halign = event.annotate_halign
valign = Valign.BASELINE
annotate_x_left = event.x + EVENT_RADIUS - te.width
annotate_x_right = event.x - EVENT_RADIUS
annotate_x = annotate_x_left + (annotate_x_right - annotate_x_left) * (1 - event.annotate_halign)
ctx.set_line_width(DEPENDENCY_THICKNESS)
ctx.move_to(event.x, event.y)
ctx.line_to(marker_x, marker_y)
ctx.stroke()
ctx.set_source_rgb(0, 0, 0)
draw_text(ctx, annotate_x, annotate_y, event.name, halign=Halign.LEFT, valign=valign)
ctx.restore()
draw_action_symbol(ctx, event.x, event.y, event.action_type)
def draw_action_symbol(ctx, x, y, action_type):
ctx.save()
if action_type == ActionType.SELF:
ctx.move_to(x, y - EVENT_RADIUS * math.sqrt(2))
ctx.line_to(x - EVENT_RADIUS * math.sqrt(2), y)
ctx.line_to(x, y + EVENT_RADIUS * math.sqrt(2))
ctx.line_to(x + EVENT_RADIUS * math.sqrt(2), y)
ctx.fill()
elif action_type == ActionType.SCHEDULE:
ctx.move_to(x - DEPENDENCY_THICKNESS / 2, y - EVENT_RADIUS * math.sqrt(2))
ctx.line_to(x - DEPENDENCY_THICKNESS / 2, y + EVENT_RADIUS * math.sqrt(2))
ctx.line_to(x + EVENT_RADIUS * math.sqrt(2), y)
ctx.fill()
elif action_type == ActionType.MAIL:
ctx.arc(x, y, EVENT_RADIUS, 0, 2 * math.pi)
ctx.fill()
elif action_type == ActionType.CHANGE_EFFECTED:
ctx.rectangle(x - EVENT_RADIUS, y - EVENT_RADIUS, 2 * EVENT_RADIUS, 2 * EVENT_RADIUS)
ctx.fill()
elif action_type == ActionType.REMOTE:
ctx.move_to(x, y - EVENT_RADIUS * math.sqrt(2))
ctx.line_to(x - EVENT_RADIUS * math.sqrt(2) / 2, y)
ctx.line_to(x, y + EVENT_RADIUS * math.sqrt(2))
ctx.line_to(x + EVENT_RADIUS * math.sqrt(2) / 2, y)
ctx.fill()
else:
assert(False)
ctx.restore()
def draw_text(ctx, x, y, text, halign=Halign.LEFT, valign=Valign.BASELINE):
te = ctx.text_extents(text)
if halign == Halign.LEFT:
origin_x = x - te.x_bearing
elif halign == Halign.CENTER:
origin_x = x - te.width / 2 - te.x_bearing
elif halign == Halign.RIGHT:
origin_x = x - te.width - te.x_bearing
else:
assert(False)
if valign == Valign.TOP:
origin_y = y - te.y_bearing
elif valign == Valign.MIDDLE:
origin_y = y - te.y_bearing - te.height / 2
elif valign == Valign.BASELINE:
origin_y = y
elif valign == Valign.BOTTOM:
origin_y = y - te.y_bearing - te.height
else:
assert(False)
ctx.move_to(origin_x, origin_y)
ctx.show_text(text)
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment