import graphviz import json from tempfile import NamedTemporaryFile import os def generate_timeline_diagram(json_input: str, output_format: str) -> str: """ Generates a serpentine timeline diagram from JSON input. Args: json_input (str): A JSON string describing the timeline structure. It must follow the Expected JSON Format Example below. Expected JSON Format Example: { "title": "AI Development Timeline", "events_per_row": 4, "events": [ { "id": "event_1", "label": "Machine Learning Foundations", "date": "1950-1960", "description": "Early neural networks and perceptrons" }, { "id": "event_2", "label": "Expert Systems Era", "date": "1970-1980", "description": "Rule-based AI systems" }, { "id": "event_3", "label": "Neural Network Revival", "date": "1980-1990", "description": "Backpropagation algorithm" } ] } Returns: str: The filepath to the generated PNG image file. """ try: if not json_input.strip(): return "Error: Empty input" data = json.loads(json_input) if 'events' not in data: raise ValueError("Missing required field: events") dot = graphviz.Digraph( name='Timeline', format='png', graph_attr={ 'rankdir': 'TB', # Top-to-Bottom for better control 'splines': 'ortho', # Straight lines with 90-degree bends 'bgcolor': 'white', # White background 'pad': '0.5', # Padding around the graph 'nodesep': '1.5', # Spacing between nodes 'ranksep': '1.5' # Spacing between ranks } ) base_color = '#19191a' # Hardcoded base color title = data.get('title', '') events = data.get('events', []) events_per_row = data.get('events_per_row', 4) # Default to 4 events per row if not events: raise ValueError("Timeline must contain at least one event") # Add title node if provided if title: dot.node( 'title', title, shape='plaintext', fontsize='18', fontweight='bold', fontcolor=base_color ) # Calculate positions and create serpentine layout total_events = len(events) previous_event_id = None # Create invisible nodes for positioning and rank control rows = [] current_row = [] # Group events into rows for i, event in enumerate(events): current_row.append(event) if len(current_row) == events_per_row or i == total_events - 1: rows.append(current_row) current_row = [] # Process each row and create serpentine connections for row_idx, row in enumerate(rows): # Determine if row should be reversed (serpentine pattern) is_reversed = row_idx % 2 == 1 if is_reversed: row = row[::-1] # Reverse the row for serpentine effect # Create invisible nodes for row positioning row_nodes = [] for event_idx, event in enumerate(row): original_idx = events.index(event) event_id = event.get('id', f'event_{original_idx}') event_label = event.get('label', f'Event {original_idx+1}') event_date = event.get('date', '') event_description = event.get('description', '') # Create full label with date and description if event_date and event_description: full_label = f"{event_date}\\n{event_label}\\n{event_description}" elif event_date: full_label = f"{event_date}\\n{event_label}" elif event_description: full_label = f"{event_label}\\n{event_description}" else: full_label = event_label # Calculate color opacity based on original position in timeline if total_events == 1: opacity = 'FF' else: opacity_value = int(255 * (1.0 - (original_idx * 0.7 / (total_events - 1)))) opacity = format(opacity_value, '02x') node_color = f"{base_color}{opacity}" font_color = 'white' if original_idx < total_events * 0.7 else 'black' # Add the event node dot.node( event_id, full_label, shape='box', style='filled,rounded', fillcolor=node_color, fontcolor=font_color, fontsize='12', width='2.5', height='1.2' ) row_nodes.append(event_id) # Create horizontal connections within the row for i in range(len(row_nodes) - 1): dot.edge( row_nodes[i], row_nodes[i + 1], color='#666666', arrowsize='0.8', penwidth='2' ) # Connect to previous row (serpentine connection) if row_idx > 0: # Connect last node of previous row to first node of current row prev_row_nodes = getattr(generate_timeline_diagram, 'prev_row_nodes', []) if prev_row_nodes: # Connect the end of previous row to start of current row if (row_idx - 1) % 2 == 0: # Previous row was left-to-right connection_start = prev_row_nodes[-1] # Last node of previous row else: # Previous row was right-to-left connection_start = prev_row_nodes[0] # First node of previous row (which was last visually) if row_idx % 2 == 0: # Current row is left-to-right connection_end = row_nodes[0] # First node of current row else: # Current row is right-to-left connection_end = row_nodes[-1] # Last node of current row (which will be first visually) dot.edge( connection_start, connection_end, color='#666666', arrowsize='0.8', penwidth='2' ) # Store current row nodes for next iteration generate_timeline_diagram.prev_row_nodes = row_nodes # Connect title to first event if title exists and this is the first row if title and row_idx == 0: first_event = row_nodes[0] if row_idx % 2 == 0 else row_nodes[-1] dot.edge('title', first_event, style='invis') # Clean up the stored attribute if hasattr(generate_timeline_diagram, 'prev_row_nodes'): delattr(generate_timeline_diagram, 'prev_row_nodes') with NamedTemporaryFile(delete=False, suffix=f'.{output_format}') as tmp: dot.render(tmp.name, format=output_format, cleanup=True) return f"{tmp.name}.{output_format}" except json.JSONDecodeError: return "Error: Invalid JSON format" except Exception as e: return f"Error: {str(e)}"