storytelling-viz: Production-Ready Charts
The goal is a chart that can be placed directly into a presentation, document, or journal, not a hastily drawn bar chart. It must satisfy three points:
- Correct chart type. The chart type matches what is being compared, the focus is clear, and direct labels are used instead of a legend.
- Does not crash on real data. Long labels, negative values, missing data, many categories, and outliers must all be displayed correctly.
- Matches output specifications. Dimensions, font sizes, line widths, format, and font embedding must all comply.
This skill is only about the chart. Topic selection, analysis, and report structure are outside its scope.
Workflow
- Identify the context. The context determines default conventions such as colors, footnotes, and numbers (see "Two Context Conventions"):
- Business Review: Charts for business stakeholders or management showing operational metrics, regardless of frequency or occasion (weekly report, monthly report, daily dashboard, anomaly attribution, management/board/strategy meeting operational data pages). The criterion is "is the content an operational metric, and will the reader make a business decision based on it?", not "is it a weekly report?".
- General / Publication: Charts for analysis or research documents, experimental results, web pages, paper figures, etc.
- When in doubt, treat it as a business review (the conventions are stricter, only requiring one extra footnote). For the business review context, you must first read
references/business.md, even if the example code seems sufficient.
- Select the function based on "what to compare." See the function selection table below; both contexts use the same table.
- Choose the output spec. Use
spec=(slide, document, journal column, etc.). - Generate the chart and review the automatic check results.
figure()/save()will printQA … okor list each warning; the.qareturn value contains the same list of issues (empty list = pass). If there are warnings, fix them; do not deliver. - Open the PNG and check against the checklist. Use Read to open the generated PNG and verify each item against the final checklist.
Quick Start
# /// script
# dependencies = ["plotnine>=0.15,<0.16", "pandas", "pillow", "pypdf"]
# ///
import sys; sys.path.insert(0, "<skill>/scripts")
from viz_common import figure, titled, set_lang, save
import charts as C # General chart functions
import biz as B # Business review chart functions (can be mixed with C)
set_lang("en") # or "zh" (default)
c = C.bar_rank(df, "channel", "gmv", highlight="POS", top_n=8, fmt="num")
figure([[(titled(c, title="POS is the only channel that shrank"), 1)]],
spec="doc", name="fig1", outdir="out")
save(c, "fig2", spec="journal-1col", outdir="out") # Journal single chart: no title
# Business review: same figure(), but you must write a metric footnote
k = B.kpi_cards([dict(label="GMV (MTD)", value=16.5e6, prev=15.9e6, target=57e6, progress=0.29)])
figure([[(k, 1)]], spec="doc", size=(6.5, 2), name="kpi", outdir="out",
caption="Basis: paid-order GMV (USD); MTD to Oct 9 vs Sep 1–9.")
Function Selection
Find the row based on what you are comparing. C. = charts.py, B. = biz.py; functions marked with ★ are designed for business review but can be used in any context.
| What to compare | Function | Notes |
|---|---|---|
| Ranking, composition at a point in time, positive/negative values | C.bar_rank(df, cat, value, highlight=, top_n=, fmt=) | Automatically sorts; merges categories beyond top_n into "Other"; value written inside or outside the bar |
| Who caused the change (scale + growth rate + contribution by dimension) | ★ B.contrib_table(df, dim, prev, cur, top_n=, growth_alert=) | Scale data bars, ▲▼ growth rate, change amount, share of total change; total row at top |
| Trend: 1–N lines together | C.line_trend(df, x, y, group=, highlight=, band=, ref=) | Direct labels at the end; automatic two-layer month labels for monthly data; breaks for missing data; band= for confidence intervals |
| Trend: multiple groups viewed separately | C.small_multiples(df, x, y, facet, highlight=) | All panels share the same y-axis scale; gray background lines are other groups |
| Trend: one operational metric + period-over-period comparison | ★ B.trend(df, date, value, compare="month"/"year", events=, as_of=, period_end=) | Automatically overlays aligned comparison lines; annotates events; dashed line + estimate for incomplete periods |
| Progress: cumulative value vs target | ★ B.cumulative_vs_target(df, date, value, target, start, end, as_of=, prior=) | Cumulative actual, target progress line, prior period cumulative, daily average-based estimate |
| Actual vs Target (multiple rows) | ★ B.bullet(df, cat, actual, target, progress=) | Actual bar + target vertical line + dashed "expected completion at current progress" line; rows behind progress turn red |
| Actual vs Range (prediction interval, normal range) | C.range_dot(df, cat, low, high, value, flag="below", bench=) | Out-of-range rows use focus color; bench= specifies a reference row |
| Two values per group (before/after, plan/actual) | C.dumbbell(df, cat, start, end, labels=, highlight=) | Open circle = start, filled circle = end; sorted by end value; each row shows change rate |
| First and last periods, large differences in group sizes | C.slope(df, cat, start, end, mode="change", groups=) | When mode="change", all lines start from 0%; rows with a start of 0 are removed and a WARNING is printed |
| Waterfall: previous period → contributions → current period | C.waterfall(df, cat, delta, start=, end_label=) | Increases/decreases use neutral color + positive/negative signs by default; pass up=PAL.good, down=PAL.bad when good/bad have business meaning |
| Metric decomposition (GMV = users × frequency × AOV) | ★ B.driver_tree(nodes) | Each node shows current value, ▲▼ change, contribution (logarithmic share for multiplication, change amount for addition) |
| Composition: percentages of multiple groups | C.stacked_100(df, cat, part, value, order=, focus_cat=) | Parts in natural order, single-color gradient; legend is in chart.legend |
| 2D table (group × time, group × group) | C.heatmap(df, x, y, value, fmt=) | White to single color, scale uses 2%–98% percentile; dark cells have white text |
| Scale + Ratio (application count and approval rate) | ★ B.bar_line_dual(df, x, bar, line, line_range=) | The only allowed dual-axis chart: scale bars (left) + ratio line (right); ratio axis starts at 0 or a fixed range |
| Distribution | C.distribution(df, value, group=, kind="box"/"strip"/"hist") | Sorted by median; whiskers at 1.5×IQR; sample points if many |
| Relationship between two continuous variables | C.scatter(df, x, y, label=, fit="linear", groups=, x_title=, y_title=) | OLS + confidence band, shows R²; only labels highlighted points; legend in chart.legend when groups= is used |
| Estimates with confidence intervals | C.point_range(df, cat, est, low, high, ref=0) | Forest plot; right side shows "estimate [lower, upper]" |
| Summary of a few key numbers (top of a report) | ★ B.kpi_cards([dict(label, value, prev, target, progress, spark, …), …]) | Large numbers, ▲▼ change, attainment rate vs time progress, mini sparkline; only turns red for anomalies |
★ Parameter details for functions (kpi_cards fields, driver_tree node structure, trend's events= / as_of=, cumulative_vs_target's prior=, anomaly rules, period-over-period alignment) are in references/business.md.
How to choose when two rows seem similar:
B.contrib_tablevsC.waterfall: If you need to show scale, growth rate, and share simultaneously, or if there are more than about 8 dimensions → use the table. If you want to explain "how the previous period step-by-step became the current period", with no more than about 8 items → use a waterfall chart. They are often used side-by-side on attribution pages.C.line_trendvsC.small_multiples: If there is one line to emphasize, or no more than 4 lines → useline_trend(for more than 4 lines, addhighlight=). If you need to see individual shapes of groups, and all groups have similar importance → usesmall_multiples. For a single journal column (3.5in), more than 5 facets won't fit; useline_trend+highlight=.- For two values per group, first check scale: if scales are similar (max / min < about 10x) → use
C.dumbbell. If they differ by more than an order of magnitude, small groups become hard to see on a shared scale; instead, directly plot the change rate: if no more than about 8 groups → useC.slope(mode="change"). For more groups → first calculate a change rate column, then useC.bar_rank(df, cat, "chg", fmt="pct"), and write the absolute scale into the labels or a side table.
Common parameters and return values for all functions:
highlight=: A category name, list, or function. Selected items use the focus color, others turn gray, and the category name is also bolded in the same color.fmt=:"num","pct","pp","int","raw", a format string, or a function;nd=controls decimal places.- Returns a panel object, to be placed directly into
figure()/save().chart + plotnine layerscan be further layered on top.chart.legendis a single-line color-word legend thattitled()uses automatically.
When there is no suitable function, draw it yourself:
- Use
C.Frame(w, h, xr, yr, left=…, …)to reserve whitespace around the plot based on the actual width of text (text_width). UseC.axis_range(lo, hi)for axis ranges. - On
C.Canvas(), draw elements usingrect,seg,pt,text,line,band, then generate the plot withcv.plot(fr). - Wrap it as
C.Chart(build), wherebuild(w, h)calculates the layout only after the final dimensions are known.
Do not hard-code coordinates or axis ranges in your code.
Two Context Conventions
The functions themselves draw the same way in both contexts; the difference lies in these default conventions. There is no "context" parameter in the code: ★ functions inherently use business review styling (▲▼, red only for anomalies) and will make figure() print a WARNING if a metric footnote is missing. For C. charts on the same page, you must pass the appropriate parameters according to the table below (e.g., caption=, highlight=, up= / down= colors).
| Item | General / Publication | Business Review |
|---|---|---|
| Increases/Decreases | Use good / bad only when good/bad has meaning, and must be accompanied by a sign or text | Always use ▲▼ + neutral color, never red/green for increases/decreases |
| When to use red | Only to indicate "bad" | Only when judged as anomalous (behind schedule / exceeding threshold / exceeding historical ±2σ); favorable anomalies are only bolded |
| Directing attention (e.g., channel dragging down growth) | highlight= (focus color orange) | Same; orange only means "look here," not good/bad; whether it is anomalous is expressed by ▲▼ and red |
| Metric footnote | Recommended | Required (caption=): numerator, denominator, currency, time window, comparison basis; missing it will cause figure() to emit a WARNING |
| Comparison basis | As needed for analysis | Default: month-over-month, current month cumulative vs same days of previous month, target attainment; daily traffic metrics aligned by day of week |
| Incomplete current period | Light color or dashed + "As of X date" | Same, plus draw an estimate for the full period scaled by progress |
| Number formatting | Per domain conventions | K/M/B for amounts; ratio changes in pp, growth rates in %; unit written in title or annotation |
| Dual axis | Avoid | Only "scale bar + ratio line" is allowed (B.bar_line_dual) |
| Pie / Donut charts | Avoid as much as possible | Forbidden |
| Common specs | doc, web, journal-* | Use slide for PPT; use doc for Feishu / Lark / Notion documents or chats (PNG illustration); use doc when the user has not specified the placement |
Reasons and counterexamples are in references/rules.md (business review sections are B1–B7).
Output Specifications (spec=)
| spec | Dimensions (inches) | Body font size | Default formats | Use case |
|---|---|---|---|---|
slide / slide-4x3 | 13.33×7.5 / 10×7.5 | 12 / 11.5 | Outline SVG, editable SVG, PNG (150dpi) | Presentation slides |
web | 8×4.5 | 10.5 | SVG, PNG (200dpi) | Web pages, emails |
doc | 6.5×4 | 9 | PDF, SVG, PNG (300dpi) | A single chart in a document body |
a4 / letter | Full page | 10 | PDF, SVG, PNG | Full-page report |
journal-1col / -1.5col / -2col | 3.5 / 5.35 / 7.2 wide | 7–7.5 | PDF, SVG, PNG (600dpi) | Journals (89 / 136 / 183 mm) |
size=(w, h)overrides the default dimensions, e.g., a taller journal figure or a shorter row of KPI cards.- Font sizes, line widths, and point sizes scale with the spec; PDF uses Type 42 embedded fonts, meeting journal requirements.
- Journal charts have no title; figure captions are placed in the text body. For a single chart: use
save(chart, name, spec="journal-1col"). For multi-panel figures: usefigure(rows, spec="journal-2col"), without passingtitle=, and usetitled(chart, title="a")for panel labels. figure()produces a whole page (optionally with title, subtitle, metric footnote, multiple panels).save(chart, "fig2", spec="journal-1col")exports only a single chart, without page title or footnote. Business review requires footnotes, so usefigure().- Two types of SVG:
<name>.svghas text converted to outlines, looking the same everywhere;<name>_editable.svgpreserves text, editable in Figma / PPT, but the recipient's computer must have the same fonts. Use the latter for editable text in PPT, otherwise use the former or PNG. slide/webdo not produce PDF by default; passformats=("pdf", "png")if needed.
Combining Multi-Panel Layouts
figure(rows, title=, subtitle=, caption=, spec=):rows=[[(panel, span), …], …], wherespanis the relative width within a row.- Row height is automatically allocated: rows containing only text or tables take height based on content; the remaining space is divided equally among the other rows.
titled(chart, title=, legend=, note=): Adds a panel title, legend row, and annotation above the chart. Iflegend=is not provided,chart.legendis used automatically.- Titles support rich text markup:
**bold**;[phrase](focus)makes it bold and colored. Colors can befocus,brand,good,bad,muted,ink, or#hex. - Other panels:
text_panel(para(...))for paragraphs,table_panel(rows, header=)for small tables (number columns automatically right-aligned),column(a, b)to stack two panels vertically. - Each panel is rendered individually at its final dimensions, so font sizes are in actual points. When stitching SVGs, element IDs are automatically prefixed to avoid conflicts.
Visual Rules (functions execute them by default; follow them when drawing manually)
- Color hierarchy:
focus(orange) marks the one and only thing to look at; it is not inherently good or bad.brand(dark blue) is for regular series. Gray is for backgrounds and references.good/badusage depends on context (see table above). - Same color, same meaning. Text keywords and the corresponding data use the same color. Elements with different meanings must not share a color.
- Text must be readable. Text contrast ratio must be at least 4.5:1. When using the focus color for text, automatically darken it with
readable(color). Text on dark backgrounds usesink_on(fill). Functions handle this automatically; when drawing manually,Canvas.textalso checks and warns. - Use direct labels instead of a legend. Axis titles must not be field names. If all bars are labeled with values, remove the y-axis and grid.
- Sorting must be meaningful. Items with a natural order (phases, grades) use that order; otherwise, sort by value. Place the focus item in the most prominent position. Do not sort alphabetically.
- Axis. Bar charts must start at 0. Line charts start at 0 when data is close to 0 (
zero="auto"). Related panels share the same scale. Dual axes are only allowed for "scale bar + ratio line". - Precision. Enough to distinguish. All numbers are calculated from data and formatted using
pct/pp/num/fmt_value; do not write them manually. - Choose the chart based on what you are comparing: Do not use an absolute stacked bar for proportion changes. Do not draw multiple pie charts for multi-group composition. When there are more than 4 lines, use
highlight=to focus or switch to small multiples. If comparing two items with a tree map or radar chart takes more than about 2 seconds, switch to horizontal bars. - Mark data issues on the chart. Incomplete current periods use a light color or dashed line with the note "As of X date". Write
nfor small samples. Break the line for missing data; do not connect it.
Details and rationale are in references/rules.md; design approaches from SWD chart remakes are in references/swd_practices.md.
Language and Typography
set_lang("zh" | "en")switches the default font, month labels (Chinese: "1月\n2026", English: "Jan\n2026"), and "其他/Other". Currency units default to K/M/B in both languages; useset_number_style("cn")for 万/亿. Labels containing Chinese characters automatically use a CJK font.T("其他", "Other")retrieves the string in the current language.- Chinese character strings are automatically processed by
zh(): punctuation converted to full-width, extra spaces next to full-width punctuation removed, URLs and abbreviations preserved. - Line-breaking rules: numbers and English words are not broken. Punctuation does not appear at the start of a line. Opening brackets are not left at the end of a line.
- The program prints the fonts used at startup. If
WARNING: no CJK fontappears, install Noto Sans SC.
Pre-Delivery Checklist
Automated checks (look at figure() output or .qa):
-
QA … ok: No overlapping text, no text outside the panel, no clipping by the plot area. - No contrast warnings, and no warnings like
rows do not fit,labels take most of the panel,cells too small,basis caption.
Manual checks (open the PNG):
- Cover the title, look only at the chart: can you say what it is about? Is there only one focus?
- Do the numbers in the title and annotations come from the same variable as the values on the chart?
- Does the chart type match what is being compared (see function selection table)? Is the sorting meaningful? Are related panel scales consistent?
- Does color usage follow the context conventions? If the color were removed, can good/bad or increases/decreases still be read from symbols or text?
- Are there no default legends, field names, 3D effects, or gradients?
- Do the dimensions and font sizes match the target specification (journal body ~7pt, slides ≥12pt)? If PDF was needed, has it been exported?
Additional for business review:
- Is the metric footnote complete? Has it been cross-checked with other reports (within the same report, the same number is consistent across pages)?
- Are units, decimal places, and formatting consistent throughout the chart?
Environment and Testing
- Dependencies:
plotnine>=0.15,<0.16(verified with 0.15.8 + matplotlib 3.11),pandas,pillow, pluspypdffor PDF export. Code is inscripts/viz_common.py(low-level tools, specs, export, QA),scripts/charts.py(C.), andscripts/biz.py(B.). - Output location: Set
outdirto a directory the user can see; do not write into the skill directory itself. - Example galleries:
uv run references/gallery.py [outdir](everyC.function, Chinese slides + English journal).uv run references/business_gallery.py [outdir](everyB.function, Chinese slides + English document). Default output isout/in the current directory. - Regression tests:
uv run references/tests/edge_cases.pyruns 38 sets of edge-case data under two specs (76 total), checking that functions and QA behave as expected. You must rerun this after changing any chart function; all tests must pass before releasing. - When plotnine cannot be installed in the sandbox: Use the matplotlib simulator in
PYTHONPATH=<skill>/scripts/plotnine_previewfirst to preview layout and run QA (line width and clipping rules are the same as plotnine). The simulator'sQA okdoes not count as a delivery standard: pass the script to the user to run locally viauv run, and explicitly tell the user this chart has not been rendered or checked with real plotnine.