Functional Weave
Code in TypeScript

charts.histogram-bins

Bin values into histogram bins on round edges, by Sturges' rule, Freedman-Diaconis, or a fixed width.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 17 tests, run in TypeScript, Python and Rust.

What it does

Sorts a sample into histogram bins with round edges: `histogramBins([1..10], "sturges", null)` gives five bins, 0-2, 2-4, ..., 8-10. Each bin counts values with `x0 <= v < x1`; the last bin also counts its right edge, so the maximum is never lost off the end.

## How many bins

For example

  • histogramBins(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, sturges, —) → ×5 Sturges: ten values make five bins of width 2
  • histogramBins(1, 2, 3, 4, 5, 6, 7, 8, 9, 100, sturges, —) → ×5 Sturges with an outlier: five wide bins
  • histogramBins(1, 2, 3, 4, 5, 6, 7, 8, 9, 100, freedman-diaconis, —) → ×20 Freedman-Diaconis with the same outlier: narrow bins sized by the quartiles

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

export function histogramBins(values: readonly number[], method: BinMethod, binWidth: number | null): readonly Bin[]
valuesfloat[]the sample, in any order; empty gives no bins
methodBinMethodhow many bins: sturges, freedman-diaconis, or fixed-width
binWidthfloat?the width for fixed-width, greater than 0; null for the other methods
returnsBin[]adjacent bins from the first edge at or below the minimum to the first at or above the maximum

The types it declares, generated into your project

export type BinMethod = "sturges" | "freedman-diaconis" | "fixed-width";

/** One histogram bar: values from x0 up to but not including x1 (the last bin includes x1). */
export interface Bin {
  readonly x0: number;
  readonly x1: number;
  readonly count: number;
}

Your code names it in one line, in the file that uses it

import { histogramBins } from "#fune/charts.histogram-bins@^1";
impl/typescript.ts · 92 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { type Bin, type BinMethod } from "./charts_histogram_bins_types.ts";
import { percentile } from "./stats_percentile.ts";  ← from stats.percentile ^1.0.0 · built alongside by fune
import { tickSpec } from "./charts_ticks_tick_step.ts";
import { pow } from "./math_pow.ts";  ← from math.pow ^1.0.0 · built alongside by fune
import { roundFloat } from "./math_round_float.ts";  ← from math.round-float ^1.0.0 · built alongside by fune

const MAX_BINS = 10000;

/**
 * Histogram bins on round edges, as d3.bin lays them out: the method decides
 * roughly how many bins, a tick step of 1, 2 or 5 x 10^k turns that into a
 * width, and the edges are multiples of it from the first at or below the
 * minimum to the first at or above the maximum. Each bin holds x0 <= v < x1;
 * the last also holds its right edge, so the maximum is always counted.
 */
export function histogramBins(values: readonly number[], method: BinMethod, binWidth: number | null): readonly Bin[] {
  if (method === "fixed-width") {
    if (binWidth === null || typeof binWidth !== "number" || !Number.isFinite(binWidth) || binWidth <= 0) {
      throw new Error(`fixed-width needs a binWidth greater than 0; got ${binWidth}`);
    }
  } else if (method === "sturges" || method === "freedman-diaconis") {
    if (binWidth !== null) throw new Error(`binWidth is only for fixed-width; pass null for ${method}`);
  } else {
    throw new Error(`unknown bin method "${method}"`);
  }
  const n = values.length;
  if (n === 0) return [];
  let lo = values[0];
  let hi = values[0];
  for (const v of values) {
    if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`values must be finite numbers; got ${v}`);
    if (v < lo) lo = v;
    if (v > hi) hi = v;
  }

  let mul: number;
  let div: number;
  if (method === "fixed-width") {
    mul = binWidth as number;
    div = 1;
  } else {
    if (lo === hi) return [{ x0: lo + 0, x1: hi + 0, count: n }];
    let count: number;
    if (method === "sturges") {
      // ceil(log2 n) + 1, by doubling rather than a logarithm.
      let bits = 0;
      let power = 1;
      while (power < n) {
        power *= 2;
        bits += 1;
      }
      count = bits + 1;
    } else {
      // Freedman and Diaconis: bin width 2 IQR n^(-1/3); d3 falls back to one
      // bin when the interquartile range is zero.
      const iqr = percentile(values, 75, "linear", 12) - percentile(values, 25, "linear", 12);
      const width = 2 * iqr * pow(n, -1 / 3);
      const bins = width > 0 ? Math.ceil((hi - lo) / width) : 1;
      if (bins > MAX_BINS) throw new Error(`too many bins: more than ${MAX_BINS}`);
      count = Math.max(1, bins);
    }
    [mul, div] = tickSpec(lo, hi, count);
  }

  // Edge i is one exact operation on a whole number, cleaned at 12 places so
  // a fixed width of 0.1 gives an edge of 0.3, not 0.30000000000000004.
  const edge = (i: number) => roundFloat(div > 1 ? i / div : i * mul, 12);
  let first = Math.floor(div > 1 ? lo * div : lo / mul);
  while (edge(first) > lo) first -= 1;
  while (edge(first + 1) <= lo) first += 1;
  let last = first + 1;
  while (edge(last) < hi) {
    last += 1;
    if (last - first > MAX_BINS) throw new Error(`too many bins: more than ${MAX_BINS}`);
  }

  const edges: number[] = [];
  for (let i = first; i <= last; i++) edges.push(edge(i));
  const counts = edges.slice(1).map(() => 0);
  for (const v of values) {
    // The last edge at or below v; the maximum falls in the last bin.
    let a = 0;
    let b = counts.length - 1;
    while (a < b) {
      const mid = Math.floor((a + b + 1) / 2);
      if (edges[mid] <= v) a = mid;
      else b = mid - 1;
    }
    counts[a] += 1;
  }
  return counts.map((count, i) => ({ x0: edges[i], x1: edges[i + 1], count }));
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add charts.histogram-bins
Download for TypeScript charts.histogram-bins-1.0.0-typescript.fune · 12,690 bytes sha256 7797cdfbf64b2a6d893aa934ed1ee42f82a0e78cbbe1e8075faee7ce05d2b239

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./charts.histogram-bins-1.0.0-typescript.fune, or fetch it from a terminal with fune pull charts.histogram-bins@1.0.0:typescript.

