--- name: visualization-patterns description: | 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: 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))`
`highlight_bar(ax, categories=['Desktop', 'Tablet', 'Mobile'], values=[4.6, 4.1, 3.4], highlight='Desktop')`
`action_title(ax, 'Desktop converts best at 4.6%')` | | **Line chart** | `highlight_line()` | `fig, ax = plt.subplots(figsize=(10, 6))`
`highlight_line(ax, x=months, y_dict={'Revenue': revenue_values}, highlight='Revenue')`
`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()`)