Opinion Flow Diagram — Bokeh

An alluvial/Sankey-style diagram showing how opinions or group memberships shift between survey waves or time periods. Flows connect the same response categories across columns, revealing patterns of opinion change, stability, and polarization. Unlike a basic alluvial diagram, this variant emphasizes distinguishing stable respondents from net changers and displays respondent totals per category at each wave.

Opinion Flow Diagram rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
alluvial-opinion-flow: Opinion Flow Diagram
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 87/100 | Updated: 2026-05-30
"""

import io
import os
import sys


# Prevent self-import: this file is named bokeh.py, so Python's path search would
# find it before the installed bokeh package. Remove the script's own directory
# from sys.path so imports resolve to the installed package.
_own_dir = os.path.dirname(os.path.abspath(__file__))
sys.path = [p for p in sys.path if os.path.abspath(p) != _own_dir]

import time
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import ColumnDataSource, HoverTool, Label, Legend, LegendItem, TapTool
from bokeh.plotting import figure
from PIL import Image
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


THEME = os.getenv("ANYPLOT_THEME", "light")

# Imprint palette theme-adaptive chrome
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Data: Remote work policy opinion survey — 1,000 employees across 4 quarterly waves
# Story: Opinions gradually polarize as the debate matures
waves = ["Q1 2024", "Q2 2024", "Q3 2024", "Q4 2024"]
opinions = ["Strongly Agree", "Agree", "Neutral", "Disagree", "Strongly Disagree"]

# Imprint palette with semantic exception: sentiment scale (positive→green, negative→red)
colors = {
    "Strongly Agree": "#009E73",  # Imprint brand green — positive
    "Agree": "#99B314",  # Imprint lime — lighter positive
    "Neutral": INK_MUTED,  # Imprint muted — neutral (theme-adaptive)
    "Disagree": "#BD8233",  # Imprint ochre — cautionary
    "Strongly Disagree": "#AE3030",  # Imprint matte red — negative
}

# Flow transitions between consecutive waves (source, target, respondent_count)
flows_data = [
    # Q1 → Q2
    [
        ("Strongly Agree", "Strongly Agree", 105),
        ("Strongly Agree", "Agree", 15),
        ("Agree", "Strongly Agree", 25),
        ("Agree", "Agree", 230),
        ("Agree", "Neutral", 20),
        ("Agree", "Disagree", 5),
        ("Neutral", "Agree", 35),
        ("Neutral", "Neutral", 190),
        ("Neutral", "Disagree", 20),
        ("Neutral", "Strongly Disagree", 5),
        ("Disagree", "Agree", 5),
        ("Disagree", "Neutral", 30),
        ("Disagree", "Disagree", 175),
        ("Disagree", "Strongly Disagree", 20),
        ("Strongly Disagree", "Neutral", 10),
        ("Strongly Disagree", "Disagree", 15),
        ("Strongly Disagree", "Strongly Disagree", 95),
    ],
    # Q2 → Q3 (polarization intensifies)
    [
        ("Strongly Agree", "Strongly Agree", 120),
        ("Strongly Agree", "Agree", 10),
        ("Agree", "Strongly Agree", 40),
        ("Agree", "Agree", 215),
        ("Agree", "Neutral", 25),
        ("Agree", "Disagree", 5),
        ("Neutral", "Agree", 30),
        ("Neutral", "Neutral", 180),
        ("Neutral", "Disagree", 30),
        ("Neutral", "Strongly Disagree", 10),
        ("Disagree", "Agree", 5),
        ("Disagree", "Neutral", 25),
        ("Disagree", "Disagree", 160),
        ("Disagree", "Strongly Disagree", 25),
        ("Strongly Disagree", "Neutral", 10),
        ("Strongly Disagree", "Disagree", 10),
        ("Strongly Disagree", "Strongly Disagree", 100),
    ],
    # Q3 → Q4 (further polarization)
    [
        ("Strongly Agree", "Strongly Agree", 148),
        ("Strongly Agree", "Agree", 12),
        ("Agree", "Strongly Agree", 35),
        ("Agree", "Agree", 195),
        ("Agree", "Neutral", 25),
        ("Agree", "Disagree", 5),
        ("Neutral", "Agree", 25),
        ("Neutral", "Neutral", 175),
        ("Neutral", "Disagree", 30),
        ("Neutral", "Strongly Disagree", 10),
        ("Disagree", "Agree", 5),
        ("Disagree", "Neutral", 20),
        ("Disagree", "Disagree", 150),
        ("Disagree", "Strongly Disagree", 30),
        ("Strongly Disagree", "Neutral", 10),
        ("Strongly Disagree", "Disagree", 10),
        ("Strongly Disagree", "Strongly Disagree", 115),
    ],
]

# Compute node totals at each wave
node_totals = []
for w_idx in range(len(waves)):
    totals = {}
    if w_idx == 0:
        for op in opinions:
            totals[op] = sum(f[2] for f in flows_data[0] if f[0] == op)
    elif w_idx == len(waves) - 1:
        for op in opinions:
            totals[op] = sum(f[2] for f in flows_data[-1] if f[1] == op)
    else:
        for op in opinions:
            totals[op] = sum(f[2] for f in flows_data[w_idx - 1] if f[1] == op)
    node_totals.append(totals)

# Compute net flows per transition to identify largest shifts for highlighting
net_flows = []
for _w_idx, flows in enumerate(flows_data):
    transition_nets = {}
    for from_op, to_op, count in flows:
        if from_op != to_op:
            key = tuple(sorted([from_op, to_op]))
            if key not in transition_nets:
                transition_nets[key] = 0
            if from_op < to_op:
                transition_nets[key] += count
            else:
                transition_nets[key] -= count
    net_flows.append(transition_nets)

all_net_magnitudes = []
for nets in net_flows:
    all_net_magnitudes.extend(abs(v) for v in nets.values())
net_highlight_threshold = sorted(all_net_magnitudes, reverse=True)[2] if len(all_net_magnitudes) > 2 else 0

# Layout — reversed iteration so Strongly Agree is at top (intuitive positive-at-top convention)
x_positions = [0, 1.5, 3.0, 4.5]
node_width = 0.14
gap = 18
layout_order = list(reversed(opinions))  # bottom-to-top: SD, D, N, A, SA

node_positions = []
for w_idx in range(len(waves)):
    positions = {}
    y_cursor = 0
    for op in layout_order:
        height = node_totals[w_idx][op]
        positions[op] = {"y_start": y_cursor, "y_end": y_cursor + height}
        y_cursor += height + gap
    node_positions.append(positions)

max_y = max(node_positions[w][op]["y_end"] for w in range(len(waves)) for op in opinions)

# Create figure — 3200×1800 landscape, toolbar disabled for correct PNG dimensions
p = figure(
    width=3200,
    height=1800,
    title="alluvial-opinion-flow · python · bokeh · anyplot.ai",
    x_range=(-2.2, 7.0),
    y_range=(-110, max_y + 110),
    tools="",
    toolbar_location=None,
    min_border_bottom=80,
    min_border_left=80,
    min_border_top=110,
    min_border_right=80,
)

# Style — theme-adaptive chrome
p.title.text_font_size = "50pt"
p.title.text_font_style = "bold"
p.title.align = "center"
p.title.text_color = INK
p.xgrid.visible = False
p.ygrid.visible = False
p.xaxis.visible = False
p.yaxis.visible = False
p.outline_line_color = None
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG

# Subtle background panel behind alluvial area
p.quad(
    left=x_positions[0] - node_width - 0.3,
    right=x_positions[-1] + node_width + 0.3,
    top=max_y + 10,
    bottom=-8,
    fill_color=ELEVATED_BG,
    fill_alpha=0.6,
    line_color=INK_SOFT,
    line_width=1.5,
    line_alpha=0.3,
)

# Subtitle
subtitle = Label(
    x=2.25,
    y=max_y + 72,
    text="Remote Work Policy Survey — 1,000 Employees Across 4 Quarters",
    text_font_size="20pt",
    text_align="center",
    text_baseline="top",
    text_color=INK_SOFT,
    text_font_style="italic",
)
p.add_layout(subtitle)

# Precompute all flow ribbon data for ColumnDataSource-based rendering
n_points = 50
t_param = np.linspace(0, 1, n_points)

flow_xs_list = []
flow_ys_list = []
flow_colors = []
flow_alphas = []
flow_line_widths = []
flow_from_labels = []
flow_to_labels = []
flow_counts = []
flow_wave_labels = []
flow_types = []

for w_idx, flows in enumerate(flows_data):
    x_start = x_positions[w_idx] + node_width / 2
    x_end = x_positions[w_idx + 1] - node_width / 2

    source_cursors = {op: node_positions[w_idx][op]["y_start"] for op in opinions}
    target_cursors = {op: node_positions[w_idx + 1][op]["y_start"] for op in opinions}

    for from_op, to_op, count in flows:
        if count == 0:
            continue

        y_src_bottom = source_cursors[from_op]
        y_src_top = y_src_bottom + count
        source_cursors[from_op] = y_src_top

        y_tgt_bottom = target_cursors[to_op]
        y_tgt_top = y_tgt_bottom + count
        target_cursors[to_op] = y_tgt_top

        is_stable = from_op == to_op

        is_net_highlight = False
        if not is_stable:
            key = tuple(sorted([from_op, to_op]))
            net_mag = abs(net_flows[w_idx].get(key, 0))
            is_net_highlight = net_mag >= net_highlight_threshold

        # Cubic bezier control points
        cx0 = x_start + (x_end - x_start) / 3
        cx1 = x_start + 2 * (x_end - x_start) / 3

        x_curve = (
            (1 - t_param) ** 3 * x_start
            + 3 * (1 - t_param) ** 2 * t_param * cx0
            + 3 * (1 - t_param) * t_param**2 * cx1
            + t_param**3 * x_end
        )
        y_top = (
            (1 - t_param) ** 3 * y_src_top
            + 3 * (1 - t_param) ** 2 * t_param * y_src_top
            + 3 * (1 - t_param) * t_param**2 * y_tgt_top
            + t_param**3 * y_tgt_top
        )
        y_bottom = (
            (1 - t_param) ** 3 * y_src_bottom
            + 3 * (1 - t_param) ** 2 * t_param * y_src_bottom
            + 3 * (1 - t_param) * t_param**2 * y_tgt_bottom
            + t_param**3 * y_tgt_bottom
        )

        xs = list(x_curve) + list(x_curve[::-1])
        ys = list(y_top) + list(y_bottom[::-1])

        if is_stable:
            fill_alpha = 0.6
            line_w = 0.5
        elif is_net_highlight:
            fill_alpha = 0.45
            line_w = 2.0
        else:
            fill_alpha = 0.30  # raised from 0.2 for better visibility in PNG
            line_w = 0.5

        flow_xs_list.append(xs)
        flow_ys_list.append(ys)
        flow_colors.append(colors[from_op])
        flow_alphas.append(fill_alpha)
        flow_line_widths.append(line_w)
        flow_from_labels.append(from_op)
        flow_to_labels.append(to_op)
        flow_counts.append(count)
        flow_wave_labels.append(f"{waves[w_idx]} → {waves[w_idx + 1]}")
        flow_types.append("Stable" if is_stable else "Changed")

# Render changers first (behind), then stable on top
sort_order = sorted(range(len(flow_types)), key=lambda i: flow_types[i] == "Stable")

flow_source = ColumnDataSource(
    data={
        "xs": [flow_xs_list[i] for i in sort_order],
        "ys": [flow_ys_list[i] for i in sort_order],
        "color": [flow_colors[i] for i in sort_order],
        "alpha": [flow_alphas[i] for i in sort_order],
        "line_width": [flow_line_widths[i] for i in sort_order],
        "from_op": [flow_from_labels[i] for i in sort_order],
        "to_op": [flow_to_labels[i] for i in sort_order],
        "count": [flow_counts[i] for i in sort_order],
        "wave": [flow_wave_labels[i] for i in sort_order],
        "flow_type": [flow_types[i] for i in sort_order],
    }
)

flow_renderer = p.patches(
    xs="xs",
    ys="ys",
    fill_color="color",
    fill_alpha="alpha",
    line_color="color",
    line_alpha=0.3,
    line_width="line_width",
    source=flow_source,
)

# HoverTool for flow ribbons
hover = HoverTool(
    renderers=[flow_renderer],
    tooltips=[
        ("Transition", "@wave"),
        ("From", "@from_op"),
        ("To", "@to_op"),
        ("Respondents", "@count"),
        ("Type", "@flow_type"),
    ],
    point_policy="follow_mouse",
)
p.add_tools(hover)

# TapTool with selection glyphs for interactive highlighting
flow_renderer.selection_glyph = flow_renderer.glyph.clone()
flow_renderer.selection_glyph.fill_alpha = 0.9
flow_renderer.selection_glyph.line_alpha = 0.9
flow_renderer.selection_glyph.line_width = 3
flow_renderer.nonselection_glyph = flow_renderer.glyph.clone()
flow_renderer.nonselection_glyph.fill_alpha = 0.1
flow_renderer.nonselection_glyph.line_alpha = 0.1
tap = TapTool(renderers=[flow_renderer])
p.add_tools(tap)

# Draw nodes
node_left = []
node_right = []
node_top = []
node_bottom = []
node_colors_list = []
node_op_labels = []
node_wave_labels = []
node_count_labels = []

for w_idx in range(len(waves)):
    x = x_positions[w_idx]
    for op in opinions:
        y_start = node_positions[w_idx][op]["y_start"]
        y_end = node_positions[w_idx][op]["y_end"]
        height = y_end - y_start
        if height > 0:
            node_left.append(x - node_width / 2)
            node_right.append(x + node_width / 2)
            node_top.append(y_end)
            node_bottom.append(y_start)
            node_colors_list.append(colors[op])
            node_op_labels.append(op)
            node_wave_labels.append(waves[w_idx])
            node_count_labels.append(str(int(height)))

node_source = ColumnDataSource(
    data={
        "left": node_left,
        "right": node_right,
        "top": node_top,
        "bottom": node_bottom,
        "color": node_colors_list,
        "opinion": node_op_labels,
        "wave": node_wave_labels,
        "count": node_count_labels,
    }
)

p.quad(
    left="left",
    right="right",
    top="top",
    bottom="bottom",
    fill_color="color",
    line_color=PAGE_BG,
    line_width=2,
    source=node_source,
)

# Node text labels and legend renderers
legend_renderers = {}
for w_idx in range(len(waves)):
    x = x_positions[w_idx]
    for op in opinions:
        y_start = node_positions[w_idx][op]["y_start"]
        y_end = node_positions[w_idx][op]["y_end"]
        height = y_end - y_start

        if height > 0:
            if op not in legend_renderers:
                r = p.quad(
                    left=x - node_width / 2,
                    right=x + node_width / 2,
                    top=y_end,
                    bottom=y_start,
                    fill_color=colors[op],
                    line_color=colors[op],
                    fill_alpha=0,
                    line_alpha=0,
                )
                legend_renderers[op] = r

            y_mid = (y_start + y_end) / 2
            if w_idx == 0:
                label = Label(
                    x=x - node_width / 2 - 0.05,
                    y=y_mid,
                    text=f"{op} ({int(height)})",
                    text_font_size="20pt",
                    text_baseline="middle",
                    text_align="right",
                    text_color=INK,
                )
                p.add_layout(label)
            elif w_idx == len(waves) - 1:
                label = Label(
                    x=x + node_width / 2 + 0.05,
                    y=y_mid,
                    text=f"{op} ({int(height)})",
                    text_font_size="20pt",
                    text_baseline="middle",
                    text_color=INK,
                )
                p.add_layout(label)
            else:
                label = Label(
                    x=x + node_width / 2 + 0.05,
                    y=y_mid,
                    text=str(int(height)),
                    text_font_size="20pt",
                    text_baseline="middle",
                    text_color=INK_SOFT,
                )
                p.add_layout(label)

# Wave column headers
for w_idx, wave in enumerate(waves):
    label = Label(
        x=x_positions[w_idx],
        y=-25,
        text=wave,
        text_font_size="24pt",
        text_align="center",
        text_baseline="top",
        text_color=INK,
        text_font_style="bold",
    )
    p.add_layout(label)

# Legend
legend_items = [LegendItem(label=op, renderers=[legend_renderers[op]]) for op in opinions]
legend = Legend(
    items=legend_items,
    location="top_right",
    label_text_font_size="34pt",
    label_text_color=INK_SOFT,
    glyph_width=36,
    glyph_height=36,
    spacing=12,
    padding=20,
    background_fill_alpha=0.92,
    background_fill_color=ELEVATED_BG,
    border_line_color=INK_SOFT,
    border_line_width=1.5,
    title="Opinion Categories",
    title_text_font_size="16pt",
    title_text_color=INK_MUTED,
    title_text_font_style="italic",
)
p.add_layout(legend, "right")

# Opacity encoding note
opacity_note = Label(
    x=2.25,
    y=-60,
    text="Solid flows = stable opinion  ·  Faded flows = opinion changed  ·  Bold flows = largest net shifts",
    text_font_size="20pt",
    text_align="center",
    text_color=INK_MUTED,
)
p.add_layout(opacity_note)

# Data storytelling: annotate key polarization trend
trend_annotation = Label(
    x=2.25,
    y=-90,
    text="▲ Polarization trend: Strongly Agree grew +53%  ·  Neutral shrank −8%  ·  Strongly Disagree grew +29%",
    text_font_size="20pt",
    text_align="center",
    text_color="#AE3030",
    text_font_style="bold",
)
p.add_layout(trend_annotation)

# Save HTML artifact (interactive catalog output)
output_file(f"plot-{THEME}.html")
save(p)

# Screenshot via headless Chrome — Chrome's viewport is ~139px shorter than
# --window-size, so use H + 200 buffer then crop to exact canvas dimensions.
W, H = 3200, 1800
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H + 200}",
    "--hide-scrollbars",
    "--force-device-scale-factor=1",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.set_window_size(W, H + 200)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
raw = driver.get_screenshot_as_png()
driver.quit()
Image.open(io.BytesIO(raw)).crop((0, 0, W, H)).save(f"plot-{THEME}.png")

Part of Opinion Flow Diagram on anyplot.ai.

Other implementations