# Svg Charting

> Build charts by hand in inline SVG when no charting library can be loaded - scales, nice ticks, axes, bars, lines, areas, stacking, waterfall layout, label collision avoidance, and responsive sizing. Ships chartkit.js, a tested dependency-free utility. Use whenever you need a chart inside a strict-CSP artifact, or anywhere a library is unavailable or not worth its bytes. Trigger on "chart without a library", "inline SVG chart", "no CDN", "D3 won't load", "hand-roll a chart", "axis ticks", "scale", "waterfall layout", "chartkit".

- Skill: `lukehle/svg-charting` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add lukehle/svg-charting`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lukehle/svg-charting/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Lukehle (https://skillmd.com/u/lukehle)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lukehle/svg-charting

---


# SVG charting without a library

Published artifacts block every external host, so D3, Recharts, Chart.js and Plotly are simply not
available. Inlining a full library costs hundreds of KB to use a fraction of it.

The good news: **the parts of a charting library you actually need are small.** Scales, ticks, paths,
and stacking are a few dozen lines each. This skill ships them, tested, in
`scripts/chartkit.js` (~8KB, no dependencies, works as ESM, CJS, or a plain `<script>`).

```bash
node scripts/chartkit.test.js    # 47 assertions
```

---

## The five things you need

### 1. Scales — map data to pixels

```js
const x = chartkit.scaleBand(months, [padLeft, width - padRight], 0.2);
const y = chartkit.scaleLinear([0, maxValue], [height - padBottom, padTop]);
```

**Note the inverted y range.** SVG's origin is top-left and y grows downward, so a value scale runs
from `height` down to `0`. Getting this backwards produces an upside-down chart that otherwise looks
plausible — check it early.

`scaleLinear` pins to the range midpoint on a zero-width domain rather than dividing by zero, so a
flat series renders instead of vanishing into `NaN`.

### 2. Nice ticks — round numbers, not raw divisions

```js
const ticks = chartkit.niceTicks(0, 4_820_000, 5);
const [lo, hi] = chartkit.niceDomain(0, 4_820_000, 5);   // expand to tick boundaries
```

Steps snap to 1, 2, **2.5**, 5, or 10 × a power of ten. The 2.5 is deliberate: it produces
250 / 500 / 750 on money axes, which reads far better to a finance audience than 200 / 400 / 600.

Ticks are derived from the index rather than accumulated, so you never get `0.30000000000000004`
in an axis label.

### 3. Paths

```js
chartkit.linePath(points)                 // breaks at nulls - a gap is a gap
chartkit.linePath(points, {connectNulls: true})
chartkit.areaPath(points, baselineY)
chartkit.barPath(x, y, w, h, radius)      // handles negative heights
```

**`linePath` breaks the line at a null by default, and that default is load-bearing.** Connecting
across a missing month draws a straight line through data that does not exist. Only pass
`connectNulls` when you can defend it.

### 4. Stacking

```js
const cols = chartkit.stack([
  {key: 'subscription', values: [...]},
  {key: 'services',     values: [...]},
]);
const pct = chartkit.stack(series, {normalize: true});   // 100% stacked
```

Negative values stack **downward from zero** rather than folding into the positive stack. A contra
item folded into the positive stack silently overstates the total.

### 5. Waterfall layout

```js
const {bars, domain, footing} = chartkit.waterfallLayout([
  {label: 'Opening ARR', value: 4_610_000, type: 'anchor'},
  {label: 'New',         value:   180_000},
  {label: 'Expansion',   value:    95_000},
  {label: 'Contraction', value:   -38_000},
  {label: 'Churn',       value:   -27_000},
  {label: 'Closing ARR', value: 4_820_000, type: 'anchor'},
]);

if (footing && !footing.ok) {
  throw new Error(`Bridge does not foot: ${footing.variance}`);
}
```

**Check `footing.ok` and fail loudly.** A bridge whose bars do not sum to its closing anchor is a
wrong analysis, and the correct response is to fix the analysis — never to plug the chart so it
looks right. This is the single most important line in the whole kit.

See `bridge-charts` for the visual conventions on top of this layout.

---

## Assembling a chart

The shape is always the same. Compute geometry, emit markup.

```js
const W = 720, H = 360, m = {top: 24, right: 16, bottom: 40, left: 64};

const y = chartkit.scaleLinear(chartkit.niceDomain(0, max, 5), [H - m.bottom, m.top]);
const x = chartkit.scaleBand(labels, [m.left, W - m.right], 0.25);

const svg = `
<svg viewBox="0 0 ${W} ${H}" role="img" aria-labelledby="t desc"
     preserveAspectRatio="xMidYMid meet" style="width:100%;height:auto">
  <title id="t">Net new ARR by month</title>
  <desc id="desc">Bar chart; peak 210K in March.</desc>

  ${chartkit.niceTicks(...).map(v => `
    <line x1="${m.left}" x2="${W - m.right}" y1="${y(v)}" y2="${y(v)}"
          class="grid"/>
    <text x="${m.left - 8}" y="${y(v)}" class="tick" text-anchor="end"
          dominant-baseline="middle">${chartkit.formatCompact(v, {currency: '$', unit: 'K'})}</text>
  `).join('')}

  ${data.map(d => `
    <path d="${chartkit.barPath(x(d.label), y(d.value), x.bandwidth(), y(0) - y(d.value), 2)}"
          class="bar"/>
  `).join('')}
</svg>`;
```

Rules that matter here:

- **`viewBox` + `width:100%; height:auto`** is the whole responsive story. Do not set pixel width.
- **Pin the axis unit** (`unit: 'K'`). Mixing `$1.2M` and `$900K` on one axis makes the reader do
  unit arithmetic.
- **Escape any text that came from data** with `chartkit.esc()`. A customer name containing `&`
  breaks the SVG otherwise.
- `role="img"` with `<title>`/`<desc>` is the minimum for screen readers — see
  `artifact-accessibility`.

---

## Labels that collide

Dense bridges and end-of-line series labels overlap. Nudge them apart while preserving order:

```js
const ys = chartkit.avoidOverlap(rawYs, 12, [m.top, H - m.bottom]);
```

Order preservation matters: labels that cross swap their apparent series, which is worse than
overlapping.

---

## SVG or canvas

| Element count | Use |
|---|---|
| Under ~1,000 nodes | SVG — styleable, accessible, inspectable, printable |
| 1,000-5,000 | SVG, but drop per-element listeners; hit-test from data instead |
| Over ~5,000 | Canvas for the marks, SVG overlay for axes and interaction |

Almost every finance chart is under 1,000 nodes. Reach for canvas only when a scatter or a tick
chart genuinely demands it. See `artifact-performance`.

---

## Things that bite

| Symptom | Cause |
|---|---|
| Chart upside down | Forgot the inverted y range |
| Labels clipped at the edges | Margins not reserved for the widest tick label |
| Blurry lines | Half-pixel coordinates; align strokes to `.5` offsets |
| Text unreadable when printed | Colour-only encoding, no greyscale fallback |
| Chart vanishes at narrow widths | Fixed `width` attribute instead of `viewBox` |
| `NaN` in a path | A null in the data reached the scale — filter first |
| Bars overlap | Band `padding` of 0, or bandwidth computed from the wrong range |

---

## Related skills

- `artifact-architecture` — why no library loads in the first place
- `bridge-charts`, `variance-charts`, `timeseries-finance` — what to build with these primitives
- `artifact-performance` — when to leave SVG
- `artifact-accessibility` — making the output readable by everyone
- `mermaid-in-artifacts` — the case where you should not hand-roll anything

