Created
February 7, 2026 15:41
-
-
Save cavedave/ac04924b422a261f0adc63e20505c974 to your computer and use it in GitHub Desktop.
pasta graph. Based on data from https://www.reddit.com/r/Cooking/comments/1qxdjxy/i_timed_how_long_31_different_pasta_shapes_take/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
Author
cavedave
commented
Feb 7, 2026
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment