{"spec_id":"alluvial-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nalluvial-basic: Basic Alluvial Diagram\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 83/100 | Updated: 2026-05-09\n\"\"\"\n\nimport os\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\nIMPRINT = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\")\n\nnp.random.seed(42)\n\n# Data: Voter migration between political parties across 4 election cycles\nyears = [\"2012\", \"2016\", \"2020\", \"2024\"]\nparties = [\"Democratic\", \"Republican\", \"Independent\", \"Other\"]\n\n# Map parties to Okabe-Ito colors\nparty_colors = {\n    \"Democratic\": IMPRINT[0],  # #009E73 (brand green)\n    \"Republican\": IMPRINT[1],  # #C475FD (vermillion)\n    \"Independent\": IMPRINT[2],  # #4467A3 (blue)\n    \"Other\": IMPRINT[3],  # #BD8233 (reddish purple)\n}\n\n# Voter counts (millions) at each time point\nvoter_counts = np.array(\n    [\n        [65.9, 65.8, 81.3, 72.0],  # Democratic\n        [60.9, 63.0, 74.2, 77.0],  # Republican\n        [8.5, 7.8, 5.2, 6.5],  # Independent\n        [3.0, 4.5, 2.8, 3.5],  # Other\n    ]\n)\n\n# Flow matrix between consecutive years (transitions between parties)\nflows = [\n    # 2012 -> 2016\n    {\n        (\"Democratic\", \"Democratic\"): 58.0,\n        (\"Democratic\", \"Republican\"): 4.5,\n        (\"Democratic\", \"Independent\"): 2.5,\n        (\"Democratic\", \"Other\"): 0.9,\n        (\"Republican\", \"Republican\"): 55.0,\n        (\"Republican\", \"Democratic\"): 3.0,\n        (\"Republican\", \"Independent\"): 1.5,\n        (\"Republican\", \"Other\"): 1.4,\n        (\"Independent\", \"Democratic\"): 3.2,\n        (\"Independent\", \"Republican\"): 2.8,\n        (\"Independent\", \"Independent\"): 2.0,\n        (\"Independent\", \"Other\"): 0.5,\n        (\"Other\", \"Democratic\"): 1.6,\n        (\"Other\", \"Republican\"): 0.7,\n        (\"Other\", \"Independent\"): 0.3,\n        (\"Other\", \"Other\"): 0.4,\n    },\n    # 2016 -> 2020\n    {\n        (\"Democratic\", \"Democratic\"): 60.0,\n        (\"Democratic\", \"Republican\"): 2.5,\n        (\"Democratic\", \"Independent\"): 2.0,\n        (\"Democratic\", \"Other\"): 1.3,\n        (\"Republican\", \"Republican\"): 58.0,\n        (\"Republican\", \"Democratic\"): 3.5,\n        (\"Republican\", \"Independent\"): 1.0,\n        (\"Republican\", \"Other\"): 0.5,\n        (\"Independent\", \"Democratic\"): 5.5,\n        (\"Independent\", \"Republican\"): 1.5,\n        (\"Independent\", \"Independent\"): 0.5,\n        (\"Independent\", \"Other\"): 0.3,\n        (\"Other\", \"Democratic\"): 2.0,\n        (\"Other\", \"Republican\"): 1.5,\n        (\"Other\", \"Independent\"): 0.5,\n        (\"Other\", \"Other\"): 0.5,\n    },\n    # 2020 -> 2024\n    {\n        (\"Democratic\", \"Democratic\"): 65.0,\n        (\"Democratic\", \"Republican\"): 10.0,\n        (\"Democratic\", \"Independent\"): 4.5,\n        (\"Democratic\", \"Other\"): 1.8,\n        (\"Republican\", \"Republican\"): 62.0,\n        (\"Republican\", \"Democratic\"): 5.5,\n        (\"Republican\", \"Independent\"): 1.2,\n        (\"Republican\", \"Other\"): 0.5,\n        (\"Independent\", \"Democratic\"): 1.0,\n        (\"Independent\", \"Republican\"): 3.0,\n        (\"Independent\", \"Independent\"): 0.7,\n        (\"Independent\", \"Other\"): 0.5,\n        (\"Other\", \"Democratic\"): 0.5,\n        (\"Other\", \"Republican\"): 2.0,\n        (\"Other\", \"Independent\"): 0.1,\n        (\"Other\", \"Other\"): 0.7,\n    },\n]\n\n# Custom style for text elements\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    title_font_size=28,\n    label_font_size=22,\n    major_label_font_size=18,\n    legend_font_size=16,\n)\n\n# Create minimal chart just for title rendering\nchart = pygal.XY(\n    width=4800,\n    height=2700,\n    style=custom_style,\n    title=\"alluvial-basic·pygal·anyplot.ai\",\n    show_legend=False,\n    show_x_guides=False,\n    show_y_guides=False,\n    show_x_labels=False,\n    show_y_labels=False,\n    dots_size=0,\n    stroke=False,\n    range=(0, 100),\n    xrange=(0, 100),\n)\n\n# Add empty data to avoid \"No data\" message\nchart.add(\"\", [(50, 50)])\n\n# Render base SVG\nbase_svg = chart.render().decode(\"utf-8\")\n\n# SVG coordinate mapping\nmargin_left = 600\nmargin_right = 600\nmargin_top = 400\nmargin_bottom = 350\nchart_width = 4800 - margin_left - margin_right\nchart_height = 2700 - margin_top - margin_bottom\n\n# Calculate positions for each time point\nn_years = len(years)\nx_positions = [margin_left + i * chart_width / (n_years - 1) for i in range(n_years)]\nbar_width = 150\ntotal_height = chart_height\n\n# Track node positions\nnode_positions = {}\n\n# Build SVG elements for nodes and flows\nalluvial_svg = '<g id=\"alluvial-diagram\">'\n\n# Calculate node positions and draw bars\nfor year_idx, year in enumerate(years):\n    x = x_positions[year_idx]\n    year_total = voter_counts[:, year_idx].sum()\n\n    y_top = margin_top\n    for party_idx, party in enumerate(parties):\n        height = (voter_counts[party_idx, year_idx] / year_total) * total_height\n        y_bottom = y_top + height\n\n        # Store position for flow drawing\n        node_positions[(year_idx, party)] = (y_top, y_bottom)\n\n        # Draw rectangle for this party at this year\n        alluvial_svg += f'''\n    <rect x=\"{x - bar_width / 2:.0f}\" y=\"{y_top:.0f}\" width=\"{bar_width:.0f}\" height=\"{height:.0f}\"\n          fill=\"{party_colors[party]}\" stroke=\"{PAGE_BG}\" stroke-width=\"3\"/>'''\n\n        y_top = y_bottom\n\n    # Add year label at bottom\n    alluvial_svg += f'''\n    <text x=\"{x:.0f}\" y=\"{margin_top + total_height + 80:.0f}\" text-anchor=\"middle\"\n          font-size=\"52\" font-weight=\"bold\" font-family=\"sans-serif\"\n          fill=\"{INK}\">{year}</text>'''\n\n# Add party labels on left side\nfor party in parties:\n    y_top, y_bottom = node_positions[(0, party)]\n    y_center = (y_top + y_bottom) / 2\n    alluvial_svg += f'''\n    <text x=\"{x_positions[0] - bar_width / 2 - 30:.0f}\" y=\"{y_center:.0f}\" text-anchor=\"end\"\n          font-size=\"44\" font-weight=\"bold\" font-family=\"sans-serif\"\n          fill=\"{party_colors[party]}\" dominant-baseline=\"middle\">{party}</text>'''\n\n# Add party labels on right side\nfor party in parties:\n    y_top, y_bottom = node_positions[(n_years - 1, party)]\n    y_center = (y_top + y_bottom) / 2\n    alluvial_svg += f'''\n    <text x=\"{x_positions[-1] + bar_width / 2 + 30:.0f}\" y=\"{y_center:.0f}\" text-anchor=\"start\"\n          font-size=\"44\" font-weight=\"bold\" font-family=\"sans-serif\"\n          fill=\"{party_colors[party]}\" dominant-baseline=\"middle\">{party}</text>'''\n\n# Draw flows between consecutive time points\nfor flow_idx, flow_dict in enumerate(flows):\n    x0 = x_positions[flow_idx]\n    x1 = x_positions[flow_idx + 1]\n\n    # Calculate totals for normalization\n    year0_total = voter_counts[:, flow_idx].sum()\n    year1_total = voter_counts[:, flow_idx + 1].sum()\n\n    # Track cumulative offsets for each source and target\n    source_offsets = {party: node_positions[(flow_idx, party)][0] for party in parties}\n    target_offsets = {party: node_positions[(flow_idx + 1, party)][0] for party in parties}\n\n    # Draw each flow\n    for (source_party, target_party), flow_value in flow_dict.items():\n        if flow_value <= 0:\n            continue\n\n        # Calculate normalized heights\n        source_height = (flow_value / year0_total) * total_height\n        target_height = (flow_value / year1_total) * total_height\n\n        # Get current positions\n        y0_top = source_offsets[source_party]\n        y0_bottom = y0_top + source_height\n        y1_top = target_offsets[target_party]\n        y1_bottom = y1_top + target_height\n\n        # Bezier curve control points\n        band_x0 = x0 + bar_width / 2\n        band_x1 = x1 - bar_width / 2\n        cx0 = band_x0 + 0.4 * (band_x1 - band_x0)\n        cx1 = band_x0 + 0.6 * (band_x1 - band_x0)\n\n        # Create path for the curved band\n        path_d = (\n            f\"M {band_x0:.0f},{y0_top:.0f} \"\n            f\"C {cx0:.0f},{y0_top:.0f} {cx1:.0f},{y1_top:.0f} {band_x1:.0f},{y1_top:.0f} \"\n            f\"L {band_x1:.0f},{y1_bottom:.0f} \"\n            f\"C {cx1:.0f},{y1_bottom:.0f} {cx0:.0f},{y0_bottom:.0f} {band_x0:.0f},{y0_bottom:.0f} \"\n            f\"Z\"\n        )\n\n        alluvial_svg += f'''\n    <path d=\"{path_d}\" fill=\"{party_colors[source_party]}\" fill-opacity=\"0.35\" stroke=\"none\"/>'''\n\n        # Update offsets\n        source_offsets[source_party] = y0_bottom\n        target_offsets[target_party] = y1_bottom\n\n# Add subtitle explaining the visualization\nsubtitle_y = margin_top + total_height + 150\nalluvial_svg += f'''\n    <text x=\"2400\" y=\"{subtitle_y:.0f}\" text-anchor=\"middle\"\n          font-size=\"42\" font-style=\"italic\" font-family=\"sans-serif\"\n          fill=\"{INK_SOFT}\">Voter Migration Between Political Parties (Millions of Voters)</text>'''\n\nalluvial_svg += \"\\n</g>\"\n\n# Insert alluvial elements before closing </svg> tag\nsvg_with_alluvial = base_svg.replace(\"</svg>\", f\"{alluvial_svg}\\n</svg>\")\n\n# Save outputs\ncairosvg.svg2png(bytestring=svg_with_alluvial.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\")\n\nwith open(f\"plot-{THEME}.html\", \"w\") as f:\n    f.write(\n        \"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <title>alluvial-basic·pygal·anyplot.ai</title>\n    <style>\n        body { margin: 0; padding: 20px; background: \"\"\"\n        + (\"#f5f5f5\" if THEME == \"light\" else \"#2a2a2a\")\n        + \"\"\"; font-family: sans-serif; }\n        .container { max-width: 100%; margin: 0 auto; }\n        object { width: 100%; height: auto; }\n    </style>\n</head>\n<body>\n    <div class=\"container\">\n        <object type=\"image/svg+xml\" data=\"plot-\"\"\"\n        + THEME\n        + \"\"\".svg\">\n            Alluvial diagram not supported\n        </object>\n    </div>\n</body>\n</html>\"\"\"\n    )\n"}