visualization-patterns · git:20260828.0c22157 · 2026-08-28 · sha256 80bff971ea368f9a

visualization-patterns git:20260828.0c22157A

Immutable. This exact content is served forever at /api/v1/blob/80bff971ea368f9a.

---
name: visualization-patterns
description: |
  Apply this skill whenever you generate ANY chart, graph, or data visualization in this AI Product Analyst tool. This includes bar charts, line charts, scatter plots, heatmaps, funnel charts, distribution plots, or any visual representation of data. Use this skill when creating charts from SQL query results, when the user asks to "visualize" data, when building analysis deliverables that include charts, when the Chart Maker agent runs, when storytelling requires visual evidence, when comparing segments, when showing trends over time, or when illustrating findings. ALWAYS trigger for requests like "make a chart", "show me a graph", "visualize the funnel", "plot revenue over time", "create a distribution", or "build a dashboard". This skill enforces Storytelling with Data (SWD) methodology: gray everything first, color only for focus (max 2 colors + gray), action titles that state the takeaway, direct labels instead of legends, decluttered design, and theme consistency. Apply the appropriate theme (minimal, nyt, economist, corporate) and use the helper functions from `helpers/viz/chart_helpers.py` (swd_style, highlight_bar, highlight_line, action_title, save_chart). This skill ensures every chart tells a clear story and follows professional design standards. DO NOT skip this skill when charting — it prevents common visualization mistakes like rainbow palettes, pie charts, descriptive titles, cluttered layouts, missing annotations, and default matplotlib styling.
---

# Skill: Visualization Patterns

## Purpose
Ensure every chart Claude Code produces follows high-quality design standards with named themes, consistent styling, and clear data communication.

## When to Use
Apply this skill whenever generating a chart, graph, or data visualization.

### When someone asks for a pie chart

Do not make the pie. Make the sorted bar (or a single stacked bar for parts of a whole), say
in one sentence why (angles and areas are hard to compare; a bar shows the same shares with
direct labels), and offer the pie only if they insist after seeing the bar. Producing both
"to be safe" is not the standard; the bar is the deliverable.

### Default Theme: Minimal

When no theme or palette is set, use the default styling without asking; offer
palette options only when the user asks about themes or colors.

