{"spec_id":"parallel-categories-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nparallel-categories-basic: Basic Parallel Categories Plot\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 82/100 | Updated: 2026-05-13\n\"\"\"\n\nimport os\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\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# Set seed for reproducibility\nnp.random.seed(42)\n\n# Okabe-Ito palette (first series is always #009E73)\nIMPRINT = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\")\n\n# Data: Product journey from category through distribution channel to outcome\n# Changed from Online/Store/Mobile to Distribution Channels (Direct/Wholesale/Retail)\ncategories = [\"Category\", \"Distribution\", \"Payment\", \"Outcome\"]\n\ndimension_values = {\n    \"Category\": [\"Electronics\", \"Clothing\", \"Home & Garden\", \"Sports\"],\n    \"Distribution\": [\"Direct\", \"Wholesale\", \"Retail\"],\n    \"Payment\": [\"Credit Card\", \"Debit Card\", \"Digital Wallet\"],\n    \"Outcome\": [\"Completed\", \"Returned\", \"Cancelled\"],\n}\n\n# Generate realistic distribution channel data\nnp.random.seed(42)\nbase_counts = {\n    # Electronics - balanced across channels\n    (\"Electronics\", \"Direct\", \"Credit Card\", \"Completed\"): 450,\n    (\"Electronics\", \"Direct\", \"Credit Card\", \"Returned\"): 85,\n    (\"Electronics\", \"Direct\", \"Digital Wallet\", \"Completed\"): 280,\n    (\"Electronics\", \"Direct\", \"Digital Wallet\", \"Returned\"): 45,\n    (\"Electronics\", \"Wholesale\", \"Credit Card\", \"Completed\"): 320,\n    (\"Electronics\", \"Wholesale\", \"Debit Card\", \"Completed\"): 180,\n    (\"Electronics\", \"Retail\", \"Digital Wallet\", \"Completed\"): 220,\n    (\"Electronics\", \"Retail\", \"Digital Wallet\", \"Cancelled\"): 75,\n    (\"Electronics\", \"Direct\", \"Credit Card\", \"Cancelled\"): 40,\n    # Clothing - higher retail presence\n    (\"Clothing\", \"Direct\", \"Credit Card\", \"Completed\"): 320,\n    (\"Clothing\", \"Direct\", \"Credit Card\", \"Returned\"): 95,\n    (\"Clothing\", \"Direct\", \"Debit Card\", \"Completed\"): 150,\n    (\"Clothing\", \"Direct\", \"Debit Card\", \"Returned\"): 50,\n    (\"Clothing\", \"Retail\", \"Credit Card\", \"Completed\"): 480,\n    (\"Clothing\", \"Retail\", \"Debit Card\", \"Completed\"): 290,\n    (\"Clothing\", \"Retail\", \"Debit Card\", \"Returned\"): 45,\n    (\"Clothing\", \"Wholesale\", \"Digital Wallet\", \"Completed\"): 200,\n    (\"Clothing\", \"Wholesale\", \"Credit Card\", \"Completed\"): 155,\n    (\"Clothing\", \"Direct\", \"Digital Wallet\", \"Cancelled\"): 35,\n    # Home & Garden - more wholesale/retail\n    (\"Home & Garden\", \"Retail\", \"Credit Card\", \"Completed\"): 420,\n    (\"Home & Garden\", \"Retail\", \"Debit Card\", \"Completed\"): 320,\n    (\"Home & Garden\", \"Retail\", \"Debit Card\", \"Returned\"): 60,\n    (\"Home & Garden\", \"Direct\", \"Credit Card\", \"Completed\"): 190,\n    (\"Home & Garden\", \"Direct\", \"Credit Card\", \"Returned\"): 35,\n    (\"Home & Garden\", \"Direct\", \"Digital Wallet\", \"Completed\"): 120,\n    (\"Home & Garden\", \"Wholesale\", \"Digital Wallet\", \"Completed\"): 110,\n    # Sports - strong direct/retail mix\n    (\"Sports\", \"Direct\", \"Digital Wallet\", \"Completed\"): 280,\n    (\"Sports\", \"Direct\", \"Credit Card\", \"Completed\"): 200,\n    (\"Sports\", \"Retail\", \"Credit Card\", \"Completed\"): 310,\n    (\"Sports\", \"Retail\", \"Debit Card\", \"Completed\"): 195,\n    (\"Sports\", \"Wholesale\", \"Credit Card\", \"Completed\"): 240,\n    (\"Sports\", \"Wholesale\", \"Debit Card\", \"Completed\"): 180,\n    (\"Sports\", \"Wholesale\", \"Debit Card\", \"Returned\"): 35,\n}\n\n# Category colors use Okabe-Ito palette (first series is brand green #009E73)\ncategory_colors = {\n    \"Electronics\": IMPRINT[0],  # #009E73 (bluish green - brand)\n    \"Clothing\": IMPRINT[1],  # #C475FD (vermillion)\n    \"Home & Garden\": IMPRINT[2],  # #4467A3 (blue)\n    \"Sports\": IMPRINT[3],  # #BD8233 (reddish purple)\n}\n\n# Secondary colors for middle dimensions - distinct but complementary\ndimension_colors = {\n    \"Distribution\": {\"Direct\": \"#7B68EE\", \"Wholesale\": \"#20B2AA\", \"Retail\": \"#FF69B4\"},\n    \"Payment\": {\"Credit Card\": \"#9370DB\", \"Debit Card\": \"#3CB371\", \"Digital Wallet\": \"#FF6347\"},\n    \"Outcome\": {\"Completed\": \"#32CD32\", \"Returned\": \"#FFA500\", \"Cancelled\": \"#DC143C\"},\n}\n\n# Custom style for pygal with theme-adaptive colors\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    title_font_size=72,\n)\n\n# Create minimal chart for title rendering\nchart = pygal.XY(\n    width=4800,\n    height=2700,\n    style=custom_style,\n    title=\"parallel-categories-basic · pygal · pyplots.ai\",\n    show_legend=False,\n    show_x_guides=False,\n    show_y_guides=False,\n    show_x_labels=False,\n    show_y_labels=False,\n    dots_size=0,\n    stroke=False,\n    range=(0, 100),\n    xrange=(0, 100),\n)\n\n# Add empty data to avoid \"No data\" message\nchart.add(\"\", [(50, 50)])\n\n# Render base SVG\nbase_svg = chart.render().decode(\"utf-8\")\n\n# SVG coordinate mapping\nmargin_left = 450\nmargin_right = 350\nmargin_top = 350\nmargin_bottom = 250\nchart_width = 4800 - margin_left - margin_right\nchart_height = 2700 - margin_top - margin_bottom\n\n# Calculate positions for each dimension axis\nn_dims = len(categories)\nx_positions = [margin_left + i * chart_width / (n_dims - 1) for i in range(n_dims)]\nbar_width = 120\ngap_ratio = 0.05\n\n# Calculate totals for each category in each dimension\ndim_totals = {}\nfor dim_idx, dim_name in enumerate(categories):\n    dim_totals[dim_idx] = {}\n    for cat in dimension_values[dim_name]:\n        total = 0\n        for path, count in base_counts.items():\n            if path[dim_idx] == cat:\n                total += count\n        dim_totals[dim_idx][cat] = total\n\n# Calculate node positions\nnode_positions = {}\n\nfor dim_idx, dim_name in enumerate(categories):\n    x = x_positions[dim_idx]\n    dim_total = sum(dim_totals[dim_idx].values())\n    total_gap = gap_ratio * chart_height\n    available_height = chart_height - total_gap\n    n_cats = len(dimension_values[dim_name])\n    gap_size = total_gap / max(1, n_cats - 1) if n_cats > 1 else 0\n\n    y_top = margin_top\n    for _cat_idx, cat in enumerate(dimension_values[dim_name]):\n        height = (dim_totals[dim_idx][cat] / dim_total) * available_height if dim_total > 0 else 0\n        y_bottom = y_top + height\n        node_positions[(dim_idx, cat)] = (y_top, y_bottom, x)\n        y_top = y_bottom + gap_size\n\n# Build SVG elements\nparallel_svg = '<g id=\"parallel-categories\">'\n\n# Draw nodes (category bars) for each dimension\nfor dim_idx, dim_name in enumerate(categories):\n    x = x_positions[dim_idx]\n\n    for cat in dimension_values[dim_name]:\n        y_top, y_bottom, _ = node_positions[(dim_idx, cat)]\n        height = y_bottom - y_top\n\n        if height < 1:\n            continue\n\n        # Color based on dimension\n        if dim_idx == 0:\n            fill_color = category_colors[cat]\n        else:\n            fill_color = dimension_colors[dim_name][cat]\n\n        parallel_svg += f'''\n    <rect x=\"{x - bar_width / 2:.0f}\" y=\"{y_top:.0f}\" width=\"{bar_width:.0f}\" height=\"{height:.0f}\"\n          fill=\"{fill_color}\" stroke=\"white\" stroke-width=\"2\" opacity=\"0.9\"/>'''\n\n    # Add dimension label at top\n    dim_name_escaped = dim_name.replace(\"&\", \"&amp;\")\n    parallel_svg += f'''\n    <text x=\"{x:.0f}\" y=\"{margin_top - 60:.0f}\" text-anchor=\"middle\"\n          font-size=\"48\" font-weight=\"bold\" font-family=\"DejaVu Sans, sans-serif\"\n          fill=\"{INK}\">{dim_name_escaped}</text>'''\n\n# Add category labels for each dimension with improved legibility\nfor dim_idx, dim_name in enumerate(categories):\n    x = x_positions[dim_idx]\n    for cat in dimension_values[dim_name]:\n        y_top, y_bottom, _ = node_positions[(dim_idx, cat)]\n        y_center = (y_top + y_bottom) / 2\n        height = y_bottom - y_top\n\n        # Position label based on dimension\n        if dim_idx == 0:  # Left side - outside bar\n            label_x = x - bar_width / 2 - 20\n            anchor = \"end\"\n        elif dim_idx == n_dims - 1:  # Right side - outside bar\n            label_x = x + bar_width / 2 + 20\n            anchor = \"start\"\n        else:  # Middle dimensions - below the bar\n            label_x = x\n            anchor = \"middle\"\n\n        # Use consistent readable font size (minimum 32px for better legibility)\n        font_size = max(32, min(40, height * 0.35))\n\n        # Escape special characters\n        cat_escaped = cat.replace(\"&\", \"&amp;\")\n\n        if dim_idx in [0, n_dims - 1]:\n            # Side labels - next to bars\n            parallel_svg += f'''\n    <text x=\"{label_x:.0f}\" y=\"{y_center:.0f}\" text-anchor=\"{anchor}\"\n          font-size=\"{font_size:.0f}\" font-family=\"DejaVu Sans, sans-serif\"\n          fill=\"{INK}\" dominant-baseline=\"middle\">{cat_escaped}</text>'''\n        else:\n            # Middle dimension labels - below each bar segment\n            label_y = y_bottom + 40\n            parallel_svg += f'''\n    <text x=\"{label_x:.0f}\" y=\"{label_y:.0f}\" text-anchor=\"{anchor}\"\n          font-size=\"{font_size:.0f}\" font-family=\"DejaVu Sans, sans-serif\"\n          fill=\"{INK}\">{cat_escaped}</text>'''\n\n# Calculate flow offsets for drawing ribbons\nsource_offsets = {}\ntarget_offsets = {}\n\nfor dim_idx in range(n_dims):\n    for cat in dimension_values[categories[dim_idx]]:\n        y_top, y_bottom, _ = node_positions[(dim_idx, cat)]\n        source_offsets[(dim_idx, cat)] = y_top\n        target_offsets[(dim_idx, cat)] = y_top\n\n# Draw flows between consecutive dimensions\nfor dim_idx in range(n_dims - 1):\n    dim1_name = categories[dim_idx]\n    dim2_name = categories[dim_idx + 1]\n    x0 = x_positions[dim_idx]\n    x1 = x_positions[dim_idx + 1]\n\n    # Calculate total for normalization\n    dim1_total = sum(dim_totals[dim_idx].values())\n    dim2_total = sum(dim_totals[dim_idx + 1].values())\n\n    # Aggregate flows between consecutive dimensions\n    flow_aggregates = {}\n    for path, count in base_counts.items():\n        key = (path[dim_idx], path[dim_idx + 1], path[0])\n        if key not in flow_aggregates:\n            flow_aggregates[key] = 0\n        flow_aggregates[key] += count\n\n    # Sort flows for consistent drawing\n    sorted_flows = sorted(\n        flow_aggregates.items(),\n        key=lambda x: (dimension_values[dim1_name].index(x[0][0]), dimension_values[dim2_name].index(x[0][1])),\n    )\n\n    # Draw each flow\n    for (source_cat, target_cat, first_cat), flow_value in sorted_flows:\n        if flow_value <= 0:\n            continue\n\n        source_y_top, source_y_bottom, _ = node_positions[(dim_idx, source_cat)]\n        target_y_top, target_y_bottom, _ = node_positions[(dim_idx + 1, target_cat)]\n\n        source_dim_total = dim_totals[dim_idx][source_cat]\n        target_dim_total = dim_totals[dim_idx + 1][target_cat]\n\n        source_height = (\n            (flow_value / source_dim_total) * (source_y_bottom - source_y_top) if source_dim_total > 0 else 0\n        )\n        target_height = (\n            (flow_value / target_dim_total) * (target_y_bottom - target_y_top) if target_dim_total > 0 else 0\n        )\n\n        # Get current positions\n        y0_top = source_offsets[(dim_idx, source_cat)]\n        y0_bottom = y0_top + source_height\n        y1_top = target_offsets[(dim_idx + 1, target_cat)]\n        y1_bottom = y1_top + target_height\n\n        # Bezier curve control points\n        band_x0 = x0 + bar_width / 2\n        band_x1 = x1 - bar_width / 2\n        cx0 = band_x0 + 0.4 * (band_x1 - band_x0)\n        cx1 = band_x0 + 0.6 * (band_x1 - band_x0)\n\n        # Create path for the curved ribbon\n        path_d = (\n            f\"M {band_x0:.0f},{y0_top:.0f} \"\n            f\"C {cx0:.0f},{y0_top:.0f} {cx1:.0f},{y1_top:.0f} {band_x1:.0f},{y1_top:.0f} \"\n            f\"L {band_x1:.0f},{y1_bottom:.0f} \"\n            f\"C {cx1:.0f},{y1_bottom:.0f} {cx0:.0f},{y0_bottom:.0f} {band_x0:.0f},{y0_bottom:.0f} \"\n            f\"Z\"\n        )\n\n        # Color by first category\n        ribbon_color = category_colors[first_cat]\n\n        parallel_svg += f'''\n    <path d=\"{path_d}\" fill=\"{ribbon_color}\" fill-opacity=\"0.4\" stroke=\"none\"/>'''\n\n        # Update offsets\n        source_offsets[(dim_idx, source_cat)] = y0_bottom\n        target_offsets[(dim_idx + 1, target_cat)] = y1_bottom\n\n# Add legend for categories\nlegend_x = margin_left\nlegend_y = chart_height + margin_top + 100\nlegend_spacing = 400\n\nfor idx, (cat, color) in enumerate(category_colors.items()):\n    lx = legend_x + idx * legend_spacing\n    cat_escaped = cat.replace(\"&\", \"&amp;\")\n    parallel_svg += f'''\n    <rect x=\"{lx:.0f}\" y=\"{legend_y:.0f}\" width=\"50\" height=\"50\" fill=\"{color}\" stroke=\"none\"/>\n    <text x=\"{lx + 70:.0f}\" y=\"{legend_y + 38:.0f}\" text-anchor=\"start\"\n          font-size=\"40\" font-family=\"DejaVu Sans, sans-serif\" fill=\"{INK}\">{cat_escaped}</text>'''\n\n# Add subtitle\nparallel_svg += f'''\n    <text x=\"2400\" y=\"{chart_height + margin_top + 200:.0f}\" text-anchor=\"middle\"\n          font-size=\"36\" font-style=\"italic\" font-family=\"DejaVu Sans, sans-serif\"\n          fill=\"{INK_SOFT}\">Product Category Distribution Flows</text>'''\n\nparallel_svg += \"\\n</g>\"\n\n# Insert elements before closing </svg> tag\nsvg_with_parallel = base_svg.replace(\"</svg>\", f\"{parallel_svg}\\n</svg>\")\n\n# Save SVG\nwith open(f\"plot-{THEME}.svg\", \"w\") as f:\n    f.write(svg_with_parallel)\n\n# Render to PNG\ncairosvg.svg2png(bytestring=svg_with_parallel.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\")\n\n# Save HTML for interactive version\nwith open(f\"plot-{THEME}.html\", \"w\") as f:\n    f.write(f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <title>parallel-categories-basic · pygal · pyplots.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        object {{ width: 100%; height: auto; }}\n    </style>\n</head>\n<body>\n    <div class=\"container\">\n        <object type=\"image/svg+xml\" data=\"plot-{THEME}.svg\">\n            Parallel categories diagram not supported\n        </object>\n    </div>\n</body>\n</html>\"\"\")\n"}