CCCPaste

a627fcd794bda

# 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")