Unless the user specifies otherwise, ALWAYS use the **minimal** theme. It's clean, professional, and suitable for most business contexts:
- Warm off-white background (#F7F6F2) for reduced eye strain
- Focus blue accent (#0072B2, an Okabe-Ito color) for the one element the takeaway argues
- Helvetica font family
- Left-aligned titles, minimal gridlines

The `swd_style()` function automatically loads the minimal theme. Other available themes: `nyt`, `economist`, `corporate` (see Theme Definitions section).

## Instructions

### STEP 1: Mandatory Helper Import (DO THIS FIRST)

Before writing ANY charting code, you MUST import and use the SWD helper functions. These functions implement all SWD principles automatically and prevent you from reinventing the wheel.

```python
from helpers.viz.chart_helpers import (
    swd_style,        # Apply SWD matplotlib theme
    highlight_bar,    # Bar chart with one bar highlighted
    highlight_line,   # Line chart with one series highlighted
    action_title,     # Action title + subtitle
    save_chart        # Save with correct DPI and tight layout
)

# Apply SWD style FIRST (loads .mplstyle, returns color palette)
colors = swd_style()
```

**If `helpers/viz/chart_helpers.py` doesn't exist:** Inform the user that chart helpers are missing and you'll need to implement SWD principles manually. Then proceed with manual matplotlib following the SWD principles below.

### STEP 2: Choose Your Helper Function

Use the pre-built helpers instead of manual matplotlib code:

| Chart Type | Helper Function | Example Usage |
|------------|----------------|---------------|
| **Bar chart** | `highlight_bar()` | `fig, ax = plt.subplots(figsize=(10, 6))`<br>`highlight_bar(ax, categories=['Desktop', 'Tablet', 'Mobile'], values=[4.6, 4.1, 3.4], highlight='Desktop')`<br>`action_title(ax, 'Desktop converts best at 4.6%')` |
| **Line chart** | `highlight_line()` | `fig, ax = plt.subplots(figsize=(10, 6))`<br>`highlight_line(ax, x=months, y_dict={'Revenue': revenue_values}, highlight='Revenue')`<br>`action_title(ax, 'Revenue grew 43% after pricing launch')` |
| **Title only** | `action_title()` | `action_title(ax, title='Finding here', subtitle='Context: time range, data source, sample size')` |

**Key points:**
- Helpers take a matplotlib `ax` object + arrays of data (not DataFrames)
- Always create the figure first: `fig, ax = plt.subplots(figsize=(10, 6))`
- Helpers automatically apply: gray + accent color, direct labels, SWD styling
- The `highlight` parameter specifies which category/series to emphasize

**For funnel charts, heatmaps, or custom visualizations:** Use manual matplotlib but apply `swd_style()` first and follow the SWD principles below.

### STEP 3: Save Chart to Correct Location

```python
# Final deliverable charts
save_chart(fig, "outputs/conversion_by_device.png")

# Exploratory/intermediate charts
save_chart(fig, "working/exploration_chart.png")
```

**Naming convention:** `{metric}_{dimension}_{chart_type}.png` (e.g., `revenue_trends_line.png`)

### Pre-flight: Load Learnings (Optional)
Check `.knowledge/learnings/index.md` for relevant entries:
- Read the file. If it doesn't exist or is empty, skip silently.
- Scan for entries under **"Chart Style"** and **"General"** headings.
- If entries exist, incorporate them as constraints (e.g., preferred chart types, color overrides).
- Never block execution if learnings are unavailable.

### Core Principle: Storytelling with Data (SWD)

Every chart follows the SWD methodology by Cole Nussbaumer Knaflic:

> **Gray everything first. Color is reserved for the one data point that tells the story.**

- Mostly gray. One focus accent, blue (`#0072B2`), for the element the takeaway argues; a second accent, orange (`#D55E00`), only for a genuine two-focal or good-vs-bad case. Both are Okabe-Ito colors, so the pair is colorblind-safe (blue vs orange, never red vs green). Everything else is gray. Use up to 5 Okabe-Ito categoricals ONLY when categories are truly independent; more than that is a signal to rethink the chart, never to add hues. (Amber `#D97706` stays the BRAND color for decks and thumbnails; it is retired from the chart focus role.)
- **Titles state the takeaway**, not a description. "iOS drove the June ticket spike" not "Tickets by Platform."
- Every visual element must earn its place — if it doesn't help the reader understand the story, remove it.
- Prefer text over charts for single numbers. Prefer horizontal bars over pie charts. Prefer direct labels over legends.

**Why use the helpers:** They enforce these principles automatically. Manual matplotlib code often forgets to remove borders, uses rainbow colors, or includes legends. The helpers prevent these mistakes.

### Declutter Checklist

Before finalizing **any** chart, verify each item:

- [ ] Chart border / box — removed entirely
- [ ] Top and right spines — removed (keep only bottom and left)
- [ ] Heavy gridlines — removed or very light gray (`#E5E7EB`), y-axis only
- [ ] Data markers — removed from line charts (the line *is* the data)
- [ ] Legend — replaced with direct labels on the data
- [ ] Rotated axis text — if labels need rotation, switch to horizontal bars
- [ ] Trailing zeros — use `$45` not `$45.00`; use `12%` not `12.0%`
- [ ] 3D effects — never
- [ ] Background color — always warm off-white (`#F7F6F2`)
- [ ] Redundant axis labels — if the title says "Revenue ($M)", the y-axis doesn't need "Revenue in Millions of Dollars"
- [ ] Excessive tick marks — reduce to 4-6 ticks maximum
- [ ] Decimal precision — match the precision to the decision (`12%` not `12.347%`)

### Chart Sequencing (Multi-Chart Analyses)

When producing multiple charts for a deep dive or root cause investigation, follow **Context → Tension → Resolution**:

| Phase | Charts | Purpose | Example |
|-------|--------|---------|---------|
| **Context** | 1-2 | Set the baseline. What does normal look like? | "[Dataset] processes ~4,000 support tickets per month" |
| **Tension** | 2-3 | Reveal the problem. Progressively zoom in. | "June spiked to 6,200" → "The spike was iOS payment issues" |
| **Resolution** | 1-2 | Explain why and recommend action. | "iOS v2.3 introduced a bug → fix eliminates ~2,200 tickets/mo" |

- Each chart builds on the previous one
- Never show a chart that makes the audience ask "so what?"
- The number of charts is determined by the storyboard. Each narrative beat that requires a visualization becomes a chart.
- The final chart should make the recommended action obvious

### Chart Helper Functions Reference

All chart helpers live in `helpers/viz/chart_helpers.py`. The style file is `helpers/viz/analytics_chart_style.mplstyle`. The full style guide with before/after examples is in `helpers/viz/chart_style_guide.md`.

| Function | Purpose | Key Args |
|----------|---------|----------|
| `swd_style()` | Apply SWD matplotlib style, return color palette | — |
| `highlight_bar()` | Bar chart with one bar highlighted, rest gray | `highlight=`, `horizontal=True`, `sort=True` |
| `highlight_line()` | Line chart with one line colored, rest gray | `highlight=`, `y_dict={}` |
| `action_title()` | Bold takeaway title + optional subtitle | `title`, `subtitle=` |
| `annotate_point()` | Clean annotation with arrow | `x`, `y`, `text`, `offset=` |
| `save_chart()` | Tight layout + correct DPI | `fig`, `path`, `dpi=150` |
| `stacked_bar()` | Stacked / 100% stacked (`normalize=True`) bar | `highlight_layer=`, `normalize=` |
| `share_bar()` | Single horizontal 100% stacked bar (pie replacement) | `parts={}`, `highlight=` |
| `slope_chart()` | Two-time-point change across items | `start_col`, `end_col`, `highlight_label=` |
| `funnel_waterfall()` | Funnel drop-off; highlights the biggest drop | `highlight_step=` |
| `retention_heatmap()` | Cohort retention as a blue-sequential table | keeps numbers in cells |
| `big_number()` | One number as text (the no-chart default for 1-2 numbers) | `value`, `label=`, `delta=` |
| `bullet()` | One metric vs target (gauge replacement) | `value`, `target`, `ranges=` |
| `end_label()` | Direct end-of-line label (replaces a legend entry) | `x`, `y`, `text`, `color=` |
| `reference_line()` | Goal/threshold line with an inline label | `value`, `label=`, `orient=` |

### Theme Definitions

#### Theme: `nyt` (New York Times)
```python
NYT_THEME = {
    "colors": {
        "primary": "#000000",
        "secondary": "#666666",
        "accent": "#D03A2B",
        "palette": ["#D03A2B", "#1A6B54", "#3D6CA3", "#E8912D", "#8B5E3C", "#6B4C9A"],
        "background": "#FFFFFF",
        "grid": "#E5E5E5",
    },
    "fonts": {
        "title": {"family": "Georgia", "size": 18, "weight": "bold"},
        "subtitle": {"family": "Arial", "size": 12, "weight": "normal", "color": "#666666"},
        "axis_label": {"family": "Arial", "size": 10},
        "annotation": {"family": "Arial", "size": 9, "style": "italic"},
    },
    "grid": {"show": True, "axis": "y", "style": "--", "alpha": 0.3},
    "annotations": {"style": "minimal", "callout_arrows": True},
    "title": {"position": "left-aligned", "include_subtitle": True},
}
```

#### Theme: `economist` (The Economist)
```python
ECONOMIST_THEME = {
    "colors": {
        "primary": "#1F2E3C",
        "secondary": "#7C8A96",
        "accent": "#E3120B",
        "palette": ["#E3120B", "#1F6ED4", "#36B37E", "#F5A623", "#6554C0", "#00B8D9"],
        "background": "#D7E4E8",
        "grid": "#FFFFFF",
    },
    "fonts": {
        "title": {"family": "Helvetica", "size": 16, "weight": "bold"},
        "subtitle": {"family": "Helvetica", "size": 11, "weight": "normal"},
        "axis_label": {"family": "Helvetica", "size": 9},
        "annotation": {"family": "Helvetica", "size": 8},
    },
    "grid": {"show": True, "axis": "y", "style": "-", "alpha": 0.5, "color": "#FFFFFF"},
    "annotations": {"style": "inline", "red_highlight": True},
    "title": {"position": "left-aligned", "red_bar_top": True},
}
```

#### Theme: `minimal`
```python
MINIMAL_THEME = {
    "colors": {
        "primary": "#1F2937",
        "secondary": "#4B5563",
        "accent": "#0072B2",
        "palette": ["#0072B2", "#D55E00", "#009E73", "#CC79A7", "#404040"],
        "background": "#FFFFFF",
        "grid": "#F0F0F0",
    },
    "fonts": {
        "title": {"family": "Helvetica", "size": 14, "weight": "bold"},
        "subtitle": {"family": "Helvetica", "size": 10, "weight": "normal", "color": "#666666"},
        "axis_label": {"family": "Helvetica", "size": 9},
        "annotation": {"family": "Helvetica", "size": 8},
    },
    "grid": {"show": True, "axis": "y", "style": "-", "alpha": 0.15},
    "annotations": {"style": "minimal", "direct_labels": True},
    "title": {"position": "left-aligned", "include_subtitle": True},
}
```

#### Theme: `corporate`
```python
CORPORATE_THEME = {
    "colors": {
        "primary": "#1B2A4A",
        "secondary": "#5A6B7F",
        "accent": "#0066CC",
        "palette": ["#0066CC", "#00A651", "#FF6600", "#CC0000", "#9933CC", "#00CCCC"],
        "background": "#FFFFFF",
        "grid": "#E8E8E8",
    },
    "fonts": {
        "title": {"family": "Arial", "size": 16, "weight": "bold"},
        "subtitle": {"family": "Arial", "size": 11, "weight": "normal"},
        "axis_label": {"family": "Arial", "size": 10},
        "annotation": {"family": "Arial", "size": 9},
    },
    "grid": {"show": True, "axis": "both", "style": "-", "alpha": 0.2},
    "annotations": {"style": "callout", "box_highlight": True},
    "title": {"position": "center", "include_subtitle": True},
}
```

### Applying a Theme (matplotlib)

```python
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker

def apply_theme(fig, ax, theme):
    """Apply a named theme to a matplotlib figure."""
    fig.patch.set_facecolor(theme["colors"]["background"])
    ax.set_facecolor(theme["colors"]["background"])

    # Title styling
    ax.set_title(
        ax.get_title(),
        fontfamily=theme["fonts"]["title"]["family"],
        fontsize=theme["fonts"]["title"]["size"],
        fontweight=theme["fonts"]["title"]["weight"],
        loc="left" if theme["title"]["position"] == "left-aligned" else "center",
        pad=15,
    )

    # Grid
    if theme["grid"]["show"]:
        ax.grid(
            axis=theme["grid"]["axis"],
            linestyle=theme["grid"]["style"],
            alpha=theme["grid"]["alpha"],
            color=theme["colors"].get("grid", "#E0E0E0"),
        )
        ax.set_axisbelow(True)

    # Clean spines
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    ax.spines["left"].set_alpha(0.3)
    ax.spines["bottom"].set_alpha(0.3)

    # Axis labels
    ax.xaxis.label.set_fontfamily(theme["fonts"]["axis_label"]["family"])
    ax.xaxis.label.set_fontsize(theme["fonts"]["axis_label"]["size"])
    ax.yaxis.label.set_fontfamily(theme["fonts"]["axis_label"]["family"])
    ax.yaxis.label.set_fontsize(theme["fonts"]["axis_label"]["size"])

    plt.tight_layout()
```

### Chart Type Selection

| Data Relationship | Chart Type | When to Use |
|---|---|---|
| **Comparison** (categories) | Bar chart (vertical) | Comparing ≤12 categories |
| **Comparison** (many categories) | Bar chart (horizontal) | Comparing >7 categories or long labels |
| **Comparison** (parts of whole) | Stacked bar | Showing composition across categories |
| **Change over time** | Line chart | Continuous time series, trends |
| **Change over time** (few periods) | Bar chart | Discrete periods (quarters, years) |
| **Correlation** | Scatter plot | Relationship between two continuous variables |
| **Distribution** | Histogram | Single variable distribution |
| **Distribution** (compare groups) | Box plot or violin | Distribution comparison across groups |
| **Proportion / parts-to-whole** | Single 100% stacked bar (`share_bar()`) | Replaces the pie/donut; direct-labeled shares |
| **Flow/Process** | Funnel chart | Conversion or drop-off rates |
| **Intensity** | Heatmap | Two categorical dimensions + one value |
| **Cumulative** | Area chart | Running totals over time |
| **Ranking changes** | Bump chart | Rank position changes over time |
| **Waterfall** | Waterfall chart | Additive/subtractive contributions |

**Pick by intent, then encode by length or position, never area or angle.** Cleveland and McGill's
graphical-perception ranking (position > length > angle > area) is why bars beat pies and why the
avoid-list below exists. One or two numbers are not a chart: use `big_number()`. Discouraged charts
(pie, donut, treemap, bubble, dual/secondary y-axis, 3D, radar, truncated bars, >5 series) are gated
behind an explicit user request, never a default.

**Builder verdicts (helpers/viz/chart_helpers.py):** horizontal bar is the default for long labels
(zero baseline enforced); multi-line-with-one-highlighted (`highlight_line`) is the default line
behavior (gray context + one accent, cap 4-5 lines); `slope_chart` is preferred for two-time-point
change; `share_bar` replaces the pie; `retention_heatmap` is a blue-sequential table (numbers kept in
cells); stacked bars are for when the TOTAL is the message, with the priority series on the baseline;
stacked/multi-series area is discouraged (redirect to a line or 100% stacked bar).

### Annotation Standards

1. **Always label key data points directly** — do not rely on legends for primary story elements
2. **Use direct labels** on bars and line endpoints instead of requiring axis reading
3. **Annotate inflection points** — mark where trends change with a brief note
4. **Titles are takeaways, not descriptions** — "Revenue grew 23% after launch" not "Revenue by Month". `action_title()` warns when a title reads as a topic ("... by X") or a question; rewrite it to state the so-what before shipping.
5. **Subtitles provide context** — "Monthly revenue, Jan–Dec 2025, in $M"
6. **Source line** at bottom-left in small gray text
7. **Format numbers for readability** — "$1.2M" not "$1,234,567"; "23%" not "0.2345"
8. **Gray plus at most 2 accents** (blue focus, optional orange) — up to 5 Okabe-Ito categoricals only when categories are truly independent; never a rainbow
9. **Highlight the story** — use accent color for the key data point, gray for context

### Standard Chart Setup

```python
def create_chart(data, chart_type, theme_name="minimal", title="", subtitle=""):
    """Standard chart creation pattern."""
    theme = {"nyt": NYT_THEME, "economist": ECONOMIST_THEME,
             "minimal": MINIMAL_THEME, "corporate": CORPORATE_THEME}[theme_name]

    fig, ax = plt.subplots(figsize=(10, 6))
    fig.patch.set_facecolor(theme["colors"]["background"])
    ax.set_facecolor(theme["colors"]["background"])

    # Plot data using theme colors
    colors = theme["colors"]["palette"]

    # Set title as takeaway
    ax.set_title(title, fontfamily=theme["fonts"]["title"]["family"],
                 fontsize=theme["fonts"]["title"]["size"],
                 fontweight=theme["fonts"]["title"]["weight"],
                 loc="left", pad=20)
    # Subtitle
    if subtitle:
        ax.text(0, 1.02, subtitle, transform=ax.transAxes,
                fontfamily=theme["fonts"]["subtitle"]["family"],
                fontsize=theme["fonts"]["subtitle"]["size"],
                color=theme["fonts"]["subtitle"].get("color", "#666666"))

    apply_theme(fig, ax, theme)
    return fig, ax
```

## Examples

### Example 1: Bar chart with NYT theme
```python
fig, ax = plt.subplots(figsize=(10, 6))
categories = ["Mobile", "Desktop", "Tablet"]
values = [45, 35, 20]
colors = ["#D03A2B", "#666666", "#666666"]  # Accent on key finding

bars = ax.bar(categories, values, color=colors, width=0.6)
# Direct labels
for bar, val in zip(bars, values):
    ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 1,
            f"{val}%", ha="center", fontsize=12, fontweight="bold")

ax.set_title("Mobile drives nearly half of all sessions", loc="left",
             fontfamily="Georgia", fontsize=18, fontweight="bold")
ax.set_ylabel("")
ax.set_ylim(0, 55)
apply_theme(fig, ax, NYT_THEME)
```

### Example 2: Line chart with annotations
```python
fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(dates, revenue, color="#2563EB", linewidth=2)
# Annotate the inflection point
ax.annotate("Feature launch\n+23% MoM", xy=(launch_date, launch_value),
            xytext=(launch_date - timedelta(days=30), launch_value + 50000),
            fontsize=9, fontstyle="italic",
            arrowprops=dict(arrowstyle="->", color="#666666"))
# Direct label on endpoint
ax.text(dates[-1], revenue[-1], f"${revenue[-1]/1e6:.1f}M",
        fontsize=11, fontweight="bold", va="bottom")
ax.set_title("Revenue grew 23% after feature launch", loc="left")
apply_theme(fig, ax, MINIMAL_THEME)
```

### Example 3: Highlighting one segment
```python
# Use accent for the key finding, gray for everything else
colors = ["#E0E0E0"] * len(categories)
colors[key_index] = theme["colors"]["accent"]  # Highlight the story
```

## Anti-Patterns (Banned)

| Anti-Pattern | Why It's Bad | Use Instead |
|--------------|-------------|-------------|
| **Pie charts** | Humans can't compare angles accurately | Horizontal bar chart |
| **Rainbow palettes** | No natural ordering, visual noise, not colorblind-safe | Gray + one highlight color (max 2 colors + gray) |
| **Spaghetti lines** | Too many colored lines, nothing stands out | `highlight_line()` — gray all, highlight one |
| **Dual y-axes** | Misleading — any two series can be made to "correlate" | Two separate charts, stacked vertically |
| **3D charts** | Distorts proportions, adds no information | Flat 2D versions |
| **Descriptive titles** | Don't tell the reader what to think | Action titles via `action_title()` |
| **Legend boxes** | Force the reader to look away from the data | Direct labels on the data |
| **Excessive gridlines** | Create visual clutter | Light y-axis gridlines only, or none |
| **Truncated y-axes** | Exaggerate small differences (for bar charts) | Start at zero for bar charts |
| **Cluttered annotations** | Annotating every data point defeats the purpose | Annotate only the story |
| **Default matplotlib styling** | Looks generic, unprofessional | Always apply `swd_style()` first |
| **Rainbow / red-green pairs** | Visual noise; red-green is unreadable for ~8% of men | Gray + focus blue + optional orange (Okabe-Ito) |

## Review Checklist

Before including any chart in an analysis:

- [ ] Title states the takeaway (not a description)
- [ ] Only 1-2 colors used (plus gray)
- [ ] No chart border, no top/right spines
- [ ] Direct labels instead of legend
- [ ] Gridlines removed or very light
- [ ] Axis labels are clean (no rotation, no trailing zeros)
- [ ] Annotations are minimal and support the story
- [ ] Chart type matches the data relationship
- [ ] A single number isn't charted — it's displayed as text
- [ ] The chart would be understood in 5 seconds
- [ ] YoY comparisons use lines (not two similar-colored bars)
- [ ] Labels don't collide with bars, axes, or other labels
- [ ] External context events have prominent bbox annotations
- [ ] Multi-panel charts with fig-level titles use direct `savefig()` (not `save_chart()`)