Skip to content
Back to Engine RoomPDF Chart Style Guide

One grammar for every chart in the dossier.

Every chart in a generated PDF follows the same rules for axis labelling, units, legends, and colour meaning. Reviewers should never have to ask “what does the green bar mean here?” The contract is centralised in src/lib/biosync/pdf-charts.ts — this page documents it.

Anatomy

Where each label lives.

yLabel ─┐                                    ← top-left, horizontal
        £ per year
        ┃ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─    ← gridlines (hair, dashed)
        ┃    ┃           ┃           ┃
        ┃    ┃           ┃    +£24k  ┃    ← inline value labels (fmtCurrency)
        ┃    ▓▓          ▓▓          ▓▓
        ┃    ▓▓          ▓▓          ▓▓
        └────┴───────────┴───────────┴───
           Stormwater  Thermal     Carbon
                    PES stream            ← xLabel (centred under axis)

        ▇ Stream contribution  ▇ Engine total   ← legend strip, bottom-left
  • · yLabel sits horizontally in the top-left padding — never rotated (rotated SVG text drifted in react-pdf renders).
  • · xLabel sits centred below the axis tick row.
  • · Units belong inside the y- or x-label, in parentheses — never on individual tick marks.
  • · Legend is a single wrapping strip at the bottom of every chart that has more than one colour.
Numeric formatting

One fmtNumber, applied everywhere.

Every axis tick, legend value, and inline label routes through fmtNumber (or its currency-aware companion fmtCurrency). Domain-specific formatters — years, NDVI, °C — are the only allowed overrides.

RuleRenders as
|v| ≥ 1B1.2B
|v| ≥ 1M1.2M
|v| ≥ 10k27k
|v| ≥ 1k1.2k
|v| ≥ 11,234 (en-US separator)
|v| < 10.42 (2 dp, trimmed)
Currency£412k via fmtCurrency(cur, v)
NaN / Infinity— (em dash)
Colour meaning

Colour carries semantics, not decoration.

A reader should never need a legend to know the difference between a contribution and a total, or an upside and a downside. The palette below is the only one allowed inside a chart plot area.

TokenRole
brand · Pulse green
#22c55e
Primary series, positive deltas, contribution bars, projected trajectories. The 'good news' colour.
negative · Red
#ef4444
Downside bound in tornadoes, negative deltas, risk-flagged findings. Never used for primary series.
ink · Slate-900
#0f172a
Totals, engine outputs, KPI numerals. Signals 'this is the headline value, not a contribution'.
muted · Slate-500
#64748b
Axis tick labels, legend captions, secondary text. Never used for data marks.
subtle · Slate-400
#94a3b8
Axis frame lines. Lighter than muted to recede behind the data.
hair · Slate-200
#e2e8f0
Gridlines (always dashed, 0.3pt). Sets the rhythm without competing with marks.
surface · Slate-50
#f8fafc
Optional plot background. Used inside KPI tiles, never inside the plot area of a data chart.
Per-chart contract

Default labels and legends, chart by chart.

If you're adding a chart to a report section, copy the matching row. If a chart you're building doesn't fit any of these, surface it for review before shipping — divergence is the bug.

BarChart

pdf-charts.ts

Single-category magnitude comparison (e.g. AAL avoided per peril, social value by MAC theme).

xLabel
Category dimension (e.g. "Peril", "MAC theme").
yLabel
Quantity with currency / unit (e.g. "AAL avoided (£ / yr)").
Legend
One item naming the series; bar colour = brand.
Value labels
On top of each bar, formatted with fmtNumber / fmtCurrency.

Waterfall

pdf-charts.ts

Decomposing a total into additive streams (e.g. PES avoided cost: Stormwater + Thermal + Social + Carbon → Total).

xLabel
Stream name (e.g. "PES stream").
yLabel
Running currency total (e.g. "£ per year").
Legend
Two items — "Stream contribution" (brand green) and "Annual engine total" (ink).
Value labels
Signed delta above each step (+£142k); absolute value on the total bar.

TornadoChart

pdf-charts.ts

Two-sided sensitivity (NPV swing per driver, downside vs upside).

xLabel
Swing axis (e.g. "NPV swing (£)"). Zero line is centred and bold.
yLabel
Driver labels run inside the plot area on the left.
Legend
"Downside (low bound)" in red, "Upside (high bound)" in green.
Value labels
Endpoint labels on each bar tip in fmtCurrency.

LineChart

pdf-charts.ts

Time-series trajectories (NDVI 8-yr projection, AAL reduction across horizons).

xLabel
Time axis (e.g. "Calendar year"). xFmt rounds to integer years.
yLabel
Series quantity + unit (e.g. "AAL reduction (£ / yr)").
Legend
One item per series at the bottom; dashed = counterfactual, solid = projected.
Value labels
Endpoint dots; no per-point labels (would crowd the plot).

DonutChart

pdf-charts.ts

Share-of-total composition (rare — prefer Waterfall for additive splits).

xLabel
Not applicable.
yLabel
Not applicable.
Legend
One row per slice with "{label} · {value}{unit} ({pct}%)".
Value labels
Slice percentages drawn over each segment if ≥ 6% share.

Radar

pdf-charts.ts

Multi-dimension scoring (Nature Value Scorecard — 1–5 across 5 dimensions).

xLabel
Dimension labels around the polygon perimeter (truncated at 22 chars).
yLabel
scaleLabel near the centre (e.g. "0–5 score").
Legend
Overall score + grade band.
Value labels
Ring ticks at integer scale steps; vertex labels carry the numeric score.

Histogram

pdf-charts.ts

Distribution of a single quantity (Monte Carlo NPV bins).

xLabel
Bin axis with the measured quantity + unit.
yLabel
Frequency ("Runs" or "Density").
Legend
Optional markers (P10 / P50 / P90) shown as vertical lines with label chips.
Value labels
No per-bar labels; readability over precision here.

Scatter

pdf-charts.ts

Two-variable relationship (cost vs benefit per intervention).

xLabel
Independent variable + unit.
yLabel
Dependent variable + unit.
Legend
Single brand-green dot; multi-series scatter is a non-goal.
Value labels
No per-point labels; rely on the surrounding table for IDs.

BeforeAfter

pdf-charts.ts

Two-bar pre/post comparison (stormwater runoff before vs after, UTCI before vs after).

xLabel
Implicit ("Before" / "After" baked into bar labels).
yLabel
Quantity + unit.
Legend
Delta chip with arrow + direction-of-good (lower-is-better / higher-is-better).
Value labels
Numeric values centred above each bar.

StackedBar

pdf-charts.ts

Composition of a single total (cashable vs non-cashable value).

xLabel
Not applicable (single stacked column).
yLabel
Quantity + unit.
Legend
One item per segment in stack order.
Value labels
Segment label + value rendered inside each segment if it fits.

Heatmap

pdf-charts.ts

Two-axis density (peril × horizon, intervention × month).

xLabel
Column dimension along the top.
yLabel
Row dimension along the left.
Legend
Gradient legend strip with min / max numeric anchors.
Value labels
Cell values inside the tile when contrast permits.
Source of truth

Two files, no exceptions.

  • · src/lib/biosync/pdf-charts.ts — chart primitives + fmtNumber / fmtCurrency. Change a default here and every chart in every dossier updates.
  • · src/lib/biosync/pdf.ts — per-section bindings (which data drives which chart). Domain-specific formatters live alongside their chart call.

CI gate: bun scripts/render-canonical.ts renders the canonical fixture end-to-end and fails if any optional section silently drops. Run it after touching either file.