{"spec_id":"alluvial-opinion-flow","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nalluvial-opinion-flow: Opinion Flow Diagram\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 85/100 | Updated: 2026-05-30\n\"\"\"\n\n# Ensure we import the installed pygal package, not this file\nimport importlib.util\nimport os\nimport sys\nimport xml.etree.ElementTree as ET\n\nimport cairosvg\nimport numpy as np\n\n\npygal_spec = importlib.util.find_spec(\"pygal\")\nif pygal_spec and pygal_spec.origin != __file__:\n    import pygal\n    from pygal.style import Style\nelse:\n    # Fallback: remove current directory from path temporarily\n    cwd = os.getcwd()\n    sys.path = [p for p in sys.path if os.path.abspath(p) != cwd]\n    try:\n        import pygal\n        from pygal.style import Style\n    finally:\n        sys.path.insert(0, cwd)\n\n# Theme-adaptive tokens from the Imprint palette system\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_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint categorical palette — canonical hybrid-v3 sort order\nIMPRINT_PALETTE = (\n    \"#009E73\",  # green    — Strongly Favor (positive semantic anchor)\n    \"#C475FD\",  # lavender — Favor\n    \"#4467A3\",  # blue     — Neutral\n    \"#BD8233\",  # ochre    — Oppose\n    \"#AE3030\",  # matte red — Strongly Oppose (negative semantic anchor)\n    \"#2ABCCD\",\n    \"#954477\",\n    \"#99B314\",\n)\n\nnp.random.seed(42)\n\n# Survey scenario: Renewable Energy Policy — 1,000 respondents across 4 quarters\nwaves = [\"Q1 2024\", \"Q2 2024\", \"Q3 2024\", \"Q4 2024\"]\ncategories = [\"Strongly Favor\", \"Favor\", \"Neutral\", \"Oppose\", \"Strongly Oppose\"]\ncat_colors = list(IMPRINT_PALETTE[:5])\n\n# Respondent counts per category at each wave\nrespondent_counts = np.array(\n    [\n        [180, 210, 250, 270],  # Strongly Favor\n        [250, 230, 220, 240],  # Favor\n        [280, 240, 180, 150],  # Neutral\n        [190, 200, 210, 200],  # Oppose\n        [100, 120, 140, 140],  # Strongly Oppose\n    ]\n)\n\n# Flow transitions between consecutive waves\nflows = [\n    # Wave 1 -> Wave 2\n    {\n        (\"Strongly Favor\", \"Strongly Favor\"): 150,\n        (\"Strongly Favor\", \"Favor\"): 25,\n        (\"Strongly Favor\", \"Neutral\"): 5,\n        (\"Favor\", \"Strongly Favor\"): 40,\n        (\"Favor\", \"Favor\"): 170,\n        (\"Favor\", \"Neutral\"): 30,\n        (\"Favor\", \"Oppose\"): 10,\n        (\"Neutral\", \"Strongly Favor\"): 10,\n        (\"Neutral\", \"Favor\"): 25,\n        (\"Neutral\", \"Neutral\"): 190,\n        (\"Neutral\", \"Oppose\"): 45,\n        (\"Neutral\", \"Strongly Oppose\"): 10,\n        (\"Oppose\", \"Favor\"): 10,\n        (\"Oppose\", \"Neutral\"): 15,\n        (\"Oppose\", \"Oppose\"): 135,\n        (\"Oppose\", \"Strongly Oppose\"): 30,\n        (\"Strongly Oppose\", \"Neutral\"): 5,\n        (\"Strongly Oppose\", \"Oppose\"): 10,\n        (\"Strongly Oppose\", \"Strongly Oppose\"): 85,\n    },\n    # Wave 2 -> Wave 3\n    {\n        (\"Strongly Favor\", \"Strongly Favor\"): 180,\n        (\"Strongly Favor\", \"Favor\"): 20,\n        (\"Strongly Favor\", \"Neutral\"): 10,\n        (\"Favor\", \"Strongly Favor\"): 50,\n        (\"Favor\", \"Favor\"): 150,\n        (\"Favor\", \"Neutral\"): 20,\n        (\"Favor\", \"Oppose\"): 10,\n        (\"Neutral\", \"Strongly Favor\"): 10,\n        (\"Neutral\", \"Favor\"): 40,\n        (\"Neutral\", \"Neutral\"): 140,\n        (\"Neutral\", \"Oppose\"): 40,\n        (\"Neutral\", \"Strongly Oppose\"): 10,\n        (\"Oppose\", \"Favor\"): 10,\n        (\"Oppose\", \"Neutral\"): 10,\n        (\"Oppose\", \"Oppose\"): 150,\n        (\"Oppose\", \"Strongly Oppose\"): 30,\n        (\"Strongly Oppose\", \"Oppose\"): 10,\n        (\"Strongly Oppose\", \"Strongly Oppose\"): 110,\n    },\n    # Wave 3 -> Wave 4\n    {\n        (\"Strongly Favor\", \"Strongly Favor\"): 220,\n        (\"Strongly Favor\", \"Favor\"): 20,\n        (\"Strongly Favor\", \"Neutral\"): 10,\n        (\"Favor\", \"Strongly Favor\"): 30,\n        (\"Favor\", \"Favor\"): 170,\n        (\"Favor\", \"Neutral\"): 15,\n        (\"Favor\", \"Oppose\"): 5,\n        (\"Neutral\", \"Strongly Favor\"): 10,\n        (\"Neutral\", \"Favor\"): 40,\n        (\"Neutral\", \"Neutral\"): 110,\n        (\"Neutral\", \"Oppose\"): 15,\n        (\"Neutral\", \"Strongly Oppose\"): 5,\n        (\"Oppose\", \"Favor\"): 10,\n        (\"Oppose\", \"Neutral\"): 15,\n        (\"Oppose\", \"Oppose\"): 165,\n        (\"Oppose\", \"Strongly Oppose\"): 20,\n        (\"Strongly Oppose\", \"Neutral\"): 5,\n        (\"Strongly Oppose\", \"Oppose\"): 15,\n        (\"Strongly Oppose\", \"Strongly Oppose\"): 120,\n    },\n]\n\n# Compute top cross-category flows for opacity highlighting\ncross_flows_list = []\nfor flow_dict in flows:\n    for (src, tgt), count in flow_dict.items():\n        if src != tgt:\n            cross_flows_list.append(((src, tgt), count))\ncross_flows_list.sort(key=lambda x: -x[1])\nhighlight_threshold = cross_flows_list[7][1] if len(cross_flows_list) > 7 else 0\n\n# Top cross-category flows for pill labels on the diagram\ntop_cross_flows = {}\nfor flow_idx, flow_dict in enumerate(flows):\n    for (src, tgt), count in flow_dict.items():\n        if src != tgt and count >= 40:\n            top_cross_flows[(flow_idx, src, tgt)] = count\n\n# Custom style with Imprint palette and theme-adaptive chrome — 3200×1800 sizing\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    opacity=\".85\",\n    opacity_hover=\".95\",\n    transition=\"200ms ease-in\",\n    colors=tuple(cat_colors),\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=30,\n    value_label_font_size=30,\n    stroke_width=2.5,\n    font_family=\"'DejaVu Sans', 'Segoe UI', sans-serif\",\n    label_font_family=\"'DejaVu Sans', 'Segoe UI', sans-serif\",\n    title_font_family=\"'DejaVu Sans', 'Segoe UI', sans-serif\",\n    legend_font_family=\"'DejaVu Sans', 'Segoe UI', sans-serif\",\n    value_font_family=\"'DejaVu Sans', 'Segoe UI', sans-serif\",\n    tooltip_font_size=28,\n    tooltip_font_family=\"'DejaVu Sans', 'Segoe UI', sans-serif\",\n)\n\n# StackedBar as alluvial node foundation — canonical 3200×1800 landscape canvas\nchart = pygal.StackedBar(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=\"alluvial-opinion-flow · python · pygal · anyplot.ai\",\n    x_title=\"Renewable Energy Policy Survey · 1,000 Respondents Tracked Quarterly\",\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=5,\n    legend_box_size=24,\n    show_y_guides=False,\n    show_x_guides=False,\n    show_y_labels=False,\n    print_values=True,\n    print_values_position=\"center\",\n    value_formatter=lambda x: f\"{int(x)}\",\n    x_label_rotation=0,\n    rounded_bars=5,\n    margin_bottom=10,\n    margin_top=10,\n    tooltip_fancy_mode=True,\n    js=[],\n)\nchart.x_labels = waves\n\nfor cat_idx, cat in enumerate(categories):\n    chart.add(cat, [{\"value\": int(v), \"label\": f\"{cat}: {int(v)} respondents\"} for v in respondent_counts[cat_idx]])\n\n# Parse SVG for structural post-processing (alluvial flows not natively supported by pygal)\nET.register_namespace(\"\", \"http://www.w3.org/2000/svg\")\nET.register_namespace(\"xlink\", \"http://www.w3.org/1999/xlink\")\nsvg_str = chart.render().decode(\"utf-8\")\nroot = ET.fromstring(svg_str)\nSVG = \"{http://www.w3.org/2000/svg}\"\n\n# Extract bar positions from rendered SVG structure\nbar_info = []  # (series_idx, bar_idx, x, y, w, h, center_x, rect_elem)\nfor g in root.iter(f\"{SVG}g\"):\n    cls = g.get(\"class\", \"\")\n    if \"serie-\" not in cls or \"series\" not in cls:\n        continue\n    series_idx = None\n    for part in cls.split():\n        if part.startswith(\"serie-\"):\n            series_idx = int(part[6:])\n            break\n    if series_idx is None:\n        continue\n    bars_group = None\n    for child in g:\n        if child.tag == f\"{SVG}g\" and child.get(\"class\", \"\") == \"bars\":\n            bars_group = child\n            break\n    if bars_group is None:\n        continue\n    bar_idx = 0\n    for bar_g in bars_group:\n        if bar_g.tag != f\"{SVG}g\":\n            continue\n        if \"bar\" not in bar_g.get(\"class\", \"\"):\n            continue\n        rect = bar_g.find(f\"{SVG}rect\")\n        cx_desc = None\n        for desc in bar_g.findall(f\"{SVG}desc\"):\n            if desc.get(\"class\") == \"x centered\":\n                cx_desc = desc\n                break\n        if rect is not None and cx_desc is not None:\n            x = float(rect.get(\"x\"))\n            y = float(rect.get(\"y\"))\n            w = float(rect.get(\"width\"))\n            h = float(rect.get(\"height\"))\n            cx = float(cx_desc.text)\n            bar_info.append((series_idx, bar_idx, x, y, w, h, cx, rect))\n        bar_idx += 1\n\n# Narrow bars to alluvial node columns with theme-adaptive separator strokes\nNODE_WIDTH = 130\nfor _si, _bi, _x, _y, _w, _h, cx, rect in bar_info:\n    new_x = cx - NODE_WIDTH / 2\n    rect.set(\"x\", f\"{new_x:.2f}\")\n    rect.set(\"width\", str(NODE_WIDTH))\n    rect.set(\"stroke\", PAGE_BG)\n    rect.set(\"stroke-width\", \"3\")\n\n# Build bar position lookup: (series_idx, wave_idx) -> (y_top, y_bottom, center_x)\nbar_positions = {}\nfor series_idx, bar_idx, _x, y, _w, h, cx, _rect in bar_info:\n    bar_positions[(series_idx, bar_idx)] = (y, y + h, cx)\n\ncat_to_series = {cat: idx for idx, cat in enumerate(categories)}\n\n# Collect wave column center-x values and vertical chart extent\nwave_cx = {}\nfor _series_idx, bar_idx, _x, _y, _w, _h, cx, _rect in bar_info:\n    if bar_idx not in wave_cx:\n        wave_cx[bar_idx] = cx\n\nall_y_top = min(y for _, _, _, y, _, h, _, _ in bar_info)\nall_y_bottom = max(y + h for _, _, _, y, _, h, _, _ in bar_info)\n\n# Locate the SVG plot group for flow and background insertion\nplot_group = None\nfirst_series_pos = 0\nfor g in root.iter(f\"{SVG}g\"):\n    cls = g.get(\"class\", \"\")\n    if cls == \"plot\":\n        for idx, child in enumerate(g):\n            if child.get(\"class\", \"\").startswith(\"series serie-0\"):\n                plot_group = g\n                first_series_pos = idx\n                break\n    if plot_group is not None:\n        break\n\n# Wave column background panels — subtle alternating shading, theme-adaptive\nPANEL_A = \"#F0EDE6\" if THEME == \"light\" else \"#242420\"\nPANEL_B = \"#E8E5DE\" if THEME == \"light\" else \"#2A2A26\"\nbg_group = ET.Element(f\"{SVG}g\")\nbg_group.set(\"id\", \"wave-backgrounds\")\npanel_padding = 25\nfor wi, cx in sorted(wave_cx.items()):\n    bg_rect = ET.SubElement(bg_group, f\"{SVG}rect\")\n    bg_rect.set(\"x\", f\"{cx - NODE_WIDTH / 2 - panel_padding:.1f}\")\n    bg_rect.set(\"y\", f\"{all_y_top - panel_padding:.1f}\")\n    bg_rect.set(\"width\", f\"{NODE_WIDTH + 2 * panel_padding}\")\n    bg_rect.set(\"height\", f\"{all_y_bottom - all_y_top + 2 * panel_padding:.1f}\")\n    bg_rect.set(\"rx\", \"8\")\n    bg_rect.set(\"ry\", \"8\")\n    bg_rect.set(\"fill\", PANEL_A if wi % 2 == 0 else PANEL_B)\n    bg_rect.set(\"fill-opacity\", \"0.65\")\n    bg_rect.set(\"stroke\", \"none\")\n\nif plot_group is not None:\n    plot_group.insert(first_series_pos, bg_group)\n    first_series_pos += 1\n\n# Build alluvial flow paths between consecutive wave columns\nflow_group = ET.Element(f\"{SVG}g\")\nflow_group.set(\"id\", \"alluvial-flows\")\n\nflow_label_positions = []\n\nfor flow_idx, flow_dict in enumerate(flows):\n    source_offsets = {}\n    target_offsets = {}\n    for cat_idx in range(len(categories)):\n        src_pos = bar_positions.get((cat_idx, flow_idx))\n        if src_pos:\n            source_offsets[cat_idx] = src_pos[0]\n        tgt_pos = bar_positions.get((cat_idx, flow_idx + 1))\n        if tgt_pos:\n            target_offsets[cat_idx] = tgt_pos[0]\n\n    for (src_cat, tgt_cat), count in sorted(flow_dict.items(), key=lambda x: -x[1]):\n        if count <= 0:\n            continue\n\n        src_idx = cat_to_series[src_cat]\n        tgt_idx = cat_to_series[tgt_cat]\n\n        src_bar = bar_positions.get((src_idx, flow_idx))\n        tgt_bar = bar_positions.get((tgt_idx, flow_idx + 1))\n        if not src_bar or not tgt_bar:\n            continue\n\n        src_total = respondent_counts[src_idx, flow_idx]\n        tgt_total = respondent_counts[tgt_idx, flow_idx + 1]\n        src_bar_h = src_bar[1] - src_bar[0]\n        tgt_bar_h = tgt_bar[1] - tgt_bar[0]\n\n        src_frac_h = (count / src_total) * src_bar_h\n        tgt_frac_h = (count / tgt_total) * tgt_bar_h\n\n        y0_top = source_offsets[src_idx]\n        y0_bottom = y0_top + src_frac_h\n        y1_top = target_offsets[tgt_idx]\n        y1_bottom = y1_top + tgt_frac_h\n\n        band_x0 = src_bar[2] + NODE_WIDTH / 2\n        band_x1 = tgt_bar[2] - NODE_WIDTH / 2\n        cx0 = band_x0 + 0.4 * (band_x1 - band_x0)\n        cx1 = band_x0 + 0.6 * (band_x1 - band_x0)\n\n        is_stable = src_cat == tgt_cat\n        if is_stable:\n            opacity = 0.55\n        elif count >= highlight_threshold:\n            opacity = 0.45\n        else:\n            # Raise minimum opacity so small flows (5-10 respondents) remain perceptible\n            opacity = max(0.35, 0.25 + count / 60.0)\n\n        path_d = (\n            f\"M {band_x0:.1f},{y0_top:.1f} \"\n            f\"C {cx0:.1f},{y0_top:.1f} {cx1:.1f},{y1_top:.1f} {band_x1:.1f},{y1_top:.1f} \"\n            f\"L {band_x1:.1f},{y1_bottom:.1f} \"\n            f\"C {cx1:.1f},{y1_bottom:.1f} {cx0:.1f},{y0_bottom:.1f} {band_x0:.1f},{y0_bottom:.1f} \"\n            f\"Z\"\n        )\n\n        path_elem = ET.SubElement(flow_group, f\"{SVG}path\")\n        path_elem.set(\"d\", path_d)\n        path_elem.set(\"fill\", cat_colors[src_idx])\n        path_elem.set(\"fill-opacity\", str(round(opacity, 2)))\n        path_elem.set(\"stroke\", \"none\")\n\n        if (flow_idx, src_cat, tgt_cat) in top_cross_flows:\n            mid_x = (band_x0 + band_x1) / 2\n            mid_y = (y0_top + y0_bottom + y1_top + y1_bottom) / 4\n            flow_label_positions.append((mid_x, mid_y, count, src_idx))\n\n        source_offsets[src_idx] = y0_bottom\n        target_offsets[tgt_idx] = y1_bottom\n\n# Insert flows before series groups so node bars render on top\nif plot_group is not None:\n    plot_group.insert(first_series_pos, flow_group)\n\n# Pill labels on largest cross-category transitions for data storytelling\nlabel_group = ET.SubElement(root, f\"{SVG}g\")\nlabel_group.set(\"id\", \"flow-labels\")\nfor mid_x, mid_y, count, src_idx in flow_label_positions:\n    pill_w, pill_h = 80, 38\n    pill = ET.SubElement(label_group, f\"{SVG}rect\")\n    pill.set(\"x\", f\"{mid_x - pill_w / 2:.1f}\")\n    pill.set(\"y\", f\"{mid_y - pill_h / 2:.1f}\")\n    pill.set(\"width\", str(pill_w))\n    pill.set(\"height\", str(pill_h))\n    pill.set(\"rx\", \"8\")\n    pill.set(\"ry\", \"8\")\n    pill.set(\"fill\", ELEVATED_BG)\n    pill.set(\"fill-opacity\", \"0.92\")\n    pill.set(\"stroke\", cat_colors[src_idx])\n    pill.set(\"stroke-width\", \"1.5\")\n\n    label = ET.SubElement(label_group, f\"{SVG}text\")\n    label.set(\"x\", f\"{mid_x:.1f}\")\n    label.set(\"y\", f\"{mid_y + 8:.1f}\")\n    label.set(\"text-anchor\", \"middle\")\n    label.set(\"font-size\", \"34\")\n    label.set(\"font-weight\", \"bold\")\n    label.set(\"font-family\", \"'DejaVu Sans', 'Segoe UI', sans-serif\")\n    label.set(\"fill\", cat_colors[src_idx])\n    label.text = str(count)\n\n# Subtitle annotation highlighting the polarization data story\nanno_group = ET.SubElement(root, f\"{SVG}g\")\nanno_group.set(\"id\", \"annotations\")\nannotation = ET.SubElement(anno_group, f\"{SVG}text\")\nannotation.set(\"x\", \"1600\")\nannotation.set(\"y\", \"92\")\nannotation.set(\"text-anchor\", \"middle\")\nannotation.set(\"font-size\", \"34\")\nannotation.set(\"font-style\", \"italic\")\nannotation.set(\"font-family\", \"'DejaVu Sans', 'Segoe UI', sans-serif\")\nannotation.set(\"fill\", INK_MUTED)\nannotation.text = \"Solid = stable opinion · Faded = changed · Polarization: Neutral 280→150\"\n\n# Serialize modified SVG\nsvg_str = ET.tostring(root, encoding=\"unicode\")\n\n# Save PNG — canonical 3200×1800 via cairosvg (1:1 from SVG viewport)\ncairosvg.svg2png(bytestring=svg_str.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\")\n\n# Save interactive HTML with embedded SVG\nhtml_content = f\"\"\"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"utf-8\">\n    <title>alluvial-opinion-flow · python · pygal · anyplot.ai</title>\n    <style>\n        body {{ margin: 0; padding: 20px; background: {PAGE_BG}; font-family: sans-serif; }}\n        .container {{ max-width: 100%; margin: 0 auto; }}\n        svg {{ width: 100%; height: auto; }}\n    </style>\n</head>\n<body>\n    <div class=\"container\">\n        {svg_str}\n    </div>\n</body>\n</html>\"\"\"\n\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(html_content)\n"}