Skip to content

Commit 063cf9c

Browse files
feat(seaborn): implement funnel-basic (#5426)
## Implementation: `funnel-basic` - python/seaborn Implements the **python/seaborn** version of `funnel-basic`. **File:** `plots/funnel-basic/implementations/python/seaborn.py` **Parent Issue:** #789 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/24949059229)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent b12ba37 commit 063cf9c

2 files changed

Lines changed: 319 additions & 201 deletions

File tree

Lines changed: 140 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,163 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
funnel-basic: Basic Funnel Chart
3-
Library: seaborn 0.13.2 | Python 3.13.11
4-
Quality: 91/100 | Created: 2025-12-23
3+
Library: seaborn 0.13.2 | Python 3.14.4
4+
Quality: 90/100 | Updated: 2026-04-26
55
"""
66

7+
import os
8+
79
import matplotlib.pyplot as plt
8-
import numpy as np
910
import seaborn as sns
1011
from matplotlib.patches import Polygon
1112

1213

13-
# Set seed for reproducibility
14-
np.random.seed(42)
14+
# Theme tokens
15+
THEME = os.getenv("ANYPLOT_THEME", "light")
16+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
17+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
18+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
19+
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
20+
21+
# Okabe-Ito palette — first series always #009E73
22+
OKABE_ITO = ["#009E73", "#D55E00", "#0072B2", "#CC79A7", "#E69F00"]
1523

16-
# Data - Sales funnel example from specification
24+
# Sales funnel data
1725
stages = ["Awareness", "Interest", "Consideration", "Intent", "Purchase"]
1826
values = [1000, 600, 400, 200, 100]
1927
max_value = values[0]
20-
21-
# Calculate percentages
2228
percentages = [v / max_value * 100 for v in values]
29+
conversions = [values[i + 1] / values[i] * 100 for i in range(len(values) - 1)]
30+
# Stage transition with the largest drop-off (lowest retention)
31+
worst_idx = min(range(len(conversions)), key=conversions.__getitem__)
32+
33+
sns.set_theme(
34+
style="white",
35+
rc={
36+
"figure.facecolor": PAGE_BG,
37+
"axes.facecolor": PAGE_BG,
38+
"text.color": INK,
39+
"axes.labelcolor": INK,
40+
"ytick.color": INK,
41+
"xtick.color": INK_SOFT,
42+
},
43+
)
44+
45+
fig, ax = plt.subplots(figsize=(16, 9), facecolor=PAGE_BG)
46+
ax.set_facecolor(PAGE_BG)
47+
48+
# Seaborn draws the rectangular core of each stage; trapezoidal panels added
49+
# below tie the cores into a continuous funnel silhouette in stage colors.
50+
sns.barplot(
51+
x=values,
52+
y=stages,
53+
hue=stages,
54+
order=stages,
55+
palette=OKABE_ITO[: len(stages)],
56+
ax=ax,
57+
legend=False,
58+
width=0.50,
59+
edgecolor="none",
60+
)
61+
62+
# Center each bar on x=0 so the silhouette narrows symmetrically
63+
bars = list(ax.patches)[: len(stages)]
64+
for patch in bars:
65+
patch.set_x(-patch.get_width() / 2)
66+
67+
# Trapezoidal panels between stages — each panel inherits the upper stage color
68+
# so visually each stage = rectangle + tapering trapezoid below it.
69+
for i in range(len(bars) - 1):
70+
p_top, p_bot = bars[i], bars[i + 1]
71+
top_y = p_top.get_y() + p_top.get_height()
72+
bot_y = p_bot.get_y()
73+
ax.add_patch(
74+
Polygon(
75+
[
76+
(p_top.get_x(), top_y),
77+
(p_top.get_x() + p_top.get_width(), top_y),
78+
(p_bot.get_x() + p_bot.get_width(), bot_y),
79+
(p_bot.get_x(), bot_y),
80+
],
81+
facecolor=OKABE_ITO[i],
82+
edgecolor="none",
83+
zorder=1,
84+
)
85+
)
86+
87+
# Closing tail below the last stage so the funnel ends with a proper taper
88+
last_bar = bars[-1]
89+
last_top_y = last_bar.get_y() + last_bar.get_height()
90+
tail_height = 0.50
91+
tail_bot_w = last_bar.get_width() * 0.5
92+
ax.add_patch(
93+
Polygon(
94+
[
95+
(last_bar.get_x(), last_top_y),
96+
(last_bar.get_x() + last_bar.get_width(), last_top_y),
97+
(tail_bot_w / 2, last_top_y + tail_height),
98+
(-tail_bot_w / 2, last_top_y + tail_height),
99+
],
100+
facecolor=OKABE_ITO[-1],
101+
edgecolor="none",
102+
zorder=1,
103+
)
104+
)
105+
106+
# Emphasise the bar after the worst drop-off with a thicker outline accent
107+
worst_bar = bars[worst_idx + 1]
108+
worst_bar.set_edgecolor(INK)
109+
worst_bar.set_linewidth(2.5)
110+
worst_bar.set_zorder(3)
111+
112+
# Value + percentage labels are placed OUTSIDE bars (right) so narrow stages
113+
# never overflow onto the page background.
114+
right_offset = max_value * 0.04
115+
for i, patch in enumerate(bars):
116+
cy = patch.get_y() + patch.get_height() / 2
117+
x_right = patch.get_x() + patch.get_width()
118+
ax.text(
119+
x_right + right_offset,
120+
cy,
121+
f"{values[i]:,} · {percentages[i]:.0f}%",
122+
ha="left",
123+
va="center",
124+
fontsize=18,
125+
fontweight="medium",
126+
color=INK,
127+
)
23128

24-
# Seaborn styling
25-
sns.set_theme(style="white")
26-
27-
# Create figure
28-
fig, ax = plt.subplots(figsize=(16, 9))
29-
30-
# Color palette using Python Blue to Gold progression
31-
colors = sns.color_palette(["#306998", "#4078A8", "#6AA8D1", "#FFD43B", "#E8C547"])
32-
33-
# Funnel parameters
34-
n_stages = len(stages)
35-
funnel_height = 0.8 # Total height of funnel
36-
stage_gap = 0.02 # Gap between stages
37-
stage_height = (funnel_height - (n_stages - 1) * stage_gap) / n_stages
38-
center_x = 0.5
39-
40-
# Draw trapezoidal funnel segments
41-
for i in range(n_stages):
42-
# Calculate widths - proportional to value relative to max
43-
top_width = values[i] / max_value * 0.8
44-
# Bottom width is the next stage's width, or smaller for last stage
45-
if i < n_stages - 1:
46-
bottom_width = values[i + 1] / max_value * 0.8
47-
else:
48-
bottom_width = values[i] / max_value * 0.8 * 0.6 # Narrower bottom for last stage
49-
50-
# Calculate y positions (top to bottom)
51-
y_top = 1 - 0.1 - i * (stage_height + stage_gap)
52-
y_bottom = y_top - stage_height
53-
54-
# Create trapezoid vertices (clockwise from top-left)
55-
vertices = [
56-
(center_x - top_width / 2, y_top), # Top-left
57-
(center_x + top_width / 2, y_top), # Top-right
58-
(center_x + bottom_width / 2, y_bottom), # Bottom-right
59-
(center_x - bottom_width / 2, y_bottom), # Bottom-left
60-
]
61-
62-
# Draw trapezoid using matplotlib Polygon
63-
trapezoid = Polygon(vertices, facecolor=colors[i], edgecolor="white", linewidth=3, closed=True)
64-
ax.add_patch(trapezoid)
65-
66-
# Calculate center of trapezoid for label placement
67-
center_y = (y_top + y_bottom) / 2
68-
69-
# Add stage name on the left
129+
# Conversion-rate annotations on the LEFT, with the largest drop-off
130+
# rendered bolder and in full-strength ink for visual emphasis.
131+
left_anchor = -max_value / 2 - max_value * 0.06
132+
for i in range(len(conversions)):
133+
p_top, p_bot = bars[i], bars[i + 1]
134+
y_mid = (p_top.get_y() + p_top.get_height() + p_bot.get_y()) / 2
135+
is_worst = i == worst_idx
70136
ax.text(
71-
center_x - top_width / 2 - 0.05,
72-
center_y,
73-
stages[i],
137+
left_anchor,
138+
y_mid,
139+
f"↓ {conversions[i]:.0f}%",
74140
ha="right",
75141
va="center",
76-
fontsize=20,
77-
fontweight="bold",
78-
color="#333333",
142+
fontsize=16 if is_worst else 13,
143+
fontweight="bold" if is_worst else "normal",
144+
style="italic",
145+
color=INK if is_worst else INK_MUTED,
79146
)
80147

81-
# Add value and percentage label in center
82-
label_text = f"{values[i]:,} ({percentages[i]:.0f}%)"
83-
# Choose text color based on background brightness
84-
text_color = "white" if i < 3 else "#333333"
85-
ax.text(center_x, center_y, label_text, ha="center", va="center", fontsize=18, fontweight="bold", color=text_color)
86-
87-
# Add conversion rate between stages
88-
if i < n_stages - 1:
89-
conversion_rate = values[i + 1] / values[i] * 100
90-
ax.text(
91-
center_x + top_width / 2 + 0.05,
92-
y_bottom,
93-
f"↓ {conversion_rate:.0f}%",
94-
ha="left",
95-
va="center",
96-
fontsize=14,
97-
color="#666666",
98-
style="italic",
99-
)
148+
# Awareness on top — matplotlib's default places the first category at the bottom
149+
ax.invert_yaxis()
150+
ax.set_ylim(len(stages) - 1 + tail_height + 0.25, -0.45)
151+
152+
sns.despine(ax=ax, left=True, bottom=True)
153+
ax.set_xticks([])
154+
ax.set_xlabel("")
155+
ax.set_ylabel("")
156+
ax.tick_params(axis="y", labelsize=20, length=0, pad=10)
100157

101-
# Set axis limits and remove decorations
102-
ax.set_xlim(0, 1)
103-
ax.set_ylim(0, 1)
104-
ax.set_aspect("equal")
105-
ax.axis("off")
158+
ax.set_xlim(-max_value * 0.95, max_value * 0.85)
106159

107-
# Title
108-
ax.set_title("funnel-basic · seaborn · pyplots.ai", fontsize=24, fontweight="bold", pad=20)
160+
ax.set_title("funnel-basic · seaborn · anyplot.ai", fontsize=24, fontweight="medium", color=INK, pad=20)
109161

110162
plt.tight_layout()
111-
plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white")
163+
plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG)

0 commit comments

Comments
 (0)