Opinion Flow Diagram — Altair

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 Altair

Python source (Altair)

""" anyplot.ai
alluvial-opinion-flow: Opinion Flow Diagram
Library: altair 6.1.0 | Python 3.13.13
Quality: 92/100 | Updated: 2026-05-30
"""

import os

import altair as alt
import numpy as np
import pandas as pd
from PIL import Image


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
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"

# Imprint palette — semantic mapping: positive→green, negative→red, neutral→muted anchor
CATEGORY_COLORS = {
    "Strongly Agree": "#009E73",  # Imprint green (positive anchor)
    "Agree": "#99B314",  # Imprint lime (mildly positive)
    "Neutral": INK_MUTED,  # theme-adaptive muted anchor
    "Disagree": "#BD8233",  # Imprint ochre (mildly negative)
    "Strongly Disagree": "#AE3030",  # Imprint matte red (negative anchor)
}

# Wave-column background band — theme-adaptive
BAND_COLOR = "#DDE5EE" if THEME == "light" else "#1C2B36"

np.random.seed(42)

# Data — employee engagement survey: 1,000 staff tracked across 4 quarterly waves
categories = ["Strongly Agree", "Agree", "Neutral", "Disagree", "Strongly Disagree"]
waves = ["Q1 2025", "Q2 2025", "Q3 2025", "Q4 2025"]
n_cats = len(categories)
initial_counts = [180, 250, 200, 220, 150]

transitions = [
    np.array(
        [[140, 25, 10, 5, 0], [20, 170, 40, 15, 5], [5, 30, 120, 35, 10], [0, 10, 25, 150, 35], [0, 5, 5, 20, 120]]
    ),
    np.array([[135, 20, 8, 2, 0], [25, 155, 35, 20, 5], [5, 25, 105, 45, 20], [0, 8, 20, 145, 52], [0, 2, 7, 18, 143]]),
    np.array([[140, 18, 5, 2, 0], [22, 135, 30, 18, 5], [3, 22, 100, 35, 15], [0, 5, 15, 155, 55], [0, 2, 5, 15, 198]]),
]

wave_totals = [dict(zip(categories, initial_counts, strict=True))]
for trans in transitions:
    wave_totals.append(dict(zip(categories, trans.sum(axis=0).tolist(), strict=True)))

# Layout — coordinate space matches view dimensions (620 × 320 CSS px)
view_w = 620
view_h = 320
top_margin = 42  # space for wave-column headers inside view
bottom_margin = 22  # space for trend annotation
left_margin = 185  # space for left side-labels
right_margin = 125  # space for right side-labels
node_w = 20
node_gap = 7
n_waves = len(waves)

avail_h = view_h - top_margin - bottom_margin
avail_w = view_w - left_margin - right_margin
x_pos = [left_margin + i * avail_w / (n_waves - 1) for i in range(n_waves)]

# Node positions for each wave
node_pos = {}
for wi in range(n_waves):
    total = sum(wave_totals[wi].values())
    usable = avail_h * 0.88 - node_gap * (n_cats - 1)
    cy = top_margin + (avail_h - usable - node_gap * (n_cats - 1)) / 2
    node_pos[wi] = {}
    for cat in categories:
        ct = wave_totals[wi][cat]
        nh = (ct / total) * usable
        node_pos[wi][cat] = {"y": cy, "h": nh, "x": x_pos[wi] - node_w / 2, "total": ct}
        cy += nh + node_gap

# Flow polygon data (smooth bezier-like curves)
src_off = {w: {c: node_pos[w][c]["y"] for c in categories} for w in range(n_waves)}
tgt_off = {w: {c: node_pos[w][c]["y"] for c in categories} for w in range(n_waves)}
flow_rows = []
ncp = 40

for ti, trans in enumerate(transitions):
    for si, sc in enumerate(categories):
        for tci, tc in enumerate(categories):
            val = int(trans[si, tci])
            if val == 0:
                continue
            sp = node_pos[ti][sc]
            tp = node_pos[ti + 1][tc]
            is_stable = si == tci
            sh = (val / wave_totals[ti][sc]) * sp["h"]
            th = (val / wave_totals[ti + 1][tc]) * tp["h"]
            syt = src_off[ti][sc]
            syb = syt + sh
            tyt = tgt_off[ti + 1][tc]
            tyb = tyt + th
            src_off[ti][sc] += sh
            tgt_off[ti + 1][tc] += th
            xs = x_pos[ti] + node_w / 2
            xe = x_pos[ti + 1] - node_w / 2
            fid = f"w{ti}_{sc}_{tc}"
            pts = []
            for i in range(ncp):
                t = i / (ncp - 1)
                s = t * t * (3 - 2 * t)
                pts.append((xs + t * (xe - xs), syt + s * (tyt - syt)))
            for i in range(ncp - 1, -1, -1):
                t = i / (ncp - 1)
                s = t * t * (3 - 2 * t)
                pts.append((xs + t * (xe - xs), syb + s * (tyb - syb)))
            for pi, (px, py) in enumerate(pts):
                flow_rows.append(
                    {
                        "fid": fid,
                        "src": sc,
                        "tgt": tc,
                        "name": sc,
                        "val": val,
                        "x": px,
                        "y": py,
                        "ord": pi,
                        "stable": is_stable,
                    }
                )

