{"spec_id":"alluvial-opinion-flow","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nalluvial-opinion-flow: Opinion Flow Diagram\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-05-30\n\"\"\"\n\nimport io\nimport os\nimport sys\n\n\n# Prevent self-import: this file is named bokeh.py, so Python's path search would\n# find it before the installed bokeh package. Remove the script's own directory\n# from sys.path so imports resolve to the installed package.\n_own_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p) != _own_dir]\n\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool, Label, Legend, LegendItem, TapTool\nfrom bokeh.plotting import figure\nfrom PIL import Image\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Imprint palette theme-adaptive chrome\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\n# Data: Remote work policy opinion survey — 1,000 employees across 4 quarterly waves\n# Story: Opinions gradually polarize as the debate matures\nwaves = [\"Q1 2024\", \"Q2 2024\", \"Q3 2024\", \"Q4 2024\"]\nopinions = [\"Strongly Agree\", \"Agree\", \"Neutral\", \"Disagree\", \"Strongly Disagree\"]\n\n# Imprint palette with semantic exception: sentiment scale (positive→green, negative→red)\ncolors = {\n    \"Strongly Agree\": \"#009E73\",  # Imprint brand green — positive\n    \"Agree\": \"#99B314\",  # Imprint lime — lighter positive\n    \"Neutral\": INK_MUTED,  # Imprint muted — neutral (theme-adaptive)\n    \"Disagree\": \"#BD8233\",  # Imprint ochre — cautionary\n    \"Strongly Disagree\": \"#AE3030\",  # Imprint matte red — negative\n}\n\n# Flow transitions between consecutive waves (source, target, respondent_count)\nflows_data = [\n    # Q1 → Q2\n    [\n        (\"Strongly Agree\", \"Strongly Agree\", 105),\n        (\"Strongly Agree\", \"Agree\", 15),\n        (\"Agree\", \"Strongly Agree\", 25),\n        (\"Agree\", \"Agree\", 230),\n        (\"Agree\", \"Neutral\", 20),\n        (\"Agree\", \"Disagree\", 5),\n        (\"Neutral\", \"Agree\", 35),\n        (\"Neutral\", \"Neutral\", 190),\n        (\"Neutral\", \"Disagree\", 20),\n        (\"Neutral\", \"Strongly Disagree\", 5),\n        (\"Disagree\", \"Agree\", 5),\n        (\"Disagree\", \"Neutral\", 30),\n        (\"Disagree\", \"Disagree\", 175),\n        (\"Disagree\", \"Strongly Disagree\", 20),\n        (\"Strongly Disagree\", \"Neutral\", 10),\n        (\"Strongly Disagree\", \"Disagree\", 15),\n        (\"Strongly Disagree\", \"Strongly Disagree\", 95),\n    ],\n    # Q2 → Q3 (polarization intensifies)\n    [\n        (\"Strongly Agree\", \"Strongly Agree\", 120),\n        (\"Strongly Agree\", \"Agree\", 10),\n        (\"Agree\", \"Strongly Agree\", 40),\n        (\"Agree\", \"Agree\", 215),\n        (\"Agree\", \"Neutral\", 25),\n        (\"Agree\", \"Disagree\", 5),\n        (\"Neutral\", \"Agree\", 30),\n        (\"Neutral\", \"Neutral\", 180),\n        (\"Neutral\", \"Disagree\", 30),\n        (\"Neutral\", \"Strongly Disagree\", 10),\n        (\"Disagree\", \"Agree\", 5),\n        (\"Disagree\", \"Neutral\", 25),\n        (\"Disagree\", \"Disagree\", 160),\n        (\"Disagree\", \"Strongly Disagree\", 25),\n        (\"Strongly Disagree\", \"Neutral\", 10),\n        (\"Strongly Disagree\", \"Disagree\", 10),\n        (\"Strongly Disagree\", \"Strongly Disagree\", 100),\n    ],\n    # Q3 → Q4 (further polarization)\n    [\n        (\"Strongly Agree\", \"Strongly Agree\", 148),\n        (\"Strongly Agree\", \"Agree\", 12),\n        (\"Agree\", \"Strongly Agree\", 35),\n        (\"Agree\", \"Agree\", 195),\n        (\"Agree\", \"Neutral\", 25),\n        (\"Agree\", \"Disagree\", 5),\n        (\"Neutral\", \"Agree\", 25),\n        (\"Neutral\", \"Neutral\", 175),\n        (\"Neutral\", \"Disagree\", 30),\n        (\"Neutral\", \"Strongly Disagree\", 10),\n        (\"Disagree\", \"Agree\", 5),\n        (\"Disagree\", \"Neutral\", 20),\n        (\"Disagree\", \"Disagree\", 150),\n        (\"Disagree\", \"Strongly Disagree\", 30),\n        (\"Strongly Disagree\", \"Neutral\", 10),\n        (\"Strongly Disagree\", \"Disagree\", 10),\n        (\"Strongly Disagree\", \"Strongly Disagree\", 115),\n    ],\n]\n\n# Compute node totals at each wave\nnode_totals = []\nfor w_idx in range(len(waves)):\n    totals = {}\n    if w_idx == 0:\n        for op in opinions:\n            totals[op] = sum(f[2] for f in flows_data[0] if f[0] == op)\n    elif w_idx == len(waves) - 1:\n        for op in opinions:\n            totals[op] = sum(f[2] for f in flows_data[-1] if f[1] == op)\n    else:\n        for op in opinions:\n            totals[op] = sum(f[2] for f in flows_data[w_idx - 1] if f[1] == op)\n    node_totals.append(totals)\n\n# Compute net flows per transition to identify largest shifts for highlighting\nnet_flows = []\nfor _w_idx, flows in enumerate(flows_data):\n    transition_nets = {}\n    for from_op, to_op, count in flows:\n        if from_op != to_op:\n            key = tuple(sorted([from_op, to_op]))\n            if key not in transition_nets:\n                transition_nets[key] = 0\n            if from_op < to_op:\n                transition_nets[key] += count\n            else:\n                transition_nets[key] -= count\n    net_flows.append(transition_nets)\n\nall_net_magnitudes = []\nfor nets in net_flows:\n    all_net_magnitudes.extend(abs(v) for v in nets.values())\nnet_highlight_threshold = sorted(all_net_magnitudes, reverse=True)[2] if len(all_net_magnitudes) > 2 else 0\n\n# Layout — reversed iteration so Strongly Agree is at top (intuitive positive-at-top convention)\nx_positions = [0, 1.5, 3.0, 4.5]\nnode_width = 0.14\ngap = 18\nlayout_order = list(reversed(opinions))  # bottom-to-top: SD, D, N, A, SA\n\nnode_positions = []\nfor w_idx in range(len(waves)):\n    positions = {}\n    y_cursor = 0\n    for op in layout_order:\n        height = node_totals[w_idx][op]\n        positions[op] = {\"y_start\": y_cursor, \"y_end\": y_cursor + height}\n        y_cursor += height + gap\n    node_positions.append(positions)\n\nmax_y = max(node_positions[w][op][\"y_end\"] for w in range(len(waves)) for op in opinions)\n\n# Create figure — 3200×1800 landscape, toolbar disabled for correct PNG dimensions\np = figure(\n    width=3200,\n    height=1800,\n    title=\"alluvial-opinion-flow · python · bokeh · anyplot.ai\",\n    x_range=(-2.2, 7.0),\n    y_range=(-110, max_y + 110),\n    tools=\"\",\n    toolbar_location=None,\n    min_border_bottom=80,\n    min_border_left=80,\n    min_border_top=110,\n    min_border_right=80,\n)\n\n# Style — theme-adaptive chrome\np.title.text_font_size = \"50pt\"\np.title.text_font_style = \"bold\"\np.title.align = \"center\"\np.title.text_color = INK\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\n# Subtle background panel behind alluvial area\np.quad(\n    left=x_positions[0] - node_width - 0.3,\n    right=x_positions[-1] + node_width + 0.3,\n    top=max_y + 10,\n    bottom=-8,\n    fill_color=ELEVATED_BG,\n    fill_alpha=0.6,\n    line_color=INK_SOFT,\n    line_width=1.5,\n    line_alpha=0.3,\n)\n\n# Subtitle\nsubtitle = Label(\n    x=2.25,\n    y=max_y + 72,\n    text=\"Remote Work Policy Survey — 1,000 Employees Across 4 Quarters\",\n    text_font_size=\"20pt\",\n    text_align=\"center\",\n    text_baseline=\"top\",\n    text_color=INK_SOFT,\n    text_font_style=\"italic\",\n)\np.add_layout(subtitle)\n\n# Precompute all flow ribbon data for ColumnDataSource-based rendering\nn_points = 50\nt_param = np.linspace(0, 1, n_points)\n\nflow_xs_list = []\nflow_ys_list = []\nflow_colors = []\nflow_alphas = []\nflow_line_widths = []\nflow_from_labels = []\nflow_to_labels = []\nflow_counts = []\nflow_wave_labels = []\nflow_types = []\n\nfor w_idx, flows in enumerate(flows_data):\n    x_start = x_positions[w_idx] + node_width / 2\n    x_end = x_positions[w_idx + 1] - node_width / 2\n\n    source_cursors = {op: node_positions[w_idx][op][\"y_start\"] for op in opinions}\n    target_cursors = {op: node_positions[w_idx + 1][op][\"y_start\"] for op in opinions}\n\n    for from_op, to_op, count in flows:\n        if count == 0:\n            continue\n\n        y_src_bottom = source_cursors[from_op]\n        y_src_top = y_src_bottom + count\n        source_cursors[from_op] = y_src_top\n\n        y_tgt_bottom = target_cursors[to_op]\n        y_tgt_top = y_tgt_bottom + count\n        target_cursors[to_op] = y_tgt_top\n\n        is_stable = from_op == to_op\n\n        is_net_highlight = False\n        if not is_stable:\n            key = tuple(sorted([from_op, to_op]))\n            net_mag = abs(net_flows[w_idx].get(key, 0))\n            is_net_highlight = net_mag >= net_highlight_threshold\n\n        # Cubic bezier control points\n        cx0 = x_start + (x_end - x_start) / 3\n        cx1 = x_start + 2 * (x_end - x_start) / 3\n\n        x_curve = (\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        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_curve) + list(x_curve[::-1])\n        ys = list(y_top) + list(y_bottom[::-1])\n\n        if is_stable:\n            fill_alpha = 0.6\n            line_w = 0.5\n        elif is_net_highlight:\n            fill_alpha = 0.45\n            line_w = 2.0\n        else:\n            fill_alpha = 0.30  # raised from 0.2 for better visibility in PNG\n            line_w = 0.5\n\n        flow_xs_list.append(xs)\n        flow_ys_list.append(ys)\n        flow_colors.append(colors[from_op])\n        flow_alphas.append(fill_alpha)\n        flow_line_widths.append(line_w)\n        flow_from_labels.append(from_op)\n        flow_to_labels.append(to_op)\n        flow_counts.append(count)\n        flow_wave_labels.append(f\"{waves[w_idx]} → {waves[w_idx + 1]}\")\n        flow_types.append(\"Stable\" if is_stable else \"Changed\")\n\n# Render changers first (behind), then stable on top\nsort_order = sorted(range(len(flow_types)), key=lambda i: flow_types[i] == \"Stable\")\n\nflow_source = ColumnDataSource(\n    data={\n        \"xs\": [flow_xs_list[i] for i in sort_order],\n        \"ys\": [flow_ys_list[i] for i in sort_order],\n        \"color\": [flow_colors[i] for i in sort_order],\n        \"alpha\": [flow_alphas[i] for i in sort_order],\n        \"line_width\": [flow_line_widths[i] for i in sort_order],\n        \"from_op\": [flow_from_labels[i] for i in sort_order],\n        \"to_op\": [flow_to_labels[i] for i in sort_order],\n        \"count\": [flow_counts[i] for i in sort_order],\n        \"wave\": [flow_wave_labels[i] for i in sort_order],\n        \"flow_type\": [flow_types[i] for i in sort_order],\n    }\n)\n\nflow_renderer = p.patches(\n    xs=\"xs\",\n    ys=\"ys\",\n    fill_color=\"color\",\n    fill_alpha=\"alpha\",\n    line_color=\"color\",\n    line_alpha=0.3,\n    line_width=\"line_width\",\n    source=flow_source,\n)\n\n# HoverTool for flow ribbons\nhover = HoverTool(\n    renderers=[flow_renderer],\n    tooltips=[\n        (\"Transition\", \"@wave\"),\n        (\"From\", \"@from_op\"),\n        (\"To\", \"@to_op\"),\n        (\"Respondents\", \"@count\"),\n        (\"Type\", \"@flow_type\"),\n    ],\n    point_policy=\"follow_mouse\",\n)\np.add_tools(hover)\n\n# TapTool with selection glyphs for interactive highlighting\nflow_renderer.selection_glyph = flow_renderer.glyph.clone()\nflow_renderer.selection_glyph.fill_alpha = 0.9\nflow_renderer.selection_glyph.line_alpha = 0.9\nflow_renderer.selection_glyph.line_width = 3\nflow_renderer.nonselection_glyph = flow_renderer.glyph.clone()\nflow_renderer.nonselection_glyph.fill_alpha = 0.1\nflow_renderer.nonselection_glyph.line_alpha = 0.1\ntap = TapTool(renderers=[flow_renderer])\np.add_tools(tap)\n\n# Draw nodes\nnode_left = []\nnode_right = []\nnode_top = []\nnode_bottom = []\nnode_colors_list = []\nnode_op_labels = []\nnode_wave_labels = []\nnode_count_labels = []\n\nfor w_idx in range(len(waves)):\n    x = x_positions[w_idx]\n    for op in opinions:\n        y_start = node_positions[w_idx][op][\"y_start\"]\n        y_end = node_positions[w_idx][op][\"y_end\"]\n        height = y_end - y_start\n        if height > 0:\n            node_left.append(x - node_width / 2)\n            node_right.append(x + node_width / 2)\n            node_top.append(y_end)\n            node_bottom.append(y_start)\n            node_colors_list.append(colors[op])\n            node_op_labels.append(op)\n            node_wave_labels.append(waves[w_idx])\n            node_count_labels.append(str(int(height)))\n\nnode_source = ColumnDataSource(\n    data={\n        \"left\": node_left,\n        \"right\": node_right,\n        \"top\": node_top,\n        \"bottom\": node_bottom,\n        \"color\": node_colors_list,\n        \"opinion\": node_op_labels,\n        \"wave\": node_wave_labels,\n        \"count\": node_count_labels,\n    }\n)\n\np.quad(\n    left=\"left\",\n    right=\"right\",\n    top=\"top\",\n    bottom=\"bottom\",\n    fill_color=\"color\",\n    line_color=PAGE_BG,\n    line_width=2,\n    source=node_source,\n)\n\n# Node text labels and legend renderers\nlegend_renderers = {}\nfor w_idx in range(len(waves)):\n    x = x_positions[w_idx]\n    for op in opinions:\n        y_start = node_positions[w_idx][op][\"y_start\"]\n        y_end = node_positions[w_idx][op][\"y_end\"]\n        height = y_end - y_start\n\n        if height > 0:\n            if op not in legend_renderers:\n                r = 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[op],\n                    line_color=colors[op],\n                    fill_alpha=0,\n                    line_alpha=0,\n                )\n                legend_renderers[op] = r\n\n            y_mid = (y_start + y_end) / 2\n            if w_idx == 0:\n                label = Label(\n                    x=x - node_width / 2 - 0.05,\n                    y=y_mid,\n                    text=f\"{op} ({int(height)})\",\n                    text_font_size=\"20pt\",\n                    text_baseline=\"middle\",\n                    text_align=\"right\",\n                    text_color=INK,\n                )\n                p.add_layout(label)\n            elif w_idx == len(waves) - 1:\n                label = Label(\n                    x=x + node_width / 2 + 0.05,\n                    y=y_mid,\n                    text=f\"{op} ({int(height)})\",\n                    text_font_size=\"20pt\",\n                    text_baseline=\"middle\",\n                    text_color=INK,\n                )\n                p.add_layout(label)\n            else:\n                label = Label(\n                    x=x + node_width / 2 + 0.05,\n                    y=y_mid,\n                    text=str(int(height)),\n                    text_font_size=\"20pt\",\n                    text_baseline=\"middle\",\n                    text_color=INK_SOFT,\n                )\n                p.add_layout(label)\n\n# Wave column headers\nfor w_idx, wave in enumerate(waves):\n    label = Label(\n        x=x_positions[w_idx],\n        y=-25,\n        text=wave,\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\n# Legend\nlegend_items = [LegendItem(label=op, renderers=[legend_renderers[op]]) for op in opinions]\nlegend = Legend(\n    items=legend_items,\n    location=\"top_right\",\n    label_text_font_size=\"34pt\",\n    label_text_color=INK_SOFT,\n    glyph_width=36,\n    glyph_height=36,\n    spacing=12,\n    padding=20,\n    background_fill_alpha=0.92,\n    background_fill_color=ELEVATED_BG,\n    border_line_color=INK_SOFT,\n    border_line_width=1.5,\n    title=\"Opinion Categories\",\n    title_text_font_size=\"16pt\",\n    title_text_color=INK_MUTED,\n    title_text_font_style=\"italic\",\n)\np.add_layout(legend, \"right\")\n\n# Opacity encoding note\nopacity_note = Label(\n    x=2.25,\n    y=-60,\n    text=\"Solid flows = stable opinion  ·  Faded flows = opinion changed  ·  Bold flows = largest net shifts\",\n    text_font_size=\"20pt\",\n    text_align=\"center\",\n    text_color=INK_MUTED,\n)\np.add_layout(opacity_note)\n\n# Data storytelling: annotate key polarization trend\ntrend_annotation = Label(\n    x=2.25,\n    y=-90,\n    text=\"▲ Polarization trend: Strongly Agree grew +53%  ·  Neutral shrank −8%  ·  Strongly Disagree grew +29%\",\n    text_font_size=\"20pt\",\n    text_align=\"center\",\n    text_color=\"#AE3030\",\n    text_font_style=\"bold\",\n)\np.add_layout(trend_annotation)\n\n# Save HTML artifact (interactive catalog output)\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot via headless Chrome — Chrome's viewport is ~139px shorter than\n# --window-size, so use H + 200 buffer then crop to exact canvas dimensions.\nW, H = 3200, 1800\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 + 200}\",\n    \"--hide-scrollbars\",\n    \"--force-device-scale-factor=1\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H + 200)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\nraw = driver.get_screenshot_as_png()\ndriver.quit()\nImage.open(io.BytesIO(raw)).crop((0, 0, W, H)).save(f\"plot-{THEME}.png\")\n"}