# timeline_visualizer.py
# Requires: matplotlib, numpy
# Usage: create instance, add people/events, then render()
import math
import datetime
from typing import List, Dict, Any, Tuple, Optional
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
from matplotlib.patches import FancyBboxPatch, Rectangle
import numpy as np
class TimelineVisualizer:
"""
Simple, reusable visualizer that draws horizontal age-based timelines
for multiple people (parallel bars). X axis = AGE (not calendar year).
"""
def __init__(self,
people: List[Dict[str, Any]],
age_max: int = 100,
size: Tuple[int, int] = (1920, 1080),
dpi: int = 100,
font_family: str = "DejaVu Sans",
pastel_palette: Optional[List[str]] = None):
"""
people: list of dicts, each with keys:
- 'name': str
- 'birth_year': int
- 'events': list of dicts with either:
Point event: {'year': int, 'label': str, 'color': Optional[str], 'position': Optional[str]}
position: 'close_to_bar', 'away_from_bar', or 'auto' (default)
Duration event: {'start_year': int, 'end_year': int, 'label': str}
age_max: maximum age shown on X axis
size: (width_px, height_px)
"""
self.people = people
self.age_max = age_max
self.size = size
self.dpi = dpi
self.font_family = font_family
# Modern, vibrant social media color palette
self.pastel_palette = pastel_palette or ["#FF6B6B", "#4ECDC4", "#45B7D1", "#FFA07A"]
plt.rcParams['font.family'] = self.font_family
def year_to_age(self, birth_year: int, year: int) -> float:
return year - birth_year
def render(self,
filename: str = "parallel_timelines.png",
show_grid_every: int = 10,
bar_height: float = 0.6):
# Figure sizing: Matplotlib expects inches
fig_w = self.size[0] / self.dpi
fig_h = self.size[1] / self.dpi
fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=self.dpi)
# Y positions for parallel bars
n = len(self.people)
if n == 0:
raise ValueError("No people provided to visualize.")
ys = np.linspace(n - 1, 0, n) # top-to-bottom order
# Draw a modern infographic-style background
fig.patch.set_facecolor("#F8F9FA")
ax.set_facecolor("#F8F9FA")
# Draw dashed vertical separators (every show_grid_every years)
for age in range(0, self.age_max + 1, show_grid_every):
ax.vlines(age, -1, n, linestyles='dashed', linewidth=0.8, alpha=0.3, color='#666666')
# Draw horizontal bars (life spans) and events
for i, person in enumerate(self.people):
y = ys[i]
name = person.get('name', f"Person {i+1}")
birth = int(person['birth_year'])
# Determine max age for this person (special handling for 1985)
person_age_max = 40 if birth == 1985 else self.age_max
# draw full-age bar (0 .. age_max) with stroke and rounded corners
bar_color = self.pastel_palette[i % len(self.pastel_palette)]
# Draw bar with rounded corners
bar = FancyBboxPatch((0, y - bar_height/2), person_age_max, bar_height,
boxstyle="round,pad=0.02",
edgecolor='none', # We'll draw borders manually
linewidth=0,
facecolor=bar_color,
alpha=0.85,
zorder=2)
ax.add_patch(bar)
# Manually draw borders with precise positioning
border_width = 2.5
border_color = '#2C3E50'
border_offset = border_width / 2000 # Slight inset to align with bar edge
# Special fade effect for 1985
if birth == 1985:
# Add gradient fade from bar color to white background
fade_width = 1.5 # Fade over about 1.5 age units
num_steps = 30
background_color = mcolors.to_rgb('#F8F9FA')
bar_rgb = mcolors.to_rgb(bar_color)
for step in range(num_steps):
x_start = person_age_max + (step * fade_width / num_steps)
x_width = fade_width / num_steps
# Interpolate between bar color and white background
ratio = step / num_steps
r = bar_rgb[0] * (1 - ratio) + background_color[0] * ratio
g = bar_rgb[1] * (1 - ratio) + background_color[1] * ratio
b = bar_rgb[2] * (1 - ratio) + background_color[2] * ratio
fade_color = (r, g, b)
fade_rect = Rectangle((x_start, y - bar_height/2), x_width, bar_height,
facecolor=fade_color, edgecolor='none', alpha=0.85, zorder=2.1)
ax.add_patch(fade_rect)
# Fading top border
for step in range(num_steps):
x_start = person_age_max + (step * fade_width / num_steps)
x_end = person_age_max + ((step + 1) * fade_width / num_steps)
ratio = step / num_steps
alpha_val = 1.0 - ratio
ax.plot([x_start, x_end], [y + bar_height/2 - border_offset, y + bar_height/2 - border_offset],
linewidth=border_width, color=border_color, alpha=alpha_val,
solid_capstyle='butt', zorder=3)
# Fading bottom border
for step in range(num_steps):
x_start = person_age_max + (step * fade_width / num_steps)
x_end = person_age_max + ((step + 1) * fade_width / num_steps)
ratio = step / num_steps
alpha_val = 1.0 - ratio
ax.plot([x_start, x_end], [y - bar_height/2 + border_offset, y - bar_height/2 + border_offset],
linewidth=border_width, color=border_color, alpha=alpha_val,
solid_capstyle='butt', zorder=3)
# Top border (up to person_age_max)
ax.plot([0, person_age_max], [y + bar_height/2 - border_offset, y + bar_height/2 - border_offset],
linewidth=border_width, color=border_color, solid_capstyle='round', zorder=3)
# Bottom border (up to person_age_max)
ax.plot([0, person_age_max], [y - bar_height/2 + border_offset, y - bar_height/2 + border_offset],
linewidth=border_width, color=border_color, solid_capstyle='round', zorder=3)
# Left border
ax.plot([0, 0], [y - bar_height/2 + border_offset, y + bar_height/2 - border_offset],
linewidth=border_width, color=border_color, solid_capstyle='round', zorder=3)
# Right border - only for 1885, not for 1985 (ongoing)
if birth != 1985:
ax.plot([person_age_max, person_age_max], [y - bar_height/2 + border_offset, y + bar_height/2 - border_offset],
linewidth=border_width, color=border_color, solid_capstyle='round', zorder=3)
# person label positioning: top for 1985 (to avoid dot overlap), bottom for 1885
if birth == 1985:
# Upper left corner to avoid overlapping with dots at bottom
ax.text(1, y + bar_height/2 - 0.05, f"{name}",
ha='left', va='top', fontsize=16, weight='bold', color='white', zorder=3,
bbox=dict(boxstyle="round,pad=0.3", facecolor='#2C3E50', alpha=0.8, edgecolor='none'))
else:
# Bottom left corner (default)
ax.text(1, y - bar_height/2 + 0.05, f"{name}",
ha='left', va='bottom', fontsize=16, weight='bold', color='white', zorder=3,
bbox=dict(boxstyle="round,pad=0.3", facecolor='#2C3E50', alpha=0.8, edgecolor='none'))
# events: annotate on the bar
# Divide bar height into thirds for vertical positioning
bar_third = bar_height / 3
events = person.get('events', [])
for ev_idx, ev in enumerate(events):
label = ev.get('label', '')
# Check if this is a duration event (has start_year and end_year)
is_duration = 'start_year' in ev and 'end_year' in ev
if is_duration:
# Duration event (e.g., wars, political eras)
start_year = int(ev['start_year'])
end_year = int(ev['end_year'])
start_age = self.year_to_age(birth, start_year)
end_age = self.year_to_age(birth, end_year)
# skip if completely outside the plotted range
if end_age < 0 or start_age > person_age_max:
continue
# Clamp to visible range
start_age = max(0, start_age)
end_age = min(person_age_max, end_age)
mid_age = (start_age + end_age) / 2
# Position closer to vertical center of the chart
# Move toward the center between the two bars
if birth == 1885:
y_duration = y - bar_third * 0.5 # Move down slightly
else:
y_duration = y + bar_third * 0.5 # Move up slightly
# Draw horizontal line connecting the markers
ax.plot([start_age, end_age], [y_duration, y_duration], linewidth=2.5,
color='#2C3E50', alpha=0.8, solid_capstyle='butt', zorder=5)
# Draw semi-circle markers at start and end (like split dots)
# Left semi-circle (start)
ax.plot(start_age, y_duration, marker='o', markersize=12,
markeredgecolor='white', markerfacecolor='#2C3E50',
markeredgewidth=2, zorder=6)
# Right semi-circle (end)
ax.plot(end_age, y_duration, marker='o', markersize=12,
markeredgecolor='white', markerfacecolor='#2C3E50',
markeredgewidth=2, zorder=6)
# Label above the line (event name)
ax.text(mid_age, y_duration + 0.08, label,
ha='center',
va='bottom',
fontsize=12,
weight='600',
color='#2C3E50',
zorder=7)
# Years below the line
ax.text(mid_age, y_duration - 0.08, f"{start_year}–{end_year}",
ha='center',
va='top',
fontsize=11,
weight='500',
color='#2C3E50',
zorder=7)
else:
# Regular point event
year = int(ev['year'])
age = self.year_to_age(birth, year)
# skip if outside the plotted range
if age < 0 or age > person_age_max:
continue
# Position to the outside: top third for 1885, bottom third for 1985
if birth == 1885:
y_point = y + bar_third # Move towards top
else:
y_point = y - bar_third # Move towards bottom
# marker with uniform color for clean look
ax.plot(age, y_point, marker='o', markersize=12, markeredgecolor='white',
markerfacecolor='#2C3E50', markeredgewidth=2, zorder=5)
# smart annotation placement: above for 1885 (top), below for 1985 (bottom)
# Position controlled by "position" field: "close_to_bar" or "away_from_bar"
# Increased offsets for 3-row labels
position = ev.get('position', 'auto')
if birth == 1885:
# Labels on top
base_offset = 0.6 # More space from bar
extra_offset = 0.4
if position == 'close_to_bar':
vert_offset = base_offset
elif position == 'away_from_bar':
vert_offset = base_offset + extra_offset
else:
# Auto: alternate based on index (fallback)
vert_offset = base_offset + (extra_offset if ev_idx % 2 == 1 else 0)
else:
# Labels on bottom (1985)
base_offset = -0.7
extra_offset = -0.4
if position == 'close_to_bar':
vert_offset = base_offset
elif position == 'away_from_bar':
vert_offset = base_offset + extra_offset
else:
# Auto: alternate based on index (fallback)
vert_offset = base_offset + (extra_offset if ev_idx % 2 == 1 else 0)
ax.annotate(f"{label}\n{year}\n{age} éves",
xy=(age, y_point),
xytext=(age, y_point + vert_offset),
ha='center',
va='center',
fontsize=13,
weight='500',
textcoords='data',
arrowprops=dict(arrowstyle="-", linewidth=1.2, alpha=0.7, color='#2C3E50'),
bbox=dict(boxstyle="round,pad=0.4", fc="white", ec="#2C3E50", linewidth=1.5, alpha=0.95),
zorder=6)
# Labels, limits, aesthetics - adjusted for better label visibility
ax.set_xlim(-5, self.age_max + 5)
ax.set_ylim(-2, n + 0.3) # Extra space at bottom and top for labels
ax.set_yticks([]) # we use inline labels at left instead
# X-axis ticks for age markers
ax.set_xticks(range(0, self.age_max + 1, show_grid_every))
ax.tick_params(axis='x', labelsize=12, colors='#2C3E50')
ax.set_xlabel("Életkor (év)", fontsize=16, weight='bold', color='#2C3E50', labelpad=10)
ax.set_title("Néhány meghatározó felfedezés és esemény két, 100 év különbséggel született ember életében", fontsize=22, weight='bold', pad=20, color='#2C3E50')
# Tidy up spines
for spine in ['top', 'right', 'left']:
ax.spines[spine].set_visible(False)
ax.spines['bottom'].set_linewidth(1.5)
ax.spines['bottom'].set_color('#2C3E50')
plt.tight_layout()
plt.savefig(filename, dpi=self.dpi, bbox_inches='tight', facecolor=fig.get_facecolor())
plt.close(fig)
print(f"Saved timeline to: {filename}")
if __name__ == "__main__":
# Example usage with the two people you described (corrected ages)
people = [
{
"name": "Születési év: 1885",
"birth_year": 1885,
"events": [
{"year": 1895, "label" : "Némafilm", "position" : "close_to_bar"},
{"year": 1910, "label": "Ford T-modell", "color": "#6FBF73", "position" : "close_to_bar"},
{"year": 1928, "label": "Penicillin", "color": "#6FBF73", "position" : "away_from_bar"},
{"start_year": 1914, "end_year": 1918, "label": "1. világháború"},
{"year": 1925, "label": "Magyar Rádió", "color": "#F2C57C", "position" : "close_to_bar"},
{"start_year": 1939, "end_year": 1945, "label": "2. világháború"},
{"year": 1947, "label" : "Tranzisztor", "color": "#6FBF73", "position" : "close_to_bar"},
{"year": 1957, "label": "Magyar Televízió", "color": "#A3C4F3", "position" : "close_to_bar"},
{"year": 1964, "label": "Audiokazetta", "color": "#A3C4F3", "position" : "away_from_bar"},
{"year": 1976, "label": "VHS", "color": "#6FBF73", "position" : "close_to_bar"},
{"year": 1982, "label": "Commodore 64", "color": "#A18CD1", "position" : "away_from_bar"},
]
},
{
"name": "Születési év: 1985",
"birth_year": 1985,
"events": [
{"year": 1986, "label": "Csernobil", "color": "#6FBF73", "position" : "close_to_bar"},
{"year": 1993, "label": "Megalakul az EU", "color": "#6FBF73", "position" : "away_from_bar"},
{"year": 1995, "label": "World Wide Web", "color": "#6FBF73", "position" : "close_to_bar"},
{"year": 2001, "label": "9/11", "color": "#E07A5F", "position" : "away_from_bar"},
{"year": 2008, "label": "Android 1.0", "color": "#F2C57C", "position" : "away_from_bar"},
{"year": 2004, "label": "Magyarország EU tag", "color": "#E07A5F", "position" : "close_to_bar"},
{"start_year": 2010, "end_year": 2026, "label": "NER"},
{"year": 2016, "label": "Brexit", "color": "#E07A5F", "position" : "close_to_bar"},
{"year": 2020, "label": "COVID-19", "color": "#A18CD1", "position" : "away_from_bar"},
{"year": 2023, "label": "ChatGPT", "color": "#A18CD1", "position" : "close_to_bar"},
]
}
]
tv = TimelineVisualizer(people=people, age_max=100, size=(1920, 1080), dpi=120)
tv.render("parallel_age_timelines.png")