Agent Skills: Before (broken for small y-range):

Figure engineering patterns, gotchas, and reusable helpers for matplotlib. Use when directly building standalone figure scripts, diagram generators, or canvas visuals outside the epq render pipeline — e.g., generate_layer_icons.py, vision project banners, morning-brief charts, competitive analysis figures. Covers shape boundary math, arrow/edge geometry, series consistency, font registration, zorder, Unicode rendering, and canvas clipping. Distinct from the epq skill (which covers the Quarto/LaTeX analysis pipeline). Load alongside epq when figure work involves both the pipeline and standalone diagram construction.

UncategorizedID: lanej/dotfiles/matplotlib

Install this agent skill to your local

pnpm dlx add-skill https://github.com/lanej/dotfiles/tree/HEAD/claude/skills/matplotlib

Skill Files

Browse the full folder contents for matplotlib.

Download Skill

Loading file tree…

claude/skills/matplotlib/SKILL.md

Skill Metadata

Name
matplotlib
Description
Figure engineering patterns, gotchas, and reusable helpers for matplotlib. Use when directly building standalone figure scripts, diagram generators, or canvas visuals outside the epq render pipeline — e.g., generate_layer_icons.py, vision project banners, morning-brief charts, competitive analysis figures. Covers shape boundary math, arrow/edge geometry, series consistency, font registration, zorder, Unicode rendering, and canvas clipping. Distinct from the epq skill (which covers the Quarto/LaTeX analysis pipeline). Load alongside epq when figure work involves both the pipeline and standalone diagram construction.

Gotchas

ax.text() does not accept set_clip_on as a kwarg: Pass it after the fact — t = ax.text(...); t.set_clip_on(True). Passing as a keyword argument raises a TypeError silently or is ignored depending on matplotlib version.

ax.text with data-unit offsets causes figure height explosion: Fixed offsets expressed in data units (e.g., ax.text(x, opex_cursor - 2.0, ...)) work fine on charts with a large y-range but silently push the label far outside the visible axes on charts with a small range. Because ax.text() defaults to clip_on=False, bbox_inches='tight' includes out-of-bounds text in the figure bounding box — producing extreme canvas heights (11,000px+ instead of ~600px). This bites any figure function called for both large-range and small-range data with the same code path (e.g., a Core API chart at $150M range vs. a Concise chart at $0.8M range). Fix: use ax.annotate() with xytext in offset points so the anchor stays in data coordinates but the visual offset is scale-invariant:

# Before (broken for small y-range):
ax.text(x, opex_cursor - 2.0, f"−${total_opex:.1f}M", ha="center", va="top", ...)

# After (safe regardless of y-range):
ax.annotate(f"−${total_opex:.1f}M",
            xy=(x, opex_cursor), xycoords="data",
            xytext=(0, -6), textcoords="offset points",
            ha="center", va="top", ...)

The same applies to labels placed above a bar top: ax.text(x, total_rev + 0.5, ...) floats outside the plot frame on any chart where 0.5 data units exceeds the headroom. Companion risk: even at normal sizes, ax.text without clip_on=True renders outside the axes frame without error — prefer annotate with offset points for all bar-adjacent labels.

Unicode arrows ↑↓→ (U+2191/U+2193/U+2192) do not render: They appear as boxes or missing glyphs in matplotlib via Helvetica Neue or any lualatex-rendered font. Use ASCII +/-/> instead. Safe Unicode in lualatex prose and annotations: ≥ ≤ ≠ ≈ only; × (U+00D7, multiplication sign) is NOT in the Latin Modern font — write "times" or ASCII "x".

$ in f-strings gets consumed as LaTeX math: Inside any matplotlib text that passes through a LaTeX renderer, f"${val:.1f}M" renders a math-mode dollar sign, not a literal one. Escape with \\$: f"\\${val:.1f}M".

plt.savefig() must be called before plt.close(): Calling plt.close("all") first produces a blank/white PNG with no error. Canonical __main__ pattern:

render({})
plt.savefig(out, dpi=150, bbox_inches="tight")
plt.close("all")

The plt.close intercept pattern (overriding plt.close to prevent premature closing) is obsolete — do not use it. The render() function should never call plt.show() or plt.close(); those are the caller's responsibility.

In-bar label thresholds must be scale-relative, not absolute: A fixed dollar/unit threshold (e.g., _NAME_HEIGHT = 2.5) fails when charts have different y-ranges. $2.5M occupies ~1.2in in a 12M-range chart at 5.5in but only ~0.16in in an 80M-range chart at 6.5in — far too thin for a 2-line label. Pattern: max(abs_floor, total_range * fraction) where fraction is ~0.08–0.12. Or set per-function constants tuned to each chart's scale. Use three tiers: (1) name+amount inside if segment tall enough, (2) dollar-only inside if medium, (3) nothing inside + legend entry with amount if too thin. Move thin segment amounts to the legend label: f"Sales $3.9M". Self-review at code level won't reveal clipping — only the rendered PNG shows it.