flows_df = pd.DataFrame(flow_rows)

# Node rectangle data
node_rows = []
for wi in range(n_waves):
    for cat in categories:
        p = node_pos[wi][cat]
        node_rows.append(
            {
                "name": cat,
                "wave": waves[wi],
                "wave_idx": wi,
                "x": p["x"],
                "y": p["y"],
                "x2": p["x"] + node_w,
                "y2": p["y"] + p["h"],
                "cx": p["x"] + node_w / 2,
                "cy": p["y"] + p["h"] / 2,
                "total": p["total"],
            }
        )
nodes_df = pd.DataFrame(node_rows)

xs = alt.Scale(domain=[0, view_w])
ys = alt.Scale(domain=[0, view_h])
cdom = list(CATEGORY_COLORS.keys())
crange = list(CATEGORY_COLORS.values())

hover = alt.selection_point(fields=["name"], on="pointerover")

stable_flows = (
    alt.Chart(flows_df)
    .transform_filter("datum.stable")
    .mark_line(filled=True, strokeWidth=0)
    .encode(
        x=alt.X("x:Q", scale=xs, axis=None),
        y=alt.Y("y:Q", scale=ys, axis=None),
        color=alt.Color("src:N", scale=alt.Scale(domain=cdom, range=crange), legend=None),
        detail="fid:N",
        order="ord:Q",
        opacity=alt.condition(hover, alt.value(0.70), alt.value(0.55)),
        tooltip=[alt.Tooltip("src:N", title="Category"), alt.Tooltip("val:Q", title="Stable respondents")],
    )
)

change_flows = (
    alt.Chart(flows_df)
    .transform_filter("!datum.stable")
    .mark_line(filled=True, strokeWidth=0)
    .encode(
        x=alt.X("x:Q", scale=xs, axis=None),
        y=alt.Y("y:Q", scale=ys, axis=None),
        color=alt.Color("src:N", scale=alt.Scale(domain=cdom, range=crange), legend=None),
        detail="fid:N",
        order="ord:Q",
        opacity=alt.condition(hover, alt.value(0.50), alt.value(0.30)),
        tooltip=[
            alt.Tooltip("src:N", title="From"),
            alt.Tooltip("tgt:N", title="To"),
            alt.Tooltip("val:Q", title="Respondents"),
        ],
    )
)

nodes_layer = (
    alt.Chart(nodes_df)
    .mark_rect(stroke=INK_SOFT, strokeWidth=0.8, cornerRadius=3)
    .encode(
        x=alt.X("x:Q", scale=xs),
        y=alt.Y("y:Q", scale=ys),
        x2="x2:Q",
        y2="y2:Q",
        color=alt.Color(
            "name:N",
            scale=alt.Scale(domain=cdom, range=crange),
            legend=alt.Legend(
                title="Sentiment",
                orient="bottom",
                direction="horizontal",
                titleFontSize=12,
                labelFontSize=11,
                titlePadding=6,
                symbolSize=200,
                padding=4,
            ),
        ),
        tooltip=[
            alt.Tooltip("wave:N", title="Quarter"),
            alt.Tooltip("name:N", title="Sentiment"),
            alt.Tooltip("total:Q", title="Respondents"),
        ],
    )
    .add_params(hover)
)

count_labels = (
    alt.Chart(nodes_df)
    .transform_filter(alt.datum.y2 - alt.datum.y >= 8)
    .transform_calculate(lbl="'' + datum.total")
    .mark_text(fontSize=10, fontWeight="bold", color="#FFFFFF", baseline="middle", align="center")
    .encode(x=alt.X("cx:Q", scale=xs), y=alt.Y("cy:Q", scale=ys), text="lbl:N")
)

