Created
April 7, 2025 15:32
-
-
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.
This file contains hidden or 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/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