The whole function, every language, is one file too: charts.histogram-bins-1.0.0.fune, 20,490 bytes, sha256 670e89879fefdf330fd24fb0245816b60333ca6a97345040574f0e8cab8b1915. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before charts.histogram-bins

after — your function gets the result and the arguments, and returns the final result.

// fune: after charts.histogram-bins

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace charts.ticks in charts.histogram-bins
// fune: replace math.pow in charts.histogram-bins
// fune: replace math.round-float in charts.histogram-bins
// fune: replace stats.percentile in charts.histogram-bins

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show charts.histogram-bins --steps.

// fune: step charts.histogram-bins after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
Sturges: ten values make five bins of width 2 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, sturges, — → ×5
Sturges with an outlier: five wide bins 1, 2, 3, 4, 5, 6, 7, 8, 9, 100, sturges, — → ×5
Freedman-Diaconis with the same outlier: narrow bins sized by the quartiles 1, 2, 3, 4, 5, 6, 7, 8, 9, 100, freedman-diaconis, — → ×20
Freedman-Diaconis with no spread in the quartiles falls back to one bin 5, 5, 5, 5, 9, freedman-diaconis, — → ×1
unsorted fractions on fifths 0.12, 0.47, 0.33, 0.91, sturges, — → ×5
fixed width 0.1: 0.3 is on the edge 0.3, not below 0.30000000000000004 0.05, 0.25, 0.3, 0.31, fixed-width, 0.1 → ×4
fixed width: the maximum on an edge goes in the last bin 0, 5, 10, fixed-width, 5 → ×2
fixed width across zero -7, -1, 3, fixed-width, 5 → ×3
fixed width with one value 7, fixed-width, 5 → ×1
one value with Sturges is a bin of no width 4.5, sturges, — → ×1
Show the other 7 tests
CaseArgumentsExpected
equal values with Sturges 3, 3, 3, sturges, — → ×1
no values, no bins , sturges, — →
fixed-width without a width is an error 1, 2, fixed-width, — → error: fixed-width needs a binWidth greater than 0
a zero width is an error 1, 2, fixed-width, 0 → error: fixed-width needs a binWidth greater than 0
a width with Sturges is an error 1, 2, sturges, 2 → error: binWidth is only for fixed-width
an unknown method is an error 1, 2, scott, — → error: unknown bin method "scott"
more than 10,000 bins is an error 0, 100, fixed-width, 0.001 → error: too many bins: more than 10000

More from the author

- **`sturges`**: ceil(log2 n) + 1 bins (Sturges 1926), d3.bin's default. It assumes a roughly normal sample and gives too few bins for large or skewed ones. log2 is found by doubling, not with a logarithm. - **`freedman-diaconis`**: bin width 2 x IQR x n^(-1/3) (Freedman and Diaconis 1981), which follows the middle half of the data and so is not stretched by an outlier. The quartiles are `stats.percentile` with the `linear` method (R-7, as d3's quantile), and n^(-1/3) is `math.pow`. When the interquartile range is zero it falls back to one bin, as d3 does. - **`fixed-width`**: the width you pass, with edges on its multiples (a width of 5 puts edges on ..., -5, 0, 5, 10, ...). `binWidth` must be null for the other two methods: an argument that would be silently ignored is an error.

For the first two, the count is turned into a round width with `charts.ticks` (1, 2 or 5 x 10^k for about that many bins), as d3.bin does with its nice thresholds, so edges fall on numbers a reader expects rather than on min + k x (max - min) / count.

## Edges

The first edge is the multiple of the width at or below the minimum and the last the first at or above the maximum. Each edge is one exact operation on a whole number (i x width, or i / divisor for a fractional round width) and is then cleaned at 12 decimal places with `math.round-float`, so a width of 0.1 has an edge at 0.3, not 0.30000000000000004 as `3 * 0.1` gives, and a value of exactly 0.3 lands in the bin starting there.

No values gives no bins; values that are all equal give one bin of no width under the first two methods. More than 10,000 bins is an error.

Sources: H. A. Sturges, "The Choice of a Class Interval", Journal of the American Statistical Association 21 (1926) 65-66; D. Freedman and P. Diaconis, "On the histogram as a density estimator: L2 theory", Zeitschrift für Wahrscheinlichkeitstheorie 57 (1981) 453-476; Mike Bostock, d3-array `bin.js` and `threshold/*.js`.

Files

PathBytes
README.md2,273
impl/python.py3,254
impl/rust.rs4,250
impl/typescript.ts3,595
vectors.json3,747