@andyball/breaks
v0.1.0
Published
Opinionated CLI and library for choosing colour scales, class breaks and palettes for data visualisation
Maintainers
Readme
breaks
An opinionated CLI and TypeScript library for choosing colour scales for data visualisation.
D3 already builds colour scales very well. breaks answers the question before
that one:
Given this data, what colour scale should I use, where should the breaks be, and why?
breaks unemployment.csv --column rate --compareAnalysing 36 values from "rate"...
range 1.8 → 21.4
median 5.9
mean 7.34
std dev 4.54
skew 1.44
outliers 3
zero no
negative no
distinct 36
Recommended scale
────────────────────────────────────────────────────
type sequential
method pretty
classes 5
breaks 5
10
15
20
palette Blues
direction light (low) → dark blue (high)
colours #eff3ff #bdd7e7 #6baed6 #3182bd #08519c
confidence 69%
Reason:
• Values are numeric and ordered.
• There is no obvious meaningful midpoint.
• The distribution is strongly right-skewed (skew 1.44).
• Evenly spaced round numbers classify this data as well as any alternative.
• 3 outlier(s) were kept in the classification rather than trimmed.
• ckmeans scored almost as well (80 vs 81); see --compare.
Warnings:
[warn] 3 outlier(s) detected (16.1, 18.4, 21.4). An outlier class may be
appropriate, but check they are not data errors.
METHOD BREAKS COUNTS SCORE
───────────────────────────────────────────
pretty 5 10 15 20 12/17/4/2/1 81 *
ckmeans 5 8 13 18 12/13/7/2/2 80
quantile 4 5.5 6.5 10 7/8/6/8/7 77
equal 6 10 14 18 18/11/3/2/2 75Contents
- Why
- Requirements
- Installation
- Usage
- Options
- Output formats
- Library API
- How the recommendation works
- Warnings
- Development
Why
A statistically optimal set of breaks is not necessarily a good legend.
3.73 4
7.91 vs 8
12.48 12
19.62 20The right-hand column is much easier to communicate, and on most datasets it
classifies the data just as well. breaks treats legend readability as a
first-class optimisation goal — but it verifies every rounding by reclassifying
the data, and rejects any that moves more than 10% of observations into a
different class.
It also explains itself, and shows its working with --compare, so you can
disagree with it.
Requirements
| | |
|---|---|
| Node | >= 22 |
| Install | ./install.sh (symlinks into ~/bin) |
Installation
npm install
./install.shThis builds to dist/ and symlinks ~/bin/breaks at this directory, so the
command always runs the working copy. npm link also works.
As a library:
npm install @andyball/breaksUsage
# CSV
breaks data.csv --column unemployment
# JSON, including nested paths
breaks data.json --column properties.rate
# GeoJSON features are unwrapped automatically
breaks boundaries.geojson --column properties.rate
# Newline-separated values on stdin
cat values.txt | breaks
# Show every candidate and its score
breaks data.csv -c value --compare
# Explain every score component
breaks data.csv -c value --compare --verboseSemantic hints, when the data alone cannot know:
breaks data.csv --column swing --centre 0
breaks data.csv --column rate --type sequential --classes 6
breaks data.csv --column change --centre 0 --symmetricOptions
-c, --column <name> column to analyse; dotted paths work for JSON
--compare show every candidate classification and its score
-f, --format <format> json | d3 | maplibre | css
-t, --type <type> categorical | sequential | diverging | binary
--centre <value> semantic midpoint; implies a diverging scale
-n, --classes <n> number of classes
-m, --method <method> equal | quantile | pretty | ckmeans | logarithmic | symlog | centred
-p, --palette <name> palette name
--reverse reverse the palette
--no-nice do not round breaks for legend readability
--symmetric mirror diverging breaks around the centre
--max-distortion <f> max share of observations a rounding may move (default 0.1)
--reference <values> comma-separated values a break should land on
--property <name> feature property name for the MapLibre expression
--strict fail when any value cannot be parsed
-v, --verbose show the full score breakdown with --compareEvery recommendation is overridable: scale type, class count, method, midpoint, palette and rounding behaviour.
Output formats
Human output is suppressed entirely when --format is set, so the result is
safe to pipe.
MapLibre — the primary exporter for mapping work:
breaks data.csv -c rate --format maplibre[
"step",
["get","rate"],
"#eff3ff",
5, "#bdd7e7",
10, "#6baed6",
15, "#3182bd",
20, "#08519c"
]Categorical data produces a match expression with an #eee fallback.
D3:
d3.scaleThreshold()
.domain([5, 10, 15, 20])
.range(['#eff3ff', '#bdd7e7', '#6baed6', '#3182bd', '#08519c'])
.unknown('#eee')JSON — the full serialisable recommendation, with the candidate list summarised. CSS — custom properties plus a hard-stop legend gradient.
Library API
import { analyse, recommend, createColourScale } from '@andyball/breaks';
const recommendation = recommend(values, { purpose: 'choropleth', classes: 5 });
// => { scaleType, classification, breaks, rawBreaks, palette, reasons, warnings, ... }
const scale = createColourScale({
type: 'threshold',
domain: [4, 8, 12, 20],
colours
});createColourScale does no statistics — it takes a domain and builds a scale.
Deciding what the domain should be is recommend()'s job. Keeping the two
apart is what makes both reusable.
Supported scale types: linear, threshold, quantile, quantize,
ordinal, diverging. Unknown, empty and unparseable values return #eee.
Every stage of the pipeline is also exported individually — analyse,
inferScaleType, pretty, ckmeans, niceBreaks, scoreCandidate,
suggestPalette — along with all types.
How the recommendation works
clean → analyse → inferScaleType → generateCandidates → niceBreaks
→ scoreCandidates → suggestPalette → recommend → exporters- Clean. Currency symbols, thousands separators, percentages and accounting negatives are parsed. Null markers are counted as missing. Anything else is reported as invalid — never coerced to zero.
- Analyse. Everything from one sorted array: extent, mean, median, quartiles, IQR, standard deviation, skewness, unique count, zero crossing and Tukey outliers. Degenerate cases (all values identical, tiny samples) are flagged rather than allowed through.
- Infer the scale type. Categorical, sequential, diverging or binary. An
explicit
--typeor--centrealways wins. - Generate candidates. Equal interval, quantile, pretty and ckmeans, plus centred for diverging data, logarithmic where the range spans two orders of magnitude and no value is zero or negative, and symlog wherever there is a long tail — including across zero, where a plain log scale is illegal.
- Nice the breaks. Snap to the 1 / 2 / 2.5 / 5 × 10ⁿ ladder, then verify
by reclassifying. Reject anything above the distortion budget. (
symlogopts out: it has already snapped each break individually, and rounding to a shared step would flatten the growing intervals.) - Score. Statistical fit (goodness of variance fit), class balance, legend readability and semantic fit, less penalties for empty classes, near-empty classes and distortion. The near-empty penalty is proportional: a class holding 1.9% of observations is a legitimate small band, one holding 0.02% is a legend entry nobody can find, and they should not cost the same. Confidence is capped at the winner's own score — there is no point being sure about a classification that scores poorly.
- Choose a palette. ColorBrewer and Tableau schemes via
d3-scale-chromatic. Direction is always stated in words, and reversing a palette rewrites that description — so a diverging scale cannot silently claim red means negative when it now means positive.
Class breaks follow the threshold convention throughout: n classes are
described by n−1 breaks, matching d3.scaleThreshold and the MapLibre step
expression. A value equal to a break falls into the upper class.
Long-tailed data
The hard case for a choropleth is a column where most areas barely move and a handful change enormously — population density, income, case counts. Equal intervals put 99% of features in one class; a log scale is illegal the moment a value is zero or negative.
symlog is the answer: round numbers at increasing intervals, fine near the
middle and coarse in the tail, with a break on zero.
METHOD BREAKS COUNTS SCORE
symlog -20 -0.5 0 5 100 1000 40/150/50/200/30/33/23 82 *
quantile -1.5 -0.75 0 1 2 90/80/70/100/100/86 59
pretty 0 5000 10000 15000 240/280/2/3/1 53
equal ... 99.7% in one class 0Each break is snapped to the round-number ladder individually rather than to a shared step, which is what lets the intervals grow.
Warnings
breaks is meant to be an adviser, not a generator. It flags:
- a dominant lowest class
- a maximum far above the median
- quantile classification separating identical values
- too many categories for a qualitative palette
- a diverging scale requested on one-sided data
- empty classes
- outliers
- a class indistinguishable from the background
- adjacent classes too close to tell apart
- values that could not be parsed
Development
npm run build # tsc -> dist/
npm run dev # tsx src/cli/index.ts
npm test # vitest run
npm run test:watchTests live beside the code as *.test.ts; the fixtures in test/fixtures/
cover the classification scenarios — uniform, right-skewed, diverging, long
tail across zero, outlier, categorical, binary and all-identical.
Licence
MIT.
