# Visualization Patterns

> Apply whenever you generate any chart, graph, or data visualization — from SQL results, in the chart-maker agent, in decks, or on "make a chart / visualize / plot / dashboard" requests. Enforces Storytelling With Data: gray first, one focus color, action titles, direct labels, no pies, using the helpers in helpers/viz/chart_helpers.py.

- Skill: `ai-analyst-lab/visualization-patterns` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ai-analyst-lab/visualization-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ai-analyst-lab/visualization-patterns/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- Author: ai-analyst-lab (https://skillmd.com/u/ai-analyst-lab)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ai-analyst-lab/visualization-patterns

---


# 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.

The **minimal** theme is 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: Import the SWD helpers

Start every chart with the helpers in `helpers/viz/chart_helpers.py`; they set the R3 background, gray-plus-accent palette, direct labels and spine cleanup that the checkpoints verify.

```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` is the deck/thumbnail brand color; it is not a chart focus color.)
- **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": "#F7F6F2",
        "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 one highlighted category
```python
fig, ax = plt.subplots(figsize=(10, 6))
colors = swd_style()
highlight_bar(ax, categories=["Mobile", "Desktop", "Tablet"], values=[45, 35, 20], highlight="Mobile")
action_title(ax, "Mobile drives nearly half of all sessions",
             subtitle="Share of sessions, Jan–Dec 2025")
save_chart(fig, "outputs/charts/sessions_by_device_bar.png")
```

### Example 2: Line chart with an annotated inflection point
```python
fig, ax = plt.subplots(figsize=(10, 6))
colors = swd_style()
highlight_line(ax, x=months, y_dict={"Revenue": revenue}, highlight="Revenue")
annotate_point(ax, x=launch_month, y=launch_value, text="Feature launch\n+23% MoM")
action_title(ax, "Revenue grew 23% after feature launch",
             subtitle="Monthly revenue, Jan–Dec 2025, in $M")
save_chart(fig, "outputs/charts/revenue_trend_line.png")
```

### 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()`)

