{"spec_id":"parallel-categories-basic","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nparallel-categories-basic: Basic Parallel Categories Plot\nLibrary: letsplot 4.9.0 | Python 3.13.13\nQuality: 93/100 | Updated: 2026-05-13\n\"\"\"\n\nimport os\n\nimport pandas as pd\nfrom lets_plot import (\n    LetsPlot,\n    aes,\n    element_blank,\n    element_rect,\n    element_text,\n    geom_polygon,\n    geom_rect,\n    geom_text,\n    ggplot,\n    ggsize,\n    labs,\n    scale_fill_manual,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n    theme_minimal,\n)\nfrom lets_plot.export import ggsave\n\n\nLetsPlot.setup_html()\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 (first 3 colors for the 3 channels)\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\"]\n\n# Customer journey data with multiple categorical dimensions\n# Dimensions: Channel (acquisition), Product Category, Purchase Size, Outcome\ndata = [\n    # Online channel journeys\n    (\"Online\", \"Electronics\", \"Large\", \"Completed\", 45),\n    (\"Online\", \"Electronics\", \"Small\", \"Completed\", 32),\n    (\"Online\", \"Electronics\", \"Large\", \"Abandoned\", 18),\n    (\"Online\", \"Electronics\", \"Small\", \"Abandoned\", 12),\n    (\"Online\", \"Clothing\", \"Large\", \"Completed\", 28),\n    (\"Online\", \"Clothing\", \"Small\", \"Completed\", 55),\n    (\"Online\", \"Clothing\", \"Large\", \"Abandoned\", 8),\n    (\"Online\", \"Clothing\", \"Small\", \"Abandoned\", 15),\n    (\"Online\", \"Home\", \"Large\", \"Completed\", 22),\n    (\"Online\", \"Home\", \"Small\", \"Completed\", 18),\n    (\"Online\", \"Home\", \"Large\", \"Abandoned\", 10),\n    (\"Online\", \"Home\", \"Small\", \"Abandoned\", 7),\n    # Store channel journeys\n    (\"Store\", \"Electronics\", \"Large\", \"Completed\", 35),\n    (\"Store\", \"Electronics\", \"Small\", \"Completed\", 20),\n    (\"Store\", \"Electronics\", \"Large\", \"Abandoned\", 5),\n    (\"Store\", \"Electronics\", \"Small\", \"Abandoned\", 3),\n    (\"Store\", \"Clothing\", \"Large\", \"Completed\", 40),\n    (\"Store\", \"Clothing\", \"Small\", \"Completed\", 65),\n    (\"Store\", \"Clothing\", \"Large\", \"Abandoned\", 4),\n    (\"Store\", \"Clothing\", \"Small\", \"Abandoned\", 6),\n    (\"Store\", \"Home\", \"Large\", \"Completed\", 30),\n    (\"Store\", \"Home\", \"Small\", \"Completed\", 25),\n    (\"Store\", \"Home\", \"Large\", \"Abandoned\", 3),\n    (\"Store\", \"Home\", \"Small\", \"Abandoned\", 2),\n    # Mobile channel journeys\n    (\"Mobile\", \"Electronics\", \"Large\", \"Completed\", 25),\n    (\"Mobile\", \"Electronics\", \"Small\", \"Completed\", 42),\n    (\"Mobile\", \"Electronics\", \"Large\", \"Abandoned\", 22),\n    (\"Mobile\", \"Electronics\", \"Small\", \"Abandoned\", 18),\n    (\"Mobile\", \"Clothing\", \"Large\", \"Completed\", 15),\n    (\"Mobile\", \"Clothing\", \"Small\", \"Completed\", 48),\n    (\"Mobile\", \"Clothing\", \"Large\", \"Abandoned\", 10),\n    (\"Mobile\", \"Clothing\", \"Small\", \"Abandoned\", 20),\n    (\"Mobile\", \"Home\", \"Large\", \"Completed\", 12),\n    (\"Mobile\", \"Home\", \"Small\", \"Completed\", 22),\n    (\"Mobile\", \"Home\", \"Large\", \"Abandoned\", 8),\n    (\"Mobile\", \"Home\", \"Small\", \"Abandoned\", 12),\n]\n\n# Define dimensions and their categories\ndimensions = [\"Channel\", \"Product\", \"Size\", \"Outcome\"]\ncategories = {\n    \"Channel\": [\"Online\", \"Store\", \"Mobile\"],\n    \"Product\": [\"Electronics\", \"Clothing\", \"Home\"],\n    \"Size\": [\"Large\", \"Small\"],\n    \"Outcome\": [\"Completed\", \"Abandoned\"],\n}\n\n# Colors for the first dimension (Channel) - using Okabe-Ito palette\nchannel_colors = {\"Online\": IMPRINT[0], \"Store\": IMPRINT[1], \"Mobile\": IMPRINT[2]}\n\n# Calculate totals for each dimension-category combination\ndimension_totals = {dim: {} for dim in dimensions}\nfor channel, product, size, outcome, count in data:\n    dimension_totals[\"Channel\"][channel] = dimension_totals[\"Channel\"].get(channel, 0) + count\n    dimension_totals[\"Product\"][product] = dimension_totals[\"Product\"].get(product, 0) + count\n    dimension_totals[\"Size\"][size] = dimension_totals[\"Size\"].get(size, 0) + count\n    dimension_totals[\"Outcome\"][outcome] = dimension_totals[\"Outcome\"].get(outcome, 0) + count\n\ntotal_flow = sum(count for _, _, _, _, count in data)\n\n# Layout parameters - increased spacing for better label readability\nx_positions = [0.10, 0.37, 0.63, 0.90]\nnode_width = 0.030\nnode_gap = 0.035\n\n# Calculate node positions for all dimensions (flat structure)\nnode_positions = []\nfor dim_idx in range(len(dimensions)):\n    dim = dimensions[dim_idx]\n    positions = {}\n    y_offset = 0.10\n    for cat in categories[dim]:\n        height = dimension_totals[dim].get(cat, 0) / total_flow * 0.72\n        positions[cat] = {\"y0\": y_offset, \"y1\": y_offset + height, \"x\": x_positions[dim_idx]}\n        y_offset += height + node_gap\n    node_positions.append(positions)\n\n# Build flow polygons between adjacent dimensions\nflow_data = []\n\n# Process each pair of adjacent dimensions\nfor dim_from_idx in range(len(dimensions) - 1):\n    dim_to_idx = dim_from_idx + 1\n    dim_from = dimensions[dim_from_idx]\n    dim_to = dimensions[dim_to_idx]\n\n    # Aggregate flows between categories\n    flow_counts = {}\n    for channel, product, size, outcome, count in data:\n        values = {\"Channel\": channel, \"Product\": product, \"Size\": size, \"Outcome\": outcome}\n        from_cat = values[dim_from]\n        to_cat = values[dim_to]\n        source_channel = channel\n        key = (from_cat, to_cat, source_channel)\n        flow_counts[key] = flow_counts.get(key, 0) + count\n\n    # Track offsets for positioning flows within nodes\n    from_offsets = dict.fromkeys(categories[dim_from], 0)\n    to_offsets = dict.fromkeys(categories[dim_to], 0)\n\n    from_positions = node_positions[dim_from_idx]\n    to_positions = node_positions[dim_to_idx]\n\n    x_left = x_positions[dim_from_idx] + node_width / 2\n    x_right = x_positions[dim_to_idx] - node_width / 2\n\n    # Sort flows for consistent ordering\n    sorted_flows = sorted(\n        flow_counts.items(),\n        key=lambda x: (\n            categories[dim_from].index(x[0][0]),\n            categories[dim_to].index(x[0][1]),\n            list(channel_colors.keys()).index(x[0][2]),\n        ),\n    )\n\n    for (from_cat, to_cat, source_channel), count in sorted_flows:\n        flow_height = count / total_flow * 0.72\n\n        src_y0 = from_positions[from_cat][\"y0\"] + from_offsets[from_cat]\n        src_y1 = src_y0 + flow_height\n        from_offsets[from_cat] += flow_height\n\n        tgt_y0 = to_positions[to_cat][\"y0\"] + to_offsets[to_cat]\n        tgt_y1 = tgt_y0 + flow_height\n        to_offsets[to_cat] += flow_height\n\n        # Create smooth curve polygon with easing\n        n_points = 30\n        x_vals_top = []\n        y_vals_top = []\n        x_vals_bottom = []\n        y_vals_bottom = []\n\n        for i in range(n_points + 1):\n            t = i / n_points\n            x = x_left + t * (x_right - x_left)\n            ease = t * t * (3 - 2 * t)\n            y_top = src_y1 + ease * (tgt_y1 - src_y1)\n            y_bottom = src_y0 + ease * (tgt_y0 - src_y0)\n\n            x_vals_top.append(x)\n            y_vals_top.append(y_top)\n            x_vals_bottom.append(x)\n            y_vals_bottom.append(y_bottom)\n\n        # Combine into closed polygon\n        x_polygon = x_vals_top + x_vals_bottom[::-1]\n        y_polygon = y_vals_top + y_vals_bottom[::-1]\n\n        flow_id = f\"d{dim_from_idx}_{from_cat}_{to_cat}_{source_channel}\"\n        for x, y in zip(x_polygon, y_polygon, strict=False):\n            flow_data.append(\n                {\"x\": x, \"y\": y, \"flow_id\": flow_id, \"channel\": source_channel, \"from_cat\": from_cat, \"to_cat\": to_cat}\n            )\n\ndf_flows = pd.DataFrame(flow_data)\n\n# Build node rectangles\nnode_rects = []\nfor dim_idx, dim in enumerate(dimensions):\n    for cat in categories[dim]:\n        pos = node_positions[dim_idx][cat]\n        node_rects.append(\n            {\n                \"xmin\": pos[\"x\"] - node_width / 2,\n                \"xmax\": pos[\"x\"] + node_width / 2,\n                \"ymin\": pos[\"y0\"],\n                \"ymax\": pos[\"y1\"],\n                \"category\": cat,\n                \"dimension\": dim,\n            }\n        )\n\ndf_nodes = pd.DataFrame(node_rects)\n\n# Build labels\nlabels = []\n\n# Dimension headers at top\nfor i, dim in enumerate(dimensions):\n    labels.append({\"x\": x_positions[i], \"y\": 0.96, \"label\": dim, \"type\": \"header\", \"hjust\": 0.5})\n\n# Category labels with counts - positioned with more spacing\nfor dim_idx, dim in enumerate(dimensions):\n    for cat in categories[dim]:\n        pos = node_positions[dim_idx][cat]\n        count = dimension_totals[dim][cat]\n\n        # Position labels on outer sides for first/last dimensions, alternating for middle\n        if dim_idx == 0:\n            x_label = pos[\"x\"] - node_width / 2 - 0.02\n            hjust = 1\n        elif dim_idx == len(dimensions) - 1:\n            x_label = pos[\"x\"] + node_width / 2 + 0.02\n            hjust = 0\n        elif dim_idx % 2 == 0:\n            x_label = pos[\"x\"] - node_width / 2 - 0.02\n            hjust = 1\n        else:\n            x_label = pos[\"x\"] + node_width / 2 + 0.02\n            hjust = 0\n\n        labels.append(\n            {\n                \"x\": x_label,\n                \"y\": (pos[\"y0\"] + pos[\"y1\"]) / 2,\n                \"label\": f\"{cat} ({count})\",\n                \"type\": \"category\",\n                \"hjust\": hjust,\n            }\n        )\n\ndf_labels = pd.DataFrame(labels)\n\n# Create the plot\nplot = (\n    ggplot()\n    + geom_polygon(\n        aes(x=\"x\", y=\"y\", group=\"flow_id\", fill=\"channel\"), data=df_flows, alpha=0.5, color=\"white\", size=0.08\n    )\n    + geom_rect(\n        aes(xmin=\"xmin\", xmax=\"xmax\", ymin=\"ymin\", ymax=\"ymax\"), data=df_nodes, fill=INK_SOFT, color=INK, size=1.2\n    )\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=df_labels[df_labels[\"type\"] == \"header\"],\n        size=18,\n        hjust=0.5,\n        fontface=\"bold\",\n        color=INK,\n    )\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"), data=df_labels[df_labels[\"type\"] == \"category\"], size=16, color=INK_SOFT\n    )\n    + scale_fill_manual(\n        values={\n            \"Online\": channel_colors[\"Online\"],\n            \"Store\": channel_colors[\"Store\"],\n            \"Mobile\": channel_colors[\"Mobile\"],\n        },\n        name=\"Acquisition Channel\",\n    )\n    + labs(title=\"parallel-categories-basic · letsplot · anyplot.ai\")\n    + theme_minimal()\n    + theme(\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        plot_title=element_text(size=26, face=\"bold\", color=INK),\n        axis_title=element_blank(),\n        axis_text=element_blank(),\n        axis_ticks=element_blank(),\n        panel_grid=element_blank(),\n        legend_text=element_text(size=16, color=INK_SOFT),\n        legend_title=element_text(size=18, face=\"bold\", color=INK),\n        legend_position=\"bottom\",\n        legend_background=element_blank(),\n    )\n    + scale_x_continuous(limits=[-0.02, 1.02])\n    + scale_y_continuous(limits=[-0.02, 1.02])\n    + ggsize(1600, 900)\n)\n\n# Save as PNG (scale 3x for 4800 × 2700 px) and HTML\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=3)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}