impl/typescript.ts
3,595 bytes · the TypeScript implementation · view 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 }));
}