{"spec_id":"alluvial-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nalluvial-basic: Basic Alluvial Diagram\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 96/100 | Updated: 2026-05-09\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import Label, Legend, LegendItem\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\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\"\n\nBRAND = \"#009E73\"\nOI_2 = \"#C475FD\"\nOI_3 = \"#4467A3\"\nOI_4 = \"#BD8233\"\n\nnp.random.seed(42)\n\ntime_points = [\"2012\", \"2016\", \"2020\", \"2024\"]\ncategories = [\"Democratic\", \"Republican\", \"Independent\", \"Other\"]\ncolors = {\"Democratic\": BRAND, \"Republican\": OI_2, \"Independent\": OI_3, \"Other\": OI_4}\n\nflows_data = [\n    [\n        (\"Democratic\", \"Democratic\", 35),\n        (\"Democratic\", \"Independent\", 5),\n        (\"Democratic\", \"Republican\", 2),\n        (\"Republican\", \"Republican\", 30),\n        (\"Republican\", \"Independent\", 4),\n        (\"Republican\", \"Democratic\", 3),\n        (\"Independent\", \"Democratic\", 4),\n        (\"Independent\", \"Republican\", 3),\n        (\"Independent\", \"Independent\", 8),\n        (\"Other\", \"Other\", 3),\n        (\"Other\", \"Independent\", 2),\n        (\"Other\", \"Democratic\", 1),\n    ],\n    [\n        (\"Democratic\", \"Democratic\", 38),\n        (\"Democratic\", \"Independent\", 3),\n        (\"Democratic\", \"Republican\", 2),\n        (\"Republican\", \"Republican\", 32),\n        (\"Republican\", \"Independent\", 3),\n        (\"Republican\", \"Democratic\", 2),\n        (\"Independent\", \"Democratic\", 5),\n        (\"Independent\", \"Republican\", 4),\n        (\"Independent\", \"Independent\", 8),\n        (\"Other\", \"Other\", 2),\n        (\"Other\", \"Independent\", 2),\n        (\"Other\", \"Republican\", 1),\n    ],\n    [\n        (\"Democratic\", \"Democratic\", 40),\n        (\"Democratic\", \"Independent\", 4),\n        (\"Democratic\", \"Republican\", 1),\n        (\"Republican\", \"Republican\", 34),\n        (\"Republican\", \"Independent\", 2),\n        (\"Republican\", \"Democratic\", 3),\n        (\"Independent\", \"Democratic\", 4),\n        (\"Independent\", \"Republican\", 5),\n        (\"Independent\", \"Independent\", 6),\n        (\"Other\", \"Other\", 2),\n        (\"Other\", \"Democratic\", 1),\n        (\"Other\", \"Independent\", 1),\n    ],\n]\n\nnode_heights = []\nfor t_idx, _t in enumerate(time_points):\n    heights = {}\n    if t_idx == 0:\n        for cat in categories:\n            heights[cat] = sum(f[2] for f in flows_data[0] if f[0] == cat)\n    elif t_idx == len(time_points) - 1:\n        for cat in categories:\n            heights[cat] = sum(f[2] for f in flows_data[-1] if f[1] == cat)\n    else:\n        for cat in categories:\n            heights[cat] = sum(f[2] for f in flows_data[t_idx - 1] if f[1] == cat)\n    node_heights.append(heights)\n\nx_positions = [0, 1, 2, 3]\nnode_width = 0.12\ngap = 2\n\nnode_positions = []\nfor t_idx in range(len(time_points)):\n    positions = {}\n    y_cursor = 0\n    for cat in categories:\n        height = node_heights[t_idx][cat]\n        positions[cat] = {\"y_start\": y_cursor, \"y_end\": y_cursor + height}\n        y_cursor += height + gap\n    node_positions.append(positions)\n\np = figure(\n    width=4800,\n    height=2700,\n    title=\"alluvial-basic · bokeh · anyplot.ai\",\n    x_range=(-0.9, 4.3),\n    y_range=(-8, max(sum(node_heights[0].values()) + gap * len(categories), 120)),\n    tools=\"\",\n    toolbar_location=None,\n)\n\np.title.text_font_size = \"28pt\"\np.title.text_color = INK\np.title.align = \"center\"\np.xgrid.visible = False\np.ygrid.visible = False\np.xaxis.visible = False\np.yaxis.visible = False\np.outline_line_color = None\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\n\nsubtitle = Label(\n    x=1.5,\n    y=115,\n    text=\"Voter Migration Between Parties (values in millions)\",\n    text_font_size=\"22pt\",\n    text_align=\"center\",\n    text_baseline=\"top\",\n    text_color=INK_SOFT,\n)\np.add_layout(subtitle)\n\nn_points = 50\nt_param = np.linspace(0, 1, n_points)\n\nfor t_idx, flows in enumerate(flows_data):\n    x_start = x_positions[t_idx] + node_width / 2\n    x_end = x_positions[t_idx + 1] - node_width / 2\n\n    source_cursors = {cat: node_positions[t_idx][cat][\"y_start\"] for cat in categories}\n    target_cursors = {cat: node_positions[t_idx + 1][cat][\"y_start\"] for cat in categories}\n\n    for from_cat, to_cat, value in flows:\n        if value == 0:\n            continue\n\n        y_src_bottom = source_cursors[from_cat]\n        y_src_top = y_src_bottom + value\n        source_cursors[from_cat] = y_src_top\n\n        y_tgt_bottom = target_cursors[to_cat]\n        y_tgt_top = y_tgt_bottom + value\n        target_cursors[to_cat] = y_tgt_top\n\n        cx0 = x_start + (x_end - x_start) / 3\n        cx1 = x_start + 2 * (x_end - x_start) / 3\n\n        x_top = (\n            (1 - t_param) ** 3 * x_start\n            + 3 * (1 - t_param) ** 2 * t_param * cx0\n            + 3 * (1 - t_param) * t_param**2 * cx1\n            + t_param**3 * x_end\n        )\n        y_top = (\n            (1 - t_param) ** 3 * y_src_top\n            + 3 * (1 - t_param) ** 2 * t_param * y_src_top\n            + 3 * (1 - t_param) * t_param**2 * y_tgt_top\n            + t_param**3 * y_tgt_top\n        )\n\n        x_bottom = (\n            (1 - t_param) ** 3 * x_start\n            + 3 * (1 - t_param) ** 2 * t_param * cx0\n            + 3 * (1 - t_param) * t_param**2 * cx1\n            + t_param**3 * x_end\n        )\n        y_bottom = (\n            (1 - t_param) ** 3 * y_src_bottom\n            + 3 * (1 - t_param) ** 2 * t_param * y_src_bottom\n            + 3 * (1 - t_param) * t_param**2 * y_tgt_bottom\n            + t_param**3 * y_tgt_bottom\n        )\n\n        xs = list(x_top) + list(x_bottom[::-1])\n        ys = list(y_top) + list(y_bottom[::-1])\n\n        color = colors[from_cat]\n        p.patch(xs, ys, fill_color=color, fill_alpha=0.5, line_color=color, line_alpha=0.7, line_width=1)\n\nlegend_renderers = {}\nfor t_idx, _t in enumerate(time_points):\n    x = x_positions[t_idx]\n    for cat in categories:\n        y_start = node_positions[t_idx][cat][\"y_start\"]\n        y_end = node_positions[t_idx][cat][\"y_end\"]\n        height = y_end - y_start\n\n        if height > 0:\n            renderer = p.quad(\n                left=x - node_width / 2,\n                right=x + node_width / 2,\n                top=y_end,\n                bottom=y_start,\n                fill_color=colors[cat],\n                line_color=PAGE_BG,\n                line_width=2,\n            )\n\n            if cat not in legend_renderers:\n                legend_renderers[cat] = renderer\n\n            if t_idx == 0:\n                label = Label(\n                    x=x - node_width / 2 - 0.03,\n                    y=(y_start + y_end) / 2,\n                    text=f\"{cat} ({int(height)}M)\",\n                    text_font_size=\"22pt\",\n                    text_baseline=\"middle\",\n                    text_align=\"right\",\n                    text_color=INK_SOFT,\n                )\n                p.add_layout(label)\n            elif t_idx == len(time_points) - 1:\n                label = Label(\n                    x=x + node_width / 2 + 0.03,\n                    y=(y_start + y_end) / 2,\n                    text=f\"{cat} ({int(height)}M)\",\n                    text_font_size=\"22pt\",\n                    text_baseline=\"middle\",\n                    text_color=INK_SOFT,\n                )\n                p.add_layout(label)\n\nlegend_items = [LegendItem(label=cat, renderers=[legend_renderers[cat]]) for cat in categories]\nlegend = Legend(\n    items=legend_items,\n    location=\"top_right\",\n    label_text_font_size=\"18pt\",\n    label_text_color=INK_SOFT,\n    glyph_width=30,\n    glyph_height=30,\n    spacing=10,\n    padding=15,\n    background_fill_alpha=0.9,\n    background_fill_color=ELEVATED_BG,\n    border_line_color=INK_SOFT,\n)\np.add_layout(legend, \"right\")\n\nfor t_idx, t in enumerate(time_points):\n    label = Label(\n        x=x_positions[t_idx],\n        y=-4,\n        text=t,\n        text_font_size=\"24pt\",\n        text_align=\"center\",\n        text_baseline=\"top\",\n        text_color=INK,\n        text_font_style=\"bold\",\n    )\n    p.add_layout(label)\n\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\nW, H = 4800, 2700\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}