{"spec_id":"parallel-categories-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nparallel-categories-basic: Basic Parallel Categories Plot\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 96/100 | Updated: 2026-05-13\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove script directory from sys.path to avoid importing local altair.py\nscript_dir = os.path.dirname(os.path.abspath(__file__))\nif script_dir in sys.path:\n    sys.path.remove(script_dir)\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\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# Okabe-Ito palette (colorblind-safe)\nIMPRINT = [\n    \"#009E73\",  # brand green (position 1)\n    \"#C475FD\",  # vermillion (position 2)\n    \"#4467A3\",  # blue (position 3)\n    \"#BD8233\",  # reddish purple (position 4)\n    \"#AE3030\",  # orange (position 5)\n    \"#2ABCCD\",  # sky blue (position 6)\n    \"#954477\",  # yellow (position 7)\n]\n\n# Data - Customer journey through product categories\nnp.random.seed(42)\n\nn_customers = 200\nchannels = np.random.choice([\"Direct\", \"Search\", \"Social\", \"Email\"], n_customers, p=[0.3, 0.35, 0.2, 0.15])\ncategories = np.random.choice([\"Electronics\", \"Clothing\", \"Home\", \"Sports\"], n_customers, p=[0.25, 0.35, 0.25, 0.15])\noutcomes = np.random.choice([\"Purchase\", \"Abandon\", \"Browse\"], n_customers, p=[0.4, 0.35, 0.25])\n\ndf = pd.DataFrame({\"Channel\": channels, \"Category\": categories, \"Outcome\": outcomes})\nagg_df = df.groupby([\"Channel\", \"Category\", \"Outcome\"]).size().reset_index(name=\"count\")\n\n# Dimension x-positions\nx_pos = {\"Channel\": 0, \"Category\": 250, \"Outcome\": 500}\n\n# Color maps using Okabe-Ito palette\nchannel_colors = {\n    \"Direct\": IMPRINT[0],  # brand green\n    \"Search\": IMPRINT[1],  # vermillion\n    \"Social\": IMPRINT[2],  # blue\n    \"Email\": IMPRINT[3],  # reddish purple\n}\ncategory_colors = {\n    \"Electronics\": IMPRINT[0],  # green\n    \"Clothing\": IMPRINT[1],  # vermillion\n    \"Home\": IMPRINT[2],  # blue\n    \"Sports\": IMPRINT[3],  # reddish purple\n}\noutcome_colors = {\n    \"Purchase\": IMPRINT[0],  # green\n    \"Abandon\": IMPRINT[1],  # vermillion\n    \"Browse\": IMPRINT[2],  # blue\n}\n\nscale_factor = 3.5\n\n# Calculate y-positions for each category in each dimension\nchannel_totals = agg_df.groupby(\"Channel\")[\"count\"].sum().sort_values(ascending=False)\ncategory_totals = agg_df.groupby(\"Category\")[\"count\"].sum().sort_values(ascending=False)\noutcome_totals = agg_df.groupby(\"Outcome\")[\"count\"].sum().sort_values(ascending=False)\n\nchannel_pos = {}\ny = 0\nfor cat in channel_totals.index:\n    h = channel_totals[cat] * scale_factor\n    channel_pos[cat] = {\"y0\": y, \"y1\": y + h, \"total\": channel_totals[cat]}\n    y += h + 12\n\ncategory_pos = {}\ny = 0\nfor cat in category_totals.index:\n    h = category_totals[cat] * scale_factor\n    category_pos[cat] = {\"y0\": y, \"y1\": y + h, \"total\": category_totals[cat]}\n    y += h + 12\n\noutcome_pos = {}\ny = 0\nfor cat in outcome_totals.index:\n    h = outcome_totals[cat] * scale_factor\n    outcome_pos[cat] = {\"y0\": y, \"y1\": y + h, \"total\": outcome_totals[cat]}\n    y += h + 12\n\n# Build flow connections\nch_offsets = dict.fromkeys(channel_pos, 0)\ncat_left_offsets = dict.fromkeys(category_pos, 0)\ncat_right_offsets = dict.fromkeys(category_pos, 0)\nout_offsets = dict.fromkeys(outcome_pos, 0)\n\nch_cat_flows = agg_df.groupby([\"Channel\", \"Category\"])[\"count\"].sum().reset_index()\nflow_lines = []\n\nfor _, row in ch_cat_flows.iterrows():\n    ch, cat, cnt = row[\"Channel\"], row[\"Category\"], row[\"count\"]\n    height = cnt * scale_factor\n    src_y = channel_pos[ch][\"y0\"] + ch_offsets[ch] + height / 2\n    ch_offsets[ch] += height\n    tgt_y = category_pos[cat][\"y0\"] + cat_left_offsets[cat] + height / 2\n    cat_left_offsets[cat] += height\n    flow_lines.append(\n        {\n            \"x0\": x_pos[\"Channel\"] + 50,\n            \"y0\": src_y,\n            \"x1\": x_pos[\"Category\"],\n            \"y1\": tgt_y,\n            \"strokeWidth\": max(5, cnt * 2.0),\n            \"color\": channel_colors[ch],\n        }\n    )\n\ncat_out_flows = agg_df.groupby([\"Category\", \"Outcome\"])[\"count\"].sum().reset_index()\nfor _, row in cat_out_flows.iterrows():\n    cat, out, cnt = row[\"Category\"], row[\"Outcome\"], row[\"count\"]\n    height = cnt * scale_factor\n    dom_ch = agg_df[agg_df[\"Category\"] == cat].groupby(\"Channel\")[\"count\"].sum().idxmax()\n    src_y = category_pos[cat][\"y0\"] + cat_right_offsets[cat] + height / 2\n    cat_right_offsets[cat] += height\n    tgt_y = outcome_pos[out][\"y0\"] + out_offsets[out] + height / 2\n    out_offsets[out] += height\n    flow_lines.append(\n        {\n            \"x0\": x_pos[\"Category\"] + 50,\n            \"y0\": src_y,\n            \"x1\": x_pos[\"Outcome\"],\n            \"y1\": tgt_y,\n            \"strokeWidth\": max(5, cnt * 2.0),\n            \"color\": channel_colors[dom_ch],\n        }\n    )\n\n# Create bezier curve points for smooth ribbons\nbezier_pts = []\nfor flow_id, fl in enumerate(flow_lines):\n    for t in np.linspace(0, 1, 20):\n        x = (\n            fl[\"x0\"] * (1 - t) ** 3\n            + (fl[\"x0\"] + 60) * 3 * (1 - t) ** 2 * t\n            + (fl[\"x1\"] - 60) * 3 * (1 - t) * t**2\n            + fl[\"x1\"] * t**3\n        )\n        y = fl[\"y0\"] * (1 - t) + fl[\"y1\"] * t\n        bezier_pts.append(\n            {\"x\": x, \"y\": y, \"flow_id\": flow_id, \"color\": fl[\"color\"], \"strokeWidth\": fl[\"strokeWidth\"], \"order\": t}\n        )\n\nbezier_df = pd.DataFrame(bezier_pts)\n\n# Create category box data with distinct colors for each dimension\nbox_data = []\ncolor_maps = {\"Channel\": channel_colors, \"Category\": category_colors, \"Outcome\": outcome_colors}\nfor dim, pos_dict in [(\"Channel\", channel_pos), (\"Category\", category_pos), (\"Outcome\", outcome_pos)]:\n    for cat, pos in pos_dict.items():\n        box_data.append(\n            {\n                \"category\": cat,\n                \"x\": x_pos[dim],\n                \"x2\": x_pos[dim] + 50,\n                \"y0\": pos[\"y0\"],\n                \"y1\": pos[\"y1\"],\n                \"y_mid\": (pos[\"y0\"] + pos[\"y1\"]) / 2,\n                \"total\": pos[\"total\"],\n                \"color\": color_maps[dim].get(cat, INK_SOFT),\n            }\n        )\n\nbox_df = pd.DataFrame(box_data)\nmax_y = box_df[\"y1\"].max() + 80\n\n# Visualization layers\nflows = (\n    alt.Chart(bezier_df)\n    .mark_line(opacity=0.65, strokeCap=\"round\")\n    .encode(\n        x=alt.X(\"x:Q\", axis=None, scale=alt.Scale(domain=[-50, 680])),\n        y=alt.Y(\"y:Q\", axis=None, scale=alt.Scale(domain=[-70, max_y])),\n        detail=\"flow_id:N\",\n        order=\"order:Q\",\n        color=alt.Color(\"color:N\", scale=None),\n        strokeWidth=alt.StrokeWidth(\"strokeWidth:Q\", scale=None),\n    )\n)\n\nboxes = (\n    alt.Chart(box_df)\n    .mark_rect(stroke=INK_SOFT, strokeWidth=2, cornerRadius=4)\n    .encode(\n        x=alt.X(\"x:Q\", axis=None),\n        x2=\"x2:Q\",\n        y=alt.Y(\"y0:Q\", axis=None),\n        y2=\"y1:Q\",\n        color=alt.Color(\"color:N\", scale=None),\n    )\n)\n\nlabels = (\n    alt.Chart(box_df)\n    .mark_text(align=\"left\", baseline=\"middle\", fontSize=18, fontWeight=\"bold\", dx=58)\n    .encode(x=\"x:Q\", y=\"y_mid:Q\", text=\"category:N\", color=alt.value(INK))\n)\n\ncounts = (\n    alt.Chart(box_df)\n    .mark_text(align=\"left\", baseline=\"middle\", fontSize=15, dx=58, dy=24)\n    .encode(x=\"x:Q\", y=\"y_mid:Q\", text=alt.Text(\"total:Q\", format=\"d\"), color=alt.value(INK_SOFT))\n)\n\n# Headers positioned above the boxes\nheaders_df = pd.DataFrame(\n    {\n        \"x\": [x_pos[\"Channel\"] + 25, x_pos[\"Category\"] + 25, x_pos[\"Outcome\"] + 25],\n        \"y\": [-45, -45, -45],\n        \"header\": [\"Channel\", \"Category\", \"Outcome\"],\n    }\n)\nheaders = (\n    alt.Chart(headers_df)\n    .mark_text(fontSize=24, fontWeight=\"bold\")\n    .encode(x=\"x:Q\", y=\"y:Q\", text=\"header:N\", color=alt.value(INK))\n)\n\n# Legend for Channel colors (flow color coding) using square marks\nlegend_items = []\nfor i, (ch, color) in enumerate(channel_colors.items()):\n    legend_items.append({\"label\": ch, \"color\": color, \"x\": 600, \"y\": i * 32 + 10})\nlegend_df = pd.DataFrame(legend_items)\n\nlegend_marks = (\n    alt.Chart(legend_df).mark_square(size=400).encode(x=\"x:Q\", y=\"y:Q\", color=alt.Color(\"color:N\", scale=None))\n)\n\nlegend_labels = (\n    alt.Chart(legend_df)\n    .mark_text(align=\"left\", baseline=\"middle\", fontSize=16, dx=18)\n    .encode(x=\"x:Q\", y=\"y:Q\", text=\"label:N\", color=alt.value(INK_SOFT))\n)\n\nlegend_title_df = pd.DataFrame({\"x\": [600], \"y\": [-25], \"text\": [\"Flow Colors\"]})\nlegend_title = (\n    alt.Chart(legend_title_df)\n    .mark_text(fontSize=18, fontWeight=\"bold\", align=\"left\")\n    .encode(x=\"x:Q\", y=\"y:Q\", text=\"text:N\", color=alt.value(INK))\n)\n\n# Combine layers\nchart = (\n    alt.layer(flows, boxes, labels, counts, headers, legend_title, legend_marks, legend_labels)\n    .properties(\n        width=1600,\n        height=900,\n        background=PAGE_BG,\n        title=alt.Title(\n            \"parallel-categories-basic · altair · anyplot.ai\", fontSize=28, anchor=\"middle\", color=INK, offset=30\n        ),\n    )\n    .configure_view(fill=PAGE_BG, stroke=INK_SOFT, strokeWidth=0)\n)\n\n# Save\nchart.save(f\"plot-{THEME}.png\", scale_factor=3.0)\nchart.save(f\"plot-{THEME}.html\")\n"}