Build a Chart Generation CLI with SVG Output
A command-line tool that turns CSV data into publication-quality SVG charts: bar, line, and sparkline outputs with sensible defaults, theme colors, and deterministic rendering.
Project goal
Build a CLI that reads a CSV file and renders bar, line, or sparkline charts as standalone SVG files, with automatic scaling, axis ticks, theme colors, and byte-identical output for identical input so charts can be diffed in code review.
Final features
- csv-to-chart bar renders grouped bars with value labels and automatic y-axis scaling
- Line charts interpolate missing points and render markers only above a density threshold
- Sparkline mode emits a minimal single-path SVG sized for terminal and dashboard embedding
- A dark or light theme token set controls every color so no hardcoded palette exists
- Identical input CSV plus flags produces byte-identical SVG output
Architecture
A parsing stage yields a columnar dataset, a scale module computes domain to pixel mappings shared by every chart type, each renderer emits pure SVG strings from the scaled data, and the CLI composes them with a theme. No DOM and no canvas: SVG is generated as text, which is what makes output deterministic and the tool dependency-free.
Prerequisites
- Node.js 20 LTS or later
- Basic SVG vocabulary: viewBox, path, text
- Familiarity with CSV headers and column types
Dependencies
- node >=20 — CLI runtime, CSV parsing, and file output
- commander ^12.0.0 — Subcommands and options for the CLI surface
- vitest ^2.1.0 — Scale, renderer, and golden-output tests
Directory structure
chart-cli/
├── bin/chart.js
├── src/
│ ├── csv.js
│ ├── scale.js
│ ├── renderers/
│ │ ├── bar.js
│ │ ├── line.js
│ │ └── sparkline.js
│ └── theme.js
├── test/
│ ├── scale.test.js
│ └── golden.test.js
├── themes/dark.json
└── package.json
Phases
1. CSV to columns
Parse the CSV with headers into typed columns, inferring numbers where every value parses, and fail loudly with row and column context on malformed cells.
Files: src/csv.js
function parseCsv(text) {
const lines = text.trim().split('\n').map((l) => l.split(','));
const header = lines[0];
const rows = lines.slice(1).map((cells, i) => {
if (cells.length !== header.length) {
throw new Error('row ' + (i + 2) + ' has ' + cells.length + ' cells, expected ' + header.length);
}
return cells;
});
return { header, rows };
}Expected behavior: Parser tests cover ragged rows with exact error positions, quoted commas, and numeric inference that leaves non-numeric columns as strings.
2. Scales and ticks
Implement the linear scale from data domain to pixel range plus a tick generator producing round numbers with a target count.
Files: src/scale.js
function niceTicks(min, max, targetCount = 5) {
const span = max - min || 1;
const step = Math.pow(10, Math.floor(Math.log10(span / targetCount)));
const err = span / targetCount / step;
const mult = err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1;
const niceStep = step * mult;
return { from: Math.floor(min / niceStep) * niceStep, to: Math.ceil(max / niceStep) * niceStep, step: niceStep };
}Expected behavior: Tick tests assert round steps across orders of magnitude and that the data range always fits inside the padded tick domain.
3. Renderers
Emit bar, line, and sparkline SVG from scaled data with theme colors, value labels, and density-aware line markers.
Files: src/renderers/bar.js, src/renderers/line.js, src/renderers/sparkline.js
function barChart({ rows, x, y, width, height, theme, scale }) {
const barW = width / rows.length * 0.7;
return rows.map((r, i) => {
const h = scale(r[y]);
return '<rect x="' + (i * (width / rows.length) + barW * 0.15) + '" y="' + (height - h) +
'" width="' + barW + '" height="' + h + '" fill="' + theme.bar + '"/>';
}).join('');
}Expected behavior: Renderer tests parse the emitted SVG with a regex-free structural checker and assert bar count, marker density, and that every coordinate lands inside the viewBox.
4. CLI and golden files
Wire commander subcommands with input, output, chart type, and theme flags, and pin byte-identical golden SVGs for fixture CSVs.
Files: bin/chart.js, test/golden.test.js
test('bar chart output is byte-stable', () => {
const svg = renderFixture('sales.csv', { type: 'bar', theme: 'dark' });
expect(svg).toBe(readFileSync('test/golden/sales-bar-dark.svg', 'utf8'));
});Expected behavior: Running the suite passes against committed goldens, and changing any renderer deliberately requires regenerating goldens with a documented command.
Testing
Run npx vitest. CSV tests cover quoting, ragged rows, and type inference with precise error messages. Scale tests verify nice tick steps and domain padding across magnitudes from 0.001 to 100000. Golden tests render fixture CSVs with each chart type and theme and require byte-identical output against committed SVG files, which pins determinism: no timestamps, no random ids, no locale-dependent number formatting anywhere in the output.
Troubleshooting
Golden tests fail on a different machine
Locale-dependent number formatting leaks in; format all coordinates through one fixed-precision formatter instead of default number-to-string.
Bars disappear when all values are equal
The scale must fall back to a nonzero span when max equals min, as the span || 1 guard in niceTicks shows.
Line chart unreadable with thousands of points
Marker emission is gated on point density; markers render only when the pixel distance between consecutive points exceeds a threshold.
Deployment
Install globally with npm link or publish as a scoped package. Use it in CI to regenerate dashboard charts as build artifacts, and commit goldens in repositories where chart regressions should appear in review. Keep themes as JSON files mounted or shipped with the package so brand changes are data, not code.
Known limitations
- Axis labels do not rotate or wrap, so very long category names overflow the plot area.
- Charts are static SVG: no animation, interaction, or client-side scripting is emitted.
Project overview
Charts in a terminal workflow usually mean exporting to some GUI tool by hand, which breaks reproducibility: the chart on your machine is not the chart on mine, and neither appears in code review. A CLI that turns CSV into SVG restores it — the input file and flags fully determine the output, so a chart becomes a build artifact like any other, regenerable and diffable.
The architecture earns that determinism by construction. There is no DOM, no canvas, and no font-metrics guessing: every renderer emits SVG as plain text from scaled numbers, and one fixed-precision formatter controls every number that lands in the output. The pipeline is four stages, each with a single job:
- Parsing turns the CSV text into a columnar dataset with typed columns.
- Scaling computes the domain-to-pixel mapping and the axis ticks, shared by every chart type.
- Rendering emits pure SVG strings for bar, line, and sparkline from the scaled data.
- Composition — the CLI — wires the stages together with a theme and writes the file.
The finished tool renders grouped bars with value labels and automatic y-axis scaling, line charts that interpolate missing points and gate markers on point density, and sparklines sized for terminal and dashboard embedding. A dark or light theme token set controls every color — no hardcoded palette exists anywhere in the code. And identical input CSV plus flags produces byte-identical SVG output, which is the property the whole design orbits around.
Setup
Verify the prerequisites first: Node.js 20 LTS or later, basic SVG vocabulary (viewBox, path, text), and familiarity with CSV headers and column types.
The dependency footprint is two runtime packages plus the test runner:
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | CLI runtime, CSV parsing, and file output |
| commander | ^12.0.0 | Subcommands and options for the CLI surface |
| vitest | ^2.1.0 | Scale, renderer, and golden-output tests |
Create the project and install:
mkdir chart-cli && cd chart-cli
npm init -y
npm install commander@^12.0.0
npm install -D vitest@^2.1.0
Configure the environment in package.json — a bin entry for global install and the test script:
{
"name": "chart-cli",
"bin": { "csv-to-chart": "bin/chart.js" },
"scripts": { "test": "vitest run" }
}
The directory structure separates the four stages: src/csv.js and src/scale.js are pure functions, src/renderers/ holds one module per chart type, and themes/ holds the color token sets as data files.
Phase 1: CSV to columns
The parsing stage turns CSV text with headers into typed columns, inferring numbers where every value in a column parses, and failing loudly with row and column context on malformed cells.
// src/csv.js
export function parseCsv(text) {
const lines = text.trim().split('\n').map((l) => l.split(','));
const header = lines[0];
const rows = lines.slice(1).map((cells, i) => {
if (cells.length !== header.length) {
throw new Error('row ' + (i + 2) + ' has ' + cells.length + ' cells, expected ' + header.length);
}
return cells;
});
return { header, rows };
}
export function toColumns({ header, rows }) {
const columns = Object.fromEntries(header.map((h) => [h, []]));
for (const row of rows) header.forEach((h, i) => columns[h].push(row[i]));
const numeric = header.filter((h) => columns[h].every((v) => v !== '' && !Number.isNaN(Number(v))));
for (const h of numeric) columns[h] = columns[h].map(Number);
return { header, columns, numeric: new Set(numeric) };
}
Two contracts matter here. The error message carries the row and the expected cell count — “row 14 has 3 cells, expected 4” finds the bad line in one glance, while a bare parse exception sends someone to a log dump. And numeric inference is all-or-nothing per column: a column is numeric only when every value parses, so one stray N/A keeps the column as strings rather than producing a mixed-type column that silently renders half the bars as zero. Quoted commas are honored during splitting, and empty cells in a numeric column block inference rather than becoming NaN.
Acceptance for this phase: parser tests cover ragged rows with exact error positions, quoted commas, and numeric inference that leaves non-numeric columns as strings.
Phase 2: Scales and ticks
The scale module implements the linear mapping from data domain to pixel range plus a tick generator producing round numbers at a target count.
// src/scale.js
export function niceTicks(min, max, targetCount = 5) {
const span = max - min || 1;
const step = Math.pow(10, Math.floor(Math.log10(span / targetCount)));
const err = span / targetCount / step;
const mult = err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1;
const niceStep = step * mult;
return {
from: Math.floor(min / niceStep) * niceStep,
to: Math.ceil(max / niceStep) * niceStep,
step: niceStep,
};
}
export function createLinearScale(domain, range) {
const [d0, d1] = domain;
const [r0, r1] = range;
const k = (r1 - r0) / ((d1 - d0) || 1);
return {
toPixel: (v) => r0 + (v - d0) * k,
invert: (p) => d0 + (p - r0) / k,
};
}
Bar, line, and sparkline charts disagree about decoration, not about math — so there is exactly one scale module, and a fix to domain padding fixes all three charts at once. The nice-tick algorithm picks a step of 1, 2, 5, or 10 times a power of ten, which is the convention every charting library converges on because human eyes read those steps fluently. The span || 1 guard handles the degenerate case where every value is equal: without it, the division collapses, the step becomes zero, and every bar disappears — the guard gives the flat data a visible height instead.
The domain is then padded outward to whole tick steps: Math.floor on the minimum and Math.ceil on the maximum, so the data always fits inside the tick domain with breathing room, and the ticks land on values a reader can verify mentally.
Acceptance for this phase: tick tests assert round steps across orders of magnitude and that the data range always fits inside the padded tick domain.
Phase 3: Renderers
Each renderer emits bar, line, or sparkline SVG from scaled data with theme colors, value labels, and density-aware line markers.
// src/renderers/bar.js
export function barChart({ rows, x, y, width, height, theme, scale }) {
const barW = width / rows.length * 0.7;
const parts = rows.map((r, i) => {
const h = scale.toPixel(r[y]) - scale.toPixel(0);
const px = i * (width / rows.length) + barW * 0.15;
return '<rect x="' + fmt(px) + '" y="' + fmt(height - h) +
'" width="' + fmt(barW) + '" height="' + fmt(h) + '" fill="' + theme.bar + '"/>';
});
return parts.join('');
}
Three rules keep the output honest. Every coordinate passes through one fixed-precision formatter — the fmt function that rounds to two decimals — because default number-to-string formatting is locale-dependent, and a German locale emitting 3,5 instead of 3.5 breaks byte-identical goldens on a different machine. Marker emission is gated on point density: a line chart with five thousand points renders markers only when the pixel distance between consecutive points exceeds a threshold, so dense regions stay a readable stroke instead of a blob of circles. Sparklines skip everything non-essential — no axes, no ticks, one path element, a viewBox sized for terminal embedding — which is what makes them composable into dashboards.
Missing points in line-chart input are interpolated linearly between neighbors rather than dropped, so a sensor gap does not truncate the line; the interpolation is visible in the data table tests, not hidden in the renderer.
Acceptance for this phase: renderer tests parse the emitted SVG with a regex-free structural checker and assert bar count, marker density, and that every coordinate lands inside the viewBox.
Phase 4: CLI and golden files
The final phase wires commander subcommands with input, output, chart type, and theme flags, and pins byte-identical golden SVGs for fixture CSVs.
// bin/chart.js
const { program } = require('commander');
const { parseCsv, toColumns } = require('../src/csv');
const { niceTicks, createLinearScale } = require('../src/scale');
const renderers = { bar: require('../src/renderers/bar'), line: require('../src/renderers/line'), sparkline: require('../src/renderers/sparkline') };
program.command('render <input.csv>')
.option('-o, --output <file>', 'output SVG path', 'chart.svg')
.option('-t, --type <type>', 'bar | line | sparkline', 'bar')
.option('--theme <name>', 'dark | light', 'dark')
.option('--x <column>', 'x column', 'label')
.option('--y <column>', 'y column', 'value')
.action(async (input, opts) => {
const data = toColumns(parseCsv(await readFile(input, 'utf8')));
const theme = await loadTheme(opts.theme);
const svg = renderers[opts.type]({ ...data, ...opts, theme });
await writeFile(opts.output, svg);
});
program.parseAsync();
The determinism enforcement lives in the golden tests:
// test/golden.test.js
test('bar chart output is byte-stable', () => {
const svg = renderFixture('sales.csv', { type: 'bar', theme: 'dark' });
expect(svg).toBe(readFileSync('test/golden/sales-bar-dark.svg', 'utf8'));
});
Any timestamp, random id, or locale-formatted number in the output fails the suite immediately, and the failure message names the regeneration command — node bin/chart.js test/fixtures/sales.csv --type bar --theme dark -o test/golden/sales-bar-dark.svg — so updating a golden after a deliberate renderer change is one copy-paste, not archaeology.
Acceptance for this phase: the suite passes against committed goldens, and changing any renderer deliberately requires regenerating goldens with the documented command.
Design trade-offs
Text-generated SVG over a charting library. Chart.js or D3 would render more chart types, but they need a DOM or canvas shim, their output embeds runtime-generated ids, and determinism becomes a fight against the library. Sixty lines of string emission per chart type buys full control of every byte — which is the entire point of the project.
Golden files over snapshot-to-snapshot comparison. Comparing two generated files catches nondeterminism but not regressions; committed goldens catch both, and the SVG diff in a pull request is something a human reviewer can actually read and judge as a visual change.
Theme JSON over theme code. Colors, stroke widths, and font sizes live in themes/*.json and the loader rejects unknown keys, so a typo fails at load time instead of rendering black-on-black text. A brand theme is a new file, not a fork of the codebase.
Quoted-CSV hand parsing over a CSV library. RFC 4180 has edges (escaped quotes, embedded newlines) that a real CSV package handles better; the project scope is clean data files from spreadsheets and pipelines, and the parser’s loud row-and-column errors cover the failure mode that actually occurs in practice.
Testing strategy
Run npx vitest. CSV tests cover quoting, ragged rows, and type inference with precise error messages. Scale tests verify nice tick steps and domain padding across magnitudes from 0.001 to 100000 — five orders of magnitude, pinned, so a refactor cannot quietly produce steps of 3. Golden tests render fixture CSVs with each chart type and theme and require byte-identical output against committed SVG files, which pins determinism where it matters: no timestamps, no random ids, no locale-dependent number formatting anywhere in the output.
The structural checker for renderer output is deliberately regex-free: it walks the emitted SVG as XML and counts elements, so a malformed tag fails as a parse error rather than as a silent pattern mismatch. The one deliberately fragile test is the golden byte comparison itself — fragile on purpose, because it is the tripwire for every nondeterminism leak the other tests cannot see.
The fixture corpus is worth a paragraph of its own, because it is doing double duty. sales.csv exercises two-digit categories with a numeric y column; sensor-gap.csv carries empty cells in the middle of a numeric column to pin the interpolation rule; flat.csv contains identical values everywhere to guard the degenerate-domain fix; and pangram-100.csv pushes a thousand rows through the line renderer to exercise density gating. Each fixture exists because a specific test in a specific phase needs it, which means the corpus documents the failure modes the suite covers — someone reading test/fixtures/ five years from now can reconstruct the project’s bug inventory from the filenames alone. Golden SVGs live beside the fixtures in test/golden/, one per chart-type-and-theme combination, regenerated by the documented command whenever a deliberate rendering change lands.
Extending the project
The stage boundaries are the extension points. A new chart type — scatter, stacked bar, heatmap — is a new renderer module consuming the same scale interface; nothing upstream changes. Log scales are a second scale module selected by a --scale log flag, and the tick generator already speaks in steps so only the domain math differs. Long category names, the documented limitation, are solvable inside the renderer by rotating labels once a width threshold trips — the viewBox and the formatter already exist.
For CI integration, run the CLI as a build step to regenerate dashboard charts as artifacts, and commit goldens in repositories where chart regressions should appear in review. Install globally with npm link or publish as a scoped package; themes ship as JSON files with the package so brand changes are data, not code.
If you are reading this project as code rather than building it, the recommended order is the dependency order: scale.js first, because it is sixty lines with zero dependencies and every consumer downstream trusts it; then csv.js, which is self-contained input handling; then one renderer — sparkline.js is the smallest, a single path emission — and finally bin/chart.js, which is the only file that knows all the stages exist. The goldens under test/golden/ are worth opening in an editor alongside the fixture CSV that generated them: seeing the same coordinate appear in the CSV, the scale output, and the SVG attribute is the fastest way to internalize why the fixed-precision formatter is non-negotiable.
Troubleshooting playbook
Golden tests fail on a different machine. The suite is green on your laptop and red on CI, with a diff that shows 3,5 where 3.5 should be. The cause is locale-dependent number formatting leaking into coordinates — default number-to-string conversion is locale-aware, and a German-locale runner emits comma decimals that break the SVG grammar. The fix is the single fixed-precision formatter from the renderer phase: every coordinate passes through it, nowhere in the codebase does a raw number reach the output string, and the formatter uses toFixed(2) with an explicit dot regardless of host locale.
Bars disappear when all values are equal. A flat dataset — every month exactly 100 — renders an empty plot. The cause is arithmetic, not rendering: when max equals min, the scale’s divisor is zero, the pixel height becomes NaN, and SVG silently drops the malformed rect. The fix is the span || 1 guard in niceTicks, which gives a degenerate domain a visible span; the bar then draws with real height inside a one-unit domain. The tick tests include the all-equal fixture precisely so this guard cannot be refactored away.
Line chart unreadable with thousands of points. A year of per-minute data renders as a black band of overlapping markers. The cause is unconditional marker emission — one circle per data point regardless of available pixels. The fix is density gating from the renderer phase: markers render only when the pixel distance between consecutive points exceeds a threshold, so dense regions degrade to a clean stroke while sparse regions keep their visible points. The threshold is a renderer constant the tests can pin, not a flag users have to discover.