# Left labels: category name + Q1 count
label_rows = []
for _, row in nodes_df.iterrows():
    yc = (row["y"] + row["y2"]) / 2
    if row["wave_idx"] == 0:
        label_rows.append({"x": row["x"] - 5, "y": yc, "text": f"{row['name']} ({int(row['total'])})", "side": "left"})
    elif row["wave_idx"] == n_waves - 1:
        cat = row["name"]
        delta = wave_totals[n_waves - 1][cat] - wave_totals[0][cat]
        sign = "+" if delta >= 0 else ""
        label_rows.append(
            {"x": row["x2"] + 5, "y": yc, "text": f"({int(row['total'])}) {sign}{delta}", "side": "right"}
        )
labels_df = pd.DataFrame(label_rows)

left_labels = (
    alt.Chart(labels_df)
    .transform_filter(alt.datum.side == "left")
    .mark_text(fontSize=11, color=INK_SOFT, align="right", baseline="middle")
    .encode(x=alt.X("x:Q", scale=xs), y=alt.Y("y:Q", scale=ys), text="text:N")
)

right_labels = (
    alt.Chart(labels_df)
    .transform_filter(alt.datum.side == "right")
    .mark_text(fontSize=11, color=INK_SOFT, align="left", baseline="middle")
    .encode(x=alt.X("x:Q", scale=xs), y=alt.Y("y:Q", scale=ys), text="text:N")
)

# Wave-column headers — positioned just above the node area inside the view
hdr_y = top_margin - 10
hdr_data = pd.DataFrame([{"x": x_pos[i], "y": hdr_y, "text": waves[i]} for i in range(n_waves)])
wave_headers = (
    alt.Chart(hdr_data)
    .mark_text(fontSize=12, fontWeight="bold", color=INK, baseline="bottom", align="center")
    .encode(x=alt.X("x:Q", scale=xs), y=alt.Y("y:Q", scale=ys), text="text:N")
)

# Trend annotation — bottom of view
max_y = max(node_pos[w][c]["y"] + node_pos[w][c]["h"] for w in range(n_waves) for c in categories)
trend_y = max_y + 14
trend_data = pd.DataFrame(
    [
        {
            "x": view_w / 2,
            "y": trend_y,
            "text": "Polarization trend: extreme sentiments grow while moderate opinions decline",
        }
    ]
)
trend_ann = (
    alt.Chart(trend_data)
    .mark_text(fontSize=10, fontStyle="italic", color=INK_MUTED, baseline="top", align="center")
    .encode(x=alt.X("x:Q", scale=xs), y=alt.Y("y:Q", scale=ys), text="text:N")
)

# Subtle column background bands
bw = 14
band_data = pd.DataFrame(
    [{"x": x_pos[i] - bw, "x2": x_pos[i] + bw, "y": top_margin - 5, "y2": max_y + 5} for i in range(n_waves)]
)
wave_bands = (
    alt.Chart(band_data)
    .mark_rect(color=BAND_COLOR, opacity=0.6, cornerRadius=4)
    .encode(x=alt.X("x:Q", scale=xs, axis=None), x2="x2:Q", y=alt.Y("y:Q", scale=ys, axis=None), y2="y2:Q")
)

title_str = "alluvial-opinion-flow · python · altair · anyplot.ai"
n_chars = len(title_str)
ratio = 67 / n_chars if n_chars > 67 else 1.0
title_fs = max(11, round(16 * ratio))

chart = (
    alt.layer(
        wave_bands,
        change_flows,
        stable_flows,
        nodes_layer,
        count_labels,
        left_labels,
        right_labels,
        wave_headers,
        trend_ann,
    )
    .properties(
        width=620,
        height=320,
        background=PAGE_BG,
        title=alt.Title(
            text=title_str,
            subtitle="Employee Engagement Survey — 1,000 Staff Quarterly Sentiment Tracking",
            fontSize=title_fs,
            subtitleFontSize=10,
            subtitleColor=INK_MUTED,
            anchor="middle",
            color=INK,
            offset=8,
        ),
    )
    .configure_view(strokeWidth=0, fill=PAGE_BG)
    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)
    .interactive()
)

# Save — canvas hard contract: 3200 × 1800 (landscape)
TW, TH = 3200, 1800
chart.save(f"plot-{THEME}.png", scale_factor=4.0)

_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
    raise SystemExit(
        f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
        f"Shrink chart .properties(width=, height=) values and re-render."
    )
if _w < TW or _h < TH:
    _canvas = Image.new("RGB", (TW, TH), PAGE_BG)
    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
    _canvas.save(f"plot-{THEME}.png")

chart.save(f"plot-{THEME}.html")

Part of Opinion Flow Diagram on anyplot.ai.

Other implementations