Always read the output PNG before reporting done: After any plt.savefig() / uv run python script.py, use the Read tool on the generated PNG and inspect before declaring done. Check: (1) color-on-color invisibility — white icon on white background is invisible; (2) arrow/line clipping through icons — an icon centered at an arrow origin means the arrow runs through the icon; (3) stale visual artifacts from removed elements. Do NOT report "done" until the image has been visually inspected.

HandlerBase.create_artists() returning [] crashes legend_artist(): When building invisible legend entries (e.g., section-header labels with no color swatch), overriding create_artists to return an empty list raises IndexError: list index out of range because legend_artist() indexes into the result. Override legend_artist() directly instead:

from matplotlib.legend_handler import HandlerBase
from matplotlib.patches import Rectangle

class _InvisibleHandler(HandlerBase):
    def legend_artist(self, legend, orig_handle, fontsize, handlebox):
        patch = Rectangle([0, 0], 0, 0, visible=False,
                          transform=handlebox.get_transform())
        handlebox.add_artist(patch)
        return patch

Use as handler_map={MySentinel: _InvisibleHandler()}. The zero-size invisible patch satisfies matplotlib's contract; only the label text renders in the legend.

bbox_inches="tight" breaks series consistency: tight trims each figure to its content bounds independently, producing different output dimensions across figures with different content extents. For a series that must be identical pixel dimensions, use fig.subplots_adjust(left=0, right=1, top=1, bottom=0) to fill the full figure area and omit bbox_inches entirely: fig.savefig(path, dpi=N, facecolor=bg).

Legend below the x-axis (outside axes) for dense charts: When the chart body is too dense for an inside legend (Gantt timelines, many-row bar charts), place the legend below the x-axis with bbox_to_anchor:

ncols = min(len(handles), 4)
ax.legend(
    handles=handles,
    loc="upper center",          # anchors the TOP of the legend box
    bbox_to_anchor=(0.5, -0.04), # 0.5 = centered, -0.04 = just below x-axis
    ncols=ncols,
    fontsize=6,
    frameon=False,
    handlelength=1.2,
    handleheight=0.9,
)
plt.tight_layout()  # compatible here; bbox_inches="tight" in savefig captures the legend

Use loc="upper center" (not "lower center") — it anchors the TOP of the legend box at the bbox_to_anchor point, which places the legend just below the axis. With bbox_inches="tight" in savefig, the out-of-axes legend is captured automatically. Do not add subplots_adjust(bottom=...) alongside tight_layout — they fight (see rule below). For ≤3 entries use ncols=3 to keep it single-row.

tight_layout and subplots_adjust conflict — use one, not both: fig.tight_layout(rect=[...]) called after fig.subplots_adjust(...) silently overrides the manual adjustment. For charts that need precise margins (waterfalls, bar charts where the legend should align with bar edges), drop tight_layout entirely and use a single fig.subplots_adjust:

# WRONG — tight_layout wins, subplots_adjust is discarded
fig.subplots_adjust(bottom=0.18)
fig.tight_layout(rect=[0, 0.12, 1, 1])

# CORRECT
_bot = 0.20 if has_legend else 0.08
fig.subplots_adjust(left=0.10, right=0.99, bottom=_bot, top=0.93)

For bar/waterfall charts with integer x-positions (bars at 0…N-1), also set tight xlim so bars span the full plot width: ax.set_xlim(-0.45, n_bars - 0.55). Without it, matplotlib's default padding adds ~5% whitespace on each side, making the legend appear narrower than the chart.

Font family name after addfont() differs from filename: font_manager.fontManager.addfont(path) registers a font using its internal family name. "IBMPlexMono-Regular.ttf" registers as "IBM Plex Mono", not "IBMPlexMono". Verify before use: from matplotlib import font_manager as fm; fm.FontProperties(fname=path).get_name(). Using the wrong name silently falls back to the default font.

FancyBboxPatch round,pad=rad extends beyond the specified rect: FancyBboxPatch((cx-w/2, cy-h/2), w, h, boxstyle="round,pad=rad") draws a shape whose OUTER boundary extends rad units beyond the specified rect on all sides — actual extents are (cx ± w/2 ± rad, cy ± h/2 ± rad). Placing a box near a canvas edge silently clips the rounded corners without error. Before drawing, verify cy + h/2 + rad ≤ YM and cy - h/2 - rad ≥ 0 (and equivalent for x). Reduce h, w, or rad if clipping is possible. The bug is invisible in code and shows as flat/angled corners in the PNG.

Diagram Patterns

Geometric edge attachment for arrows: Hardcoded offsets for arrow endpoints drift inside or outside shape borders as layouts change. Compute intersection points from shape geometry instead:

def rect_edge(cx, cy, w, h, rad, tx, ty):
    """Outer AABB boundary of rounded rect toward (tx, ty)."""
    dx, dy = tx - cx, ty - cy
    dist = np.hypot(dx, dy)
    if dist < 1e-9: return cx, cy
    nx, ny = dx / dist, dy / dist
    ax, ay = w / 2 + rad, h / 2 + rad
    t = min(ax / abs(nx) if abs(nx) > 1e-9 else np.inf,
            ay / abs(ny) if abs(ny) > 1e-9 else np.inf)
    return cx + t * nx, cy + t * ny

def circ_edge(cx, cy, r, tx, ty):
    """Circle boundary toward (tx, ty)."""
    dx, dy = tx - cx, ty - cy
    dist = np.hypot(dx, dy)
    if dist < 1e-9: return cx + r, cy
    return cx + r * dx / dist, cy + r * dy / dist

Usage: s = rect_edge(src_cx, src_cy, src_w, src_h, src_rad, dst_cx, dst_cy) for the arrow tail; e = rect_edge(dst_cx, ...) for the arrowhead. Then arr(ax, *s, *e, ...). For a filled destination box (opaque fc), draw the box first at lower zorder, then the arrow at higher zorder so the arrowhead is visible at the boundary.

Diagram series consistency — anchor shared elements to module-level constants: Any element that must appear at the same position, size, or style across all frames in a series (e.g., N layer banners) must be defined as a module-level constant before the per-frame functions. Without this, per-function choices drift independently and realignment requires user correction. Pattern:

EP_X_STD = 6.20              # shared x-center across all frames
EP_W, EP_H, EP_RAD = 1.9, 1.05, 0.12  # shared size and pad

def ep_box(ax, cx, cy, ...):  # single drawing function used by all frames
    rr(ax, cx, cy, EP_W, EP_H, ...)

Any element the user might compare side-by-side across frames — position, size, stroke weight, color — should be a named constant, not a per-function literal.

Icon semantic value — don't annotate what the shape already communicates: Before placing an icon, ask whether it adds information not already conveyed by the shape's position, grouping, or context. Carrier dots in a fan pattern already communicate "carriers" — a van icon beside one dot reads as "one carrier is a van," not "these are carriers." Reserve icons for: (a) distinguishing actor type when ambiguous (e.g., shipper vs. carrier card in a bilateral layout), (b) labeling a resolved output state with no other visual identity, (c) providing a group label when the group has no enclosing shape. When in doubt, omit.

Geographic Map Patterns

International figures require world land-mass GeoJSON — US states GeoJSON produces empty space: Any figure that shows destinations outside the US (international shipping, global OD flow maps) needs a world countries GeoJSON, not the US states GeoJSON used for domestic maps. Using only US states causes all non-US geography to render as blank canvas with floating dots and arcs — visually indistinguishable from a broken figure.

  • Domestic-only maps: us-states.geojson (download from https://raw.githubusercontent.com/PublicaMundi/MappingAPI/master/data/geojson/us-states.json)
  • International or mixed maps: world-countries.geojson from Natural Earth or equivalent (e.g., https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson)
  • Cache both locally in data/figures/ and load via Path(__file__).parent.parent / "data" / "figures" / "world-countries.geojson" with a download fallback

Separate domestic and international into distinct figure panels or modules: Do not try to combine a US-scale map and a world-scale map into a single two-panel layout where the US panel is CONUS-extent and the world panel is also CONUS-extent. The projections fight — CONUS xlim (-130, -65) cuts off Atlantic destinations. Pattern that works:

  • Domestic figure: single CONUS panel, xlim=(-130, -65), ylim=(24, 50), US states GeoJSON
  • International figure: Mercator world extent, xlim=(-130, 10) covers US west coast through West Africa, ylim=(-5, 65) covers equatorial Africa through Canada, world countries GeoJSON

OD flow arc direction: Use matplotlib.patches.ConnectionPatch for arcs that cross figure panels, or FancyArrowPatch with connectionstyle="arc3,rad=0.15" for within-axes arcs. For eastward routes (US → Europe/Africa), set rad=0.15 to curve north over the Atlantic. For westward routes, negative rad curves south. Always set clip_on=False on arcs — they frequently cross axes boundaries and will be silently clipped otherwise.

Mercator y-projection for hand-rolled world maps: When not using cartopy, convert latitude to Mercator y manually:

import numpy as np

def _merc_y(lat_deg: float) -> float:
    lat_rad = np.radians(lat_deg)
    return np.log(np.tan(np.pi / 4 + lat_rad / 2))

Apply to all coordinate rings in the GeoJSON before drawing polygons. Apply to all destination/origin lat values before placing dots and arcs. xlim/ylim must be set in Mercator-y space, not degrees.