Imported from lanej/dotfiles (
claude/skills/matplotlib/SKILL.md). Install upstream withnpx skills add lanej/dotfiles --skill matplotlib. Copyright stays with the author.
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 fromhttps://raw.githubusercontent.com/PublicaMundi/MappingAPI/master/data/geojson/us-states.json) - International or mixed maps:
world-countries.geojsonfrom 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 viaPath(__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.