Skip to content

Instantly share code, notes, and snippets.

@cavedave
Created February 7, 2026 15:41
Show Gist options
  • Select an option

  • Save cavedave/ac04924b422a261f0adc63e20505c974 to your computer and use it in GitHub Desktop.

Select an option

Save cavedave/ac04924b422a261f0adc63e20505c974 to your computer and use it in GitHub Desktop.
import re
from io import StringIO
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap
DATA = """pasta\tbox time\tactual al dente\tdifference
capellini\t4-5 min\t2:45\t-1:15
angel hair\t4-5 min\t3:00\t-1:00
spaghetti\t8-10 min\t7:15\t-0:45
linguine\t9-11 min\t8:00\t-1:00
fettuccine\t10-12 min\t8:30\t-1:30
bucatini\t10-12 min\t9:00\t-1:00
pappardelle\t7-9 min\t6:00\t-1:00
tagliatelle\t8-10 min\t7:00\t-1:00
penne\t11-13 min\t9:30\t-1:30
penne rigate\t11-13 min\t10:00\t-1:00
rigatoni\t12-15 min\t9:15\t-2:45
ziti\t14-15 min\t11:00\t-3:00
macaroni\t8-10 min\t7:00\t-1:00
rotini\t8-10 min\t7:30\t-0:30
fusilli\t11-13 min\t9:00\t-2:00
gemelli\t10-12 min\t8:30\t-1:30
cavatappi\t9-12 min\t8:00\t-1:00
campanelle\t10-12 min\t8:30\t-1:30
radiatori\t9-11 min\t8:00\t-1:00
orecchiette\t12-15 min\t10:30\t-1:30
shells (medium)\t9-11 min\t8:00\t-1:00
shells (large)\t12-15 min\t10:00\t-2:00
conchiglie\t10-12 min\t8:30\t-1:30
orzo\t8-10 min\t7:00\t-1:00
ditalini\t9-11 min\t8:00\t-1:00
paccheri\t12-14 min\t10:30\t-1:30
casarecce\t10-12 min\t9:00\t-1:00
trofie\t10-12 min\t8:30\t-1:30
strozzapreti\t10-12 min\t9:00\t-1:00
mafalda\t8-10 min\t7:30\t-0:30
"""
def parse_box_range(s: str):
m = re.search(r"(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)", s)
if m:
return float(m.group(1)), float(m.group(2))
m2 = re.search(r"(\d+(?:\.\d+)?)", s)
v = float(m2.group(1)) if m2 else float("nan")
return v, v
def parse_m_ss(s: str) -> float:
mm, ss = s.strip().split(":")
return int(mm) + int(ss) / 60.0
# ---------- Load ----------
df = pd.read_csv(StringIO(DATA), sep="\t")
df[["box_min", "box_max"]] = df["box time"].apply(lambda s: pd.Series(parse_box_range(s)))
df["actual_min"] = df["actual al dente"].map(parse_m_ss)
df["box_mid"] = (df["box_min"] + df["box_max"]) / 2.0
df["pct_of_mid"] = df["actual_min"] / df["box_mid"]
# ---------- Family grouping ----------
# (Edit freely: this is a subjective but useful “field-guide” grouping.)
family_map = {
# long strands / ribbons
"capellini": "Long strands",
"angel hair": "Long strands",
"spaghetti": "Long strands",
"linguine": "Long strands",
"bucatini": "Long strands",
"fettuccine": "Ribbons",
"tagliatelle": "Ribbons",
"pappardelle": "Ribbons",
"mafalda": "Ribbons",
# tubes
"penne": "Tubes",
"penne rigate": "Tubes",
"rigatoni": "Tubes",
"ziti": "Tubes",
"macaroni": "Tubes",
"ditalini": "Tubes",
"paccheri": "Tubes",
# twists / curls / odd shapes
"fusilli": "Twists & curls",
"rotini": "Twists & curls",
"gemelli": "Twists & curls",
"cavatappi": "Twists & curls",
"casarecce": "Twists & curls",
"strozzapreti": "Twists & curls",
"trofie": "Twists & curls",
"campanelle": "Twists & curls",
"radiatori": "Twists & curls",
# shells & small bits
"shells (medium)": "Shells & small shapes",
"shells (large)": "Shells & small shapes",
"conchiglie": "Shells & small shapes",
"orecchiette": "Shells & small shapes",
"orzo": "Shells & small shapes",
}
df["family"] = df["pasta"].map(family_map).fillna("Other")
# Choose a fun, intuitive family order
family_order = [
"Long strands",
"Ribbons",
"Tubes",
"Twists & curls",
"Shells & small shapes",
"Other",
]
df["family"] = pd.Categorical(df["family"], categories=family_order, ordered=True)
# Sort within each family by actual time (fast → slow)
df = df.sort_values(["family", "actual_min", "pasta"]).reset_index(drop=True)
# ---------- Build y positions with gaps between families ----------
gap = 0.9 # vertical gap size between families
y_positions = []
y_labels = []
group_bounds = [] # (family, y_start, y_end)
cur_y = 0.0
for fam in family_order:
sub = df[df["family"] == fam]
if sub.empty:
continue
y_start = cur_y
for _ in range(len(sub)):
y_positions.append(cur_y)
y_labels.append(None) # fill later
cur_y += 1.0
y_end = cur_y - 1.0
group_bounds.append((fam, y_start, y_end))
cur_y += gap
# assign labels in the same plotting order
df_plot = pd.concat([df[df["family"] == fam] for fam in family_order if not df[df["family"] == fam].empty], ignore_index=True)
y = np.array(y_positions)
labels = df_plot["pasta"].tolist()
# ---------- Coloring: red gradient for "below box min" ----------
status = np.where(
df_plot["actual_min"] < df_plot["box_min"], "below",
np.where(df_plot["actual_min"] > df_plot["box_max"], "above", "within")
)
below_amount = np.clip(df_plot["box_min"] - df_plot["actual_min"], 0, None)
t = below_amount / below_amount.max() if below_amount.max() > 0 else np.zeros_like(below_amount)
# Italian flag order: green = actual (light green → strong green)
green_cmap = LinearSegmentedColormap.from_list("italian_green", ["#E8F5E9", "#008C45"], N=256)
actual_colors = green_cmap(0.15 + 0.85 * t)
# ---------- Plot ----------
# Italian flag colors (green = actual, red = range)
IT_RED, IT_GREEN, IT_WHITE = "#CD212A", "#008C45", "#F4F9FF"
fig_h = max(9, 0.34 * len(df_plot) + 2)
fig, ax = plt.subplots(figsize=(14, fig_h))
fig.patch.set_facecolor(IT_WHITE)
ax.set_facecolor(IT_WHITE)
# Box range (Italian red – solid)
ax.hlines(y=y, xmin=df_plot["box_min"], xmax=df_plot["box_max"],
color=IT_RED, linewidth=2.8, alpha=1.0, label="Box range")
ax.scatter(df_plot["box_min"], y, s=58, color=IT_RED, zorder=2, edgecolors="white", linewidths=0.5)
ax.scatter(df_plot["box_max"], y, s=58, color=IT_RED, zorder=2, edgecolors="white", linewidths=0.5)
# Actual dots (Italian green, slight white edge)
ax.scatter(df_plot["actual_min"], y, s=72, color=actual_colors, edgecolors="white", linewidths=0.6,
zorder=3, label="Actual al dente")
# Right-side percent labels: only lowest and highest
label_col_x = df_plot["box_max"].max() + 0.25
pct = df_plot["pct_of_mid"].values
i_min, i_max = pct.argmin(), pct.argmax()
for idx in (i_min, i_max):
ax.text(label_col_x, y[idx], f"{pct[idx]*100:.0f}%", va="center", fontsize=9, fontweight="bold")
# Y ticks/labels
ax.set_yticks(y)
ax.set_yticklabels(labels)
ax.invert_yaxis()
# Slight gap at top and bottom of y-axis
ax.set_ylim(max(y) + 0.45, min(y) - 0.45)
# Axes limits (leave left margin for group labels)
xmin_data = max(0, min(df_plot["actual_min"].min(), df_plot["box_min"].min()) - 0.6)
xmax = label_col_x + 0.9
width = xmax - xmin_data
xmin = xmin_data - width * 0.22 # space for family labels on the left
ax.set_xlim(xmin, xmax)
ax.set_xlabel("Minutes")
median_pct = df_plot["pct_of_mid"].median() * 100
subtitle = f"Cooking times printed on the box versus measured al dente in practice"
ax.set_title("Pasta Past Its Prime", fontsize=22, fontweight="bold", pad=20)
ax.text(0.5, 1.005, subtitle, transform=ax.transAxes, ha="center", va="bottom", fontsize=11, color="gray")
ax.grid(axis="x", linestyle="--", linewidth=0.5, alpha=0.6)
# Column header for the right labels
ax.text(1.0, 1.02, "% of box mid", ha="right", va="bottom", fontsize=9, transform=ax.transAxes)
# Group styling: light background band per family + separator + label
xmin_plot = xmin_data # start of actual data area
margin_frac = (xmin_plot - xmin) / (xmax - xmin) # left margin as fraction of x axis
for fam, y_start, y_end in group_bounds:
y_mid = (y_start + y_end) / 2.0
# light background band (left margin; subtle red tint to match range)
ax.axhspan(y_start, y_end, xmin=0, xmax=margin_frac, facecolor=IT_RED, alpha=0.08)
# group label in left margin
x_label = xmin + (xmin_plot - xmin) * 0.5
ax.text(x_label, y_mid, fam, ha="center", va="center", fontsize=9, fontweight="bold")
# horizontal separator line below this group
ax.hlines(y=y_end + 0.5, xmin=xmin_plot, xmax=xmax, linewidth=0.9, color=IT_RED, alpha=0.35)
ax.legend(loc="upper right", bbox_to_anchor=(1.0, 0.88), framealpha=0.95, edgecolor=IT_RED)
# Credit line (figure coordinates)
plt.figtext(
0.01, 0.02,
"Data by u/sthduh • Graph by @iamreddave",
ha="left",
fontsize=9,
style="italic",
color="gray",
)
plt.tight_layout(rect=[0, 0.04, 1, 1]) # leave room for figtext
plt.savefig("pasta_chart_grouped.png", dpi=200, bbox_inches="tight", facecolor=IT_WHITE)
@cavedave
Copy link
Author

cavedave commented Feb 7, 2026

pasta_chart_grouped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment