{"spec_id":"parallel-categories-basic","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\nparallel-categories-basic: Basic Parallel Categories Plot\nLibrary: plotnine 0.15.4 | Python 3.13.13\nQuality: 84/100 | Updated: 2026-05-13\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent current directory from shadowing the plotnine package\nsys.path = [p for p in sys.path if not p.endswith(\"implementations\") and not p.endswith(\"python\")]\n\nimport numpy as np\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    annotate,\n    element_blank,\n    element_text,\n    geom_polygon,\n    geom_rect,\n    geom_text,\n    ggplot,\n    labs,\n    scale_fill_manual,\n    theme,\n    theme_minimal,\n)\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\"\n\n# Okabe-Ito palette for categorical data\nIMPRINT = [\n    \"#009E73\",  # Brand green - first series\n    \"#C475FD\",  # Vermillion\n    \"#4467A3\",  # Blue\n    \"#BD8233\",  # Reddish purple\n    \"#AE3030\",  # Orange\n    \"#2ABCCD\",  # Sky blue\n    \"#954477\",  # Yellow\n]\n\n# Data - Customer journey data with multiple categorical dimensions\nnp.random.seed(42)\n\n# Define category combinations and realistic counts\npath_data = [\n    # Channel -> Product Category -> Customer Type -> Outcome\n    (\"Online\", \"Electronics\", \"New\", \"Purchased\", 145),\n    (\"Online\", \"Electronics\", \"New\", \"Abandoned\", 98),\n    (\"Online\", \"Electronics\", \"Returning\", \"Purchased\", 187),\n    (\"Online\", \"Electronics\", \"Returning\", \"Abandoned\", 42),\n    (\"Online\", \"Clothing\", \"New\", \"Purchased\", 112),\n    (\"Online\", \"Clothing\", \"New\", \"Abandoned\", 76),\n    (\"Online\", \"Clothing\", \"Returning\", \"Purchased\", 156),\n    (\"Online\", \"Clothing\", \"Returning\", \"Abandoned\", 38),\n    (\"Online\", \"Home\", \"New\", \"Purchased\", 67),\n    (\"Online\", \"Home\", \"New\", \"Abandoned\", 54),\n    (\"Online\", \"Home\", \"Returning\", \"Purchased\", 89),\n    (\"Online\", \"Home\", \"Returning\", \"Abandoned\", 23),\n    (\"Store\", \"Electronics\", \"New\", \"Purchased\", 78),\n    (\"Store\", \"Electronics\", \"New\", \"Abandoned\", 32),\n    (\"Store\", \"Electronics\", \"Returning\", \"Purchased\", 124),\n    (\"Store\", \"Electronics\", \"Returning\", \"Abandoned\", 18),\n    (\"Store\", \"Clothing\", \"New\", \"Purchased\", 95),\n    (\"Store\", \"Clothing\", \"New\", \"Abandoned\", 28),\n    (\"Store\", \"Clothing\", \"Returning\", \"Purchased\", 142),\n    (\"Store\", \"Clothing\", \"Returning\", \"Abandoned\", 15),\n    (\"Store\", \"Home\", \"New\", \"Purchased\", 56),\n    (\"Store\", \"Home\", \"New\", \"Abandoned\", 21),\n    (\"Store\", \"Home\", \"Returning\", \"Purchased\", 78),\n    (\"Store\", \"Home\", \"Returning\", \"Abandoned\", 12),\n    (\"Mobile\", \"Electronics\", \"New\", \"Purchased\", 89),\n    (\"Mobile\", \"Electronics\", \"New\", \"Abandoned\", 112),\n    (\"Mobile\", \"Electronics\", \"Returning\", \"Purchased\", 134),\n    (\"Mobile\", \"Electronics\", \"Returning\", \"Abandoned\", 67),\n    (\"Mobile\", \"Clothing\", \"New\", \"Purchased\", 76),\n    (\"Mobile\", \"Clothing\", \"New\", \"Abandoned\", 94),\n    (\"Mobile\", \"Clothing\", \"Returning\", \"Purchased\", 118),\n    (\"Mobile\", \"Clothing\", \"Returning\", \"Abandoned\", 52),\n    (\"Mobile\", \"Home\", \"New\", \"Purchased\", 45),\n    (\"Mobile\", \"Home\", \"New\", \"Abandoned\", 58),\n    (\"Mobile\", \"Home\", \"Returning\", \"Purchased\", 67),\n    (\"Mobile\", \"Home\", \"Returning\", \"Abandoned\", 34),\n]\n\npath_counts = pd.DataFrame(path_data, columns=[\"channel\", \"product\", \"customer_type\", \"outcome\", \"count\"])\n\n# Define dimensions and their category orders\ndimensions = [\n    {\"name\": \"channel\", \"label\": \"Channel\", \"categories\": [\"Online\", \"Store\", \"Mobile\"]},\n    {\"name\": \"product\", \"label\": \"Product\", \"categories\": [\"Electronics\", \"Clothing\", \"Home\"]},\n    {\"name\": \"customer_type\", \"label\": \"Customer\", \"categories\": [\"Returning\", \"New\"]},\n    {\"name\": \"outcome\", \"label\": \"Outcome\", \"categories\": [\"Purchased\", \"Abandoned\"]},\n]\n\n# Color by outcome - using Okabe-Ito palette\noutcome_colors = {\n    \"Purchased\": IMPRINT[0],  # Brand green\n    \"Abandoned\": IMPRINT[2],  # Blue\n}\n\n# Layout parameters\nn_dims = len(dimensions)\nx_positions = np.linspace(0.1, 0.9, n_dims)\nnode_width = 0.04\nnode_gap = 0.03\ntotal_height = 0.82\ny_start = 0.92\n\n# Calculate node positions for each dimension\nnode_positions = {}\nfor dim_idx, dim in enumerate(dimensions):\n    x_pos = x_positions[dim_idx]\n    categories = dim[\"categories\"]\n    col_name = dim[\"name\"]\n\n    # Calculate totals for this dimension\n    totals = path_counts.groupby(col_name)[\"count\"].sum()\n    grand_total = totals.sum()\n    current_y = y_start\n\n    for cat in categories:\n        count = totals.get(cat, 0)\n        height = (count / grand_total) * total_height if grand_total > 0 else 0\n\n        node_positions[(dim_idx, cat)] = {\n            \"x\": x_pos,\n            \"y_top\": current_y,\n            \"y_bottom\": current_y - height,\n            \"height\": height,\n            \"count\": count,\n            \"flow_offset_out\": 0,\n            \"flow_offset_in\": 0,\n        }\n        current_y = current_y - height - node_gap\n\n# Build node rectangles dataframe\nnode_data = []\nfor (dim_idx, cat), pos in node_positions.items():\n    node_data.append(\n        {\n            \"dim_idx\": dim_idx,\n            \"category\": cat,\n            \"xmin\": pos[\"x\"] - node_width / 2,\n            \"xmax\": pos[\"x\"] + node_width / 2,\n            \"ymin\": pos[\"y_bottom\"],\n            \"ymax\": pos[\"y_top\"],\n            \"label_y\": (pos[\"y_top\"] + pos[\"y_bottom\"]) / 2,\n            \"count\": pos[\"count\"],\n            \"display_label\": str(cat),\n            \"fill_color\": INK_SOFT,\n        }\n    )\nnodes_df = pd.DataFrame(node_data)\n\n# Build flow polygons between adjacent dimensions\nflow_polygons = []\nflow_id_counter = 0\n\nfor _, path_row in path_counts.iterrows():\n    path_values = [path_row[\"channel\"], path_row[\"product\"], path_row[\"customer_type\"], path_row[\"outcome\"]]\n    count = path_row[\"count\"]\n    outcome = path_row[\"outcome\"]\n\n    # Draw flows between each adjacent pair of dimensions\n    for dim_idx in range(n_dims - 1):\n        from_cat = path_values[dim_idx]\n        to_cat = path_values[dim_idx + 1]\n\n        src_pos = node_positions[(dim_idx, from_cat)]\n        tgt_pos = node_positions[(dim_idx + 1, to_cat)]\n\n        # Calculate flow height proportional to count\n        src_total = sum(path_counts[path_counts[dimensions[dim_idx][\"name\"]] == from_cat][\"count\"])\n        flow_height_src = (count / src_total) * src_pos[\"height\"] if src_total > 0 else 0\n\n        tgt_total = sum(path_counts[path_counts[dimensions[dim_idx + 1][\"name\"]] == to_cat][\"count\"])\n        flow_height_tgt = (count / tgt_total) * tgt_pos[\"height\"] if tgt_total > 0 else 0\n\n        # Source connection point (right side of node)\n        src_y_top = src_pos[\"y_top\"] - src_pos[\"flow_offset_out\"]\n        src_y_bottom = src_y_top - flow_height_src\n        src_pos[\"flow_offset_out\"] += flow_height_src\n\n        # Target connection point (left side of node)\n        tgt_y_top = tgt_pos[\"y_top\"] - tgt_pos[\"flow_offset_in\"]\n        tgt_y_bottom = tgt_y_top - flow_height_tgt\n        tgt_pos[\"flow_offset_in\"] += flow_height_tgt\n\n        # Create curved flow polygon using cubic interpolation\n        flow_x_left = x_positions[dim_idx] + node_width / 2\n        flow_x_right = x_positions[dim_idx + 1] - node_width / 2\n        n_points = 30\n\n        t_param = np.linspace(0, 1, n_points)\n        # Smooth cubic easing for natural flow appearance\n        x_top = flow_x_left + (flow_x_right - flow_x_left) * t_param\n        y_top = src_y_top + (tgt_y_top - src_y_top) * (3 * t_param**2 - 2 * t_param**3)\n\n        x_bottom = flow_x_right + (flow_x_left - flow_x_right) * t_param\n        y_bottom = tgt_y_bottom + (src_y_bottom - tgt_y_bottom) * (3 * t_param**2 - 2 * t_param**3)\n\n        # Combine into polygon\n        x_polygon = np.concatenate([x_top, x_bottom])\n        y_polygon = np.concatenate([y_top, y_bottom])\n\n        flow_id = f\"flow_{flow_id_counter}\"\n        flow_id_counter += 1\n\n        for i in range(len(x_polygon)):\n            flow_polygons.append({\"x\": x_polygon[i], \"y\": y_polygon[i], \"flow_id\": flow_id, \"outcome\": outcome})\n\nflows_df = pd.DataFrame(flow_polygons)\n\n# Create background rectangle data\nbg_rect = pd.DataFrame({\"xmin\": [0], \"xmax\": [1], \"ymin\": [-0.05], \"ymax\": [1.05]})\n\n# Create the plot\nplot = (\n    ggplot()\n    # Background rectangle\n    + geom_rect(bg_rect, aes(xmin=\"xmin\", xmax=\"xmax\", ymin=\"ymin\", ymax=\"ymax\"), fill=PAGE_BG, color=PAGE_BG)\n    # Flow polygons with transparency - colored by outcome\n    + geom_polygon(flows_df, aes(x=\"x\", y=\"y\", group=\"flow_id\", fill=\"outcome\"), alpha=0.5)\n    # Node rectangles\n    + geom_rect(\n        nodes_df, aes(xmin=\"xmin\", xmax=\"xmax\", ymin=\"ymin\", ymax=\"ymax\"), fill=INK_SOFT, color=PAGE_BG, size=0.8\n    )\n    # Count labels on nodes\n    + geom_text(\n        nodes_df[nodes_df[\"count\"] >= 20],\n        aes(x=(nodes_df[\"xmin\"] + nodes_df[\"xmax\"]) / 2, y=\"label_y\", label=\"count\"),\n        ha=\"center\",\n        va=\"center\",\n        size=10,\n        color=PAGE_BG,\n        fontweight=\"bold\",\n    )\n    + scale_fill_manual(values=outcome_colors, name=\"Outcome\", breaks=[\"Purchased\", \"Abandoned\"])\n    + labs(title=\"parallel-categories-basic · plotnine · anyplot.ai\", x=\"\", y=\"\")\n    + theme_minimal()\n    + theme(\n        figure_size=(16, 9),\n        plot_background=element_blank(),\n        panel_background=element_blank(),\n        plot_title=element_text(size=24, ha=\"center\", weight=\"bold\", color=INK),\n        axis_text=element_blank(),\n        axis_ticks=element_blank(),\n        panel_grid=element_blank(),\n        legend_background=element_blank(),\n        legend_title=element_text(size=16, weight=\"bold\", color=INK),\n        legend_text=element_text(size=14, color=INK_SOFT),\n        legend_position=\"right\",\n    )\n)\n\n# Add dimension labels at top\nfor dim_idx, dim in enumerate(dimensions):\n    plot = plot + annotate(\n        \"text\", x=x_positions[dim_idx], y=0.98, label=dim[\"label\"], size=14, color=INK, fontweight=\"bold\", ha=\"center\"\n    )\n\n# Add category labels beside each node\nfor (dim_idx, cat), pos in node_positions.items():\n    label = str(cat)\n    label_y = (pos[\"y_top\"] + pos[\"y_bottom\"]) / 2\n\n    # For first dimension, place label on left side of node\n    if dim_idx == 0:\n        plot = plot + annotate(\n            \"text\",\n            x=x_positions[dim_idx] - node_width / 2 - 0.01,\n            y=label_y,\n            label=label,\n            size=11,\n            color=INK_SOFT,\n            ha=\"right\",\n            va=\"center\",\n        )\n    # For last dimension, place label on right side of node\n    elif dim_idx == n_dims - 1:\n        plot = plot + annotate(\n            \"text\",\n            x=x_positions[dim_idx] + node_width / 2 + 0.01,\n            y=label_y,\n            label=label,\n            size=11,\n            color=INK_SOFT,\n            ha=\"left\",\n            va=\"center\",\n        )\n    # For middle dimensions, place label below the node\n    else:\n        plot = plot + annotate(\n            \"text\",\n            x=x_positions[dim_idx],\n            y=pos[\"y_bottom\"] - 0.015,\n            label=label,\n            size=11,\n            color=INK_SOFT,\n            ha=\"center\",\n            va=\"top\",\n        )\n\nplot.save(f\"plot-{THEME}.png\", dpi=300, verbose=False)\n"}