{"spec_id":"alluvial-opinion-flow","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nalluvial-opinion-flow: Opinion Flow Diagram\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-05-30\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme tokens\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\n# Imprint palette — semantic mapping: positive→green, negative→red, neutral→muted anchor\nCATEGORY_COLORS = {\n    \"Strongly Agree\": \"#009E73\",  # Imprint green (positive anchor)\n    \"Agree\": \"#99B314\",  # Imprint lime (mildly positive)\n    \"Neutral\": INK_MUTED,  # theme-adaptive muted anchor\n    \"Disagree\": \"#BD8233\",  # Imprint ochre (mildly negative)\n    \"Strongly Disagree\": \"#AE3030\",  # Imprint matte red (negative anchor)\n}\n\n# Wave-column background band — theme-adaptive\nBAND_COLOR = \"#DDE5EE\" if THEME == \"light\" else \"#1C2B36\"\n\nnp.random.seed(42)\n\n# Data — employee engagement survey: 1,000 staff tracked across 4 quarterly waves\ncategories = [\"Strongly Agree\", \"Agree\", \"Neutral\", \"Disagree\", \"Strongly Disagree\"]\nwaves = [\"Q1 2025\", \"Q2 2025\", \"Q3 2025\", \"Q4 2025\"]\nn_cats = len(categories)\ninitial_counts = [180, 250, 200, 220, 150]\n\ntransitions = [\n    np.array(\n        [[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]]\n    ),\n    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]]),\n    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]]),\n]\n\nwave_totals = [dict(zip(categories, initial_counts, strict=True))]\nfor trans in transitions:\n    wave_totals.append(dict(zip(categories, trans.sum(axis=0).tolist(), strict=True)))\n\n# Layout — coordinate space matches view dimensions (620 × 320 CSS px)\nview_w = 620\nview_h = 320\ntop_margin = 42  # space for wave-column headers inside view\nbottom_margin = 22  # space for trend annotation\nleft_margin = 185  # space for left side-labels\nright_margin = 125  # space for right side-labels\nnode_w = 20\nnode_gap = 7\nn_waves = len(waves)\n\navail_h = view_h - top_margin - bottom_margin\navail_w = view_w - left_margin - right_margin\nx_pos = [left_margin + i * avail_w / (n_waves - 1) for i in range(n_waves)]\n\n# Node positions for each wave\nnode_pos = {}\nfor wi in range(n_waves):\n    total = sum(wave_totals[wi].values())\n    usable = avail_h * 0.88 - node_gap * (n_cats - 1)\n    cy = top_margin + (avail_h - usable - node_gap * (n_cats - 1)) / 2\n    node_pos[wi] = {}\n    for cat in categories:\n        ct = wave_totals[wi][cat]\n        nh = (ct / total) * usable\n        node_pos[wi][cat] = {\"y\": cy, \"h\": nh, \"x\": x_pos[wi] - node_w / 2, \"total\": ct}\n        cy += nh + node_gap\n\n# Flow polygon data (smooth bezier-like curves)\nsrc_off = {w: {c: node_pos[w][c][\"y\"] for c in categories} for w in range(n_waves)}\ntgt_off = {w: {c: node_pos[w][c][\"y\"] for c in categories} for w in range(n_waves)}\nflow_rows = []\nncp = 40\n\nfor ti, trans in enumerate(transitions):\n    for si, sc in enumerate(categories):\n        for tci, tc in enumerate(categories):\n            val = int(trans[si, tci])\n            if val == 0:\n                continue\n            sp = node_pos[ti][sc]\n            tp = node_pos[ti + 1][tc]\n            is_stable = si == tci\n            sh = (val / wave_totals[ti][sc]) * sp[\"h\"]\n            th = (val / wave_totals[ti + 1][tc]) * tp[\"h\"]\n            syt = src_off[ti][sc]\n            syb = syt + sh\n            tyt = tgt_off[ti + 1][tc]\n            tyb = tyt + th\n            src_off[ti][sc] += sh\n            tgt_off[ti + 1][tc] += th\n            xs = x_pos[ti] + node_w / 2\n            xe = x_pos[ti + 1] - node_w / 2\n            fid = f\"w{ti}_{sc}_{tc}\"\n            pts = []\n            for i in range(ncp):\n                t = i / (ncp - 1)\n                s = t * t * (3 - 2 * t)\n                pts.append((xs + t * (xe - xs), syt + s * (tyt - syt)))\n            for i in range(ncp - 1, -1, -1):\n                t = i / (ncp - 1)\n                s = t * t * (3 - 2 * t)\n                pts.append((xs + t * (xe - xs), syb + s * (tyb - syb)))\n            for pi, (px, py) in enumerate(pts):\n                flow_rows.append(\n                    {\n                        \"fid\": fid,\n                        \"src\": sc,\n                        \"tgt\": tc,\n                        \"name\": sc,\n                        \"val\": val,\n                        \"x\": px,\n                        \"y\": py,\n                        \"ord\": pi,\n                        \"stable\": is_stable,\n                    }\n                )\n\nflows_df = pd.DataFrame(flow_rows)\n\n# Node rectangle data\nnode_rows = []\nfor wi in range(n_waves):\n    for cat in categories:\n        p = node_pos[wi][cat]\n        node_rows.append(\n            {\n                \"name\": cat,\n                \"wave\": waves[wi],\n                \"wave_idx\": wi,\n                \"x\": p[\"x\"],\n                \"y\": p[\"y\"],\n                \"x2\": p[\"x\"] + node_w,\n                \"y2\": p[\"y\"] + p[\"h\"],\n                \"cx\": p[\"x\"] + node_w / 2,\n                \"cy\": p[\"y\"] + p[\"h\"] / 2,\n                \"total\": p[\"total\"],\n            }\n        )\nnodes_df = pd.DataFrame(node_rows)\n\nxs = alt.Scale(domain=[0, view_w])\nys = alt.Scale(domain=[0, view_h])\ncdom = list(CATEGORY_COLORS.keys())\ncrange = list(CATEGORY_COLORS.values())\n\nhover = alt.selection_point(fields=[\"name\"], on=\"pointerover\")\n\nstable_flows = (\n    alt.Chart(flows_df)\n    .transform_filter(\"datum.stable\")\n    .mark_line(filled=True, strokeWidth=0)\n    .encode(\n        x=alt.X(\"x:Q\", scale=xs, axis=None),\n        y=alt.Y(\"y:Q\", scale=ys, axis=None),\n        color=alt.Color(\"src:N\", scale=alt.Scale(domain=cdom, range=crange), legend=None),\n        detail=\"fid:N\",\n        order=\"ord:Q\",\n        opacity=alt.condition(hover, alt.value(0.70), alt.value(0.55)),\n        tooltip=[alt.Tooltip(\"src:N\", title=\"Category\"), alt.Tooltip(\"val:Q\", title=\"Stable respondents\")],\n    )\n)\n\nchange_flows = (\n    alt.Chart(flows_df)\n    .transform_filter(\"!datum.stable\")\n    .mark_line(filled=True, strokeWidth=0)\n    .encode(\n        x=alt.X(\"x:Q\", scale=xs, axis=None),\n        y=alt.Y(\"y:Q\", scale=ys, axis=None),\n        color=alt.Color(\"src:N\", scale=alt.Scale(domain=cdom, range=crange), legend=None),\n        detail=\"fid:N\",\n        order=\"ord:Q\",\n        opacity=alt.condition(hover, alt.value(0.50), alt.value(0.30)),\n        tooltip=[\n            alt.Tooltip(\"src:N\", title=\"From\"),\n            alt.Tooltip(\"tgt:N\", title=\"To\"),\n            alt.Tooltip(\"val:Q\", title=\"Respondents\"),\n        ],\n    )\n)\n\nnodes_layer = (\n    alt.Chart(nodes_df)\n    .mark_rect(stroke=INK_SOFT, strokeWidth=0.8, cornerRadius=3)\n    .encode(\n        x=alt.X(\"x:Q\", scale=xs),\n        y=alt.Y(\"y:Q\", scale=ys),\n        x2=\"x2:Q\",\n        y2=\"y2:Q\",\n        color=alt.Color(\n            \"name:N\",\n            scale=alt.Scale(domain=cdom, range=crange),\n            legend=alt.Legend(\n                title=\"Sentiment\",\n                orient=\"bottom\",\n                direction=\"horizontal\",\n                titleFontSize=12,\n                labelFontSize=11,\n                titlePadding=6,\n                symbolSize=200,\n                padding=4,\n            ),\n        ),\n        tooltip=[\n            alt.Tooltip(\"wave:N\", title=\"Quarter\"),\n            alt.Tooltip(\"name:N\", title=\"Sentiment\"),\n            alt.Tooltip(\"total:Q\", title=\"Respondents\"),\n        ],\n    )\n    .add_params(hover)\n)\n\ncount_labels = (\n    alt.Chart(nodes_df)\n    .transform_filter(alt.datum.y2 - alt.datum.y >= 8)\n    .transform_calculate(lbl=\"'' + datum.total\")\n    .mark_text(fontSize=10, fontWeight=\"bold\", color=\"#FFFFFF\", baseline=\"middle\", align=\"center\")\n    .encode(x=alt.X(\"cx:Q\", scale=xs), y=alt.Y(\"cy:Q\", scale=ys), text=\"lbl:N\")\n)\n\n# Left labels: category name + Q1 count\nlabel_rows = []\nfor _, row in nodes_df.iterrows():\n    yc = (row[\"y\"] + row[\"y2\"]) / 2\n    if row[\"wave_idx\"] == 0:\n        label_rows.append({\"x\": row[\"x\"] - 5, \"y\": yc, \"text\": f\"{row['name']} ({int(row['total'])})\", \"side\": \"left\"})\n    elif row[\"wave_idx\"] == n_waves - 1:\n        cat = row[\"name\"]\n        delta = wave_totals[n_waves - 1][cat] - wave_totals[0][cat]\n        sign = \"+\" if delta >= 0 else \"\"\n        label_rows.append(\n            {\"x\": row[\"x2\"] + 5, \"y\": yc, \"text\": f\"({int(row['total'])}) {sign}{delta}\", \"side\": \"right\"}\n        )\nlabels_df = pd.DataFrame(label_rows)\n\nleft_labels = (\n    alt.Chart(labels_df)\n    .transform_filter(alt.datum.side == \"left\")\n    .mark_text(fontSize=11, color=INK_SOFT, align=\"right\", baseline=\"middle\")\n    .encode(x=alt.X(\"x:Q\", scale=xs), y=alt.Y(\"y:Q\", scale=ys), text=\"text:N\")\n)\n\nright_labels = (\n    alt.Chart(labels_df)\n    .transform_filter(alt.datum.side == \"right\")\n    .mark_text(fontSize=11, color=INK_SOFT, align=\"left\", baseline=\"middle\")\n    .encode(x=alt.X(\"x:Q\", scale=xs), y=alt.Y(\"y:Q\", scale=ys), text=\"text:N\")\n)\n\n# Wave-column headers — positioned just above the node area inside the view\nhdr_y = top_margin - 10\nhdr_data = pd.DataFrame([{\"x\": x_pos[i], \"y\": hdr_y, \"text\": waves[i]} for i in range(n_waves)])\nwave_headers = (\n    alt.Chart(hdr_data)\n    .mark_text(fontSize=12, fontWeight=\"bold\", color=INK, baseline=\"bottom\", align=\"center\")\n    .encode(x=alt.X(\"x:Q\", scale=xs), y=alt.Y(\"y:Q\", scale=ys), text=\"text:N\")\n)\n\n# Trend annotation — bottom of view\nmax_y = max(node_pos[w][c][\"y\"] + node_pos[w][c][\"h\"] for w in range(n_waves) for c in categories)\ntrend_y = max_y + 14\ntrend_data = pd.DataFrame(\n    [\n        {\n            \"x\": view_w / 2,\n            \"y\": trend_y,\n            \"text\": \"Polarization trend: extreme sentiments grow while moderate opinions decline\",\n        }\n    ]\n)\ntrend_ann = (\n    alt.Chart(trend_data)\n    .mark_text(fontSize=10, fontStyle=\"italic\", color=INK_MUTED, baseline=\"top\", align=\"center\")\n    .encode(x=alt.X(\"x:Q\", scale=xs), y=alt.Y(\"y:Q\", scale=ys), text=\"text:N\")\n)\n\n# Subtle column background bands\nbw = 14\nband_data = pd.DataFrame(\n    [{\"x\": x_pos[i] - bw, \"x2\": x_pos[i] + bw, \"y\": top_margin - 5, \"y2\": max_y + 5} for i in range(n_waves)]\n)\nwave_bands = (\n    alt.Chart(band_data)\n    .mark_rect(color=BAND_COLOR, opacity=0.6, cornerRadius=4)\n    .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\")\n)\n\ntitle_str = \"alluvial-opinion-flow · python · altair · anyplot.ai\"\nn_chars = len(title_str)\nratio = 67 / n_chars if n_chars > 67 else 1.0\ntitle_fs = max(11, round(16 * ratio))\n\nchart = (\n    alt.layer(\n        wave_bands,\n        change_flows,\n        stable_flows,\n        nodes_layer,\n        count_labels,\n        left_labels,\n        right_labels,\n        wave_headers,\n        trend_ann,\n    )\n    .properties(\n        width=620,\n        height=320,\n        background=PAGE_BG,\n        title=alt.Title(\n            text=title_str,\n            subtitle=\"Employee Engagement Survey — 1,000 Staff Quarterly Sentiment Tracking\",\n            fontSize=title_fs,\n            subtitleFontSize=10,\n            subtitleColor=INK_MUTED,\n            anchor=\"middle\",\n            color=INK,\n            offset=8,\n        ),\n    )\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n    .interactive()\n)\n\n# Save — canvas hard contract: 3200 × 1800 (landscape)\nTW, TH = 3200, 1800\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}