apexcharts
Version:
A JavaScript Chart Library
454 lines (417 loc) • 14.7 kB
JavaScript
// @ts-check
/**
* Histogram binning.
*
* Pure math: raw observations in, bin edges and counts out. No DOM, no chart
* state, no config object, so it is testable on its own and safe under SSR.
*
* A histogram is the one aggregate mark that knows exactly which rows it
* stands for, because the binning here is what aggregated them. `rowsForBin`
* recovers those rows on demand from the same (values, edges) pair the render
* used, so nothing has to be retained per bar.
*
* @module charts/common/Stats
*/
/**
* Upper bound on bins. A degenerate binWidth (a stray 1e-9, a range spanning
* timestamps) would otherwise ask for millions of bars and hang the render.
*/
const MAX_BINS = 1000
/**
* @typedef {Object} Binning
* @property {number[]} edges - bin boundaries, length = binCount + 1
* @property {number} binWidth - uniform width (edges are evenly spaced)
* @property {string} rule - the rule that chose the width ('fd', 'sturges', ...)
* @property {boolean} capped - true when MAX_BINS clamped the requested count
*/
/**
* Quantile of an ascending-sorted array (linear interpolation between ranks,
* the same definition numpy and R type 7 use).
* @param {number[]} sorted
* @param {number} q - 0..1
* @returns {number}
*/
export function quantileSorted(sorted, q) {
const n = sorted.length
if (n === 0) return NaN
if (n === 1) return sorted[0]
const pos = (n - 1) * q
const lo = Math.floor(pos)
const hi = Math.ceil(pos)
if (lo === hi) return sorted[lo]
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo)
}
/**
* Population standard deviation.
* @param {number[]} values
* @returns {number}
*/
function stdDev(values) {
const n = values.length
if (n < 2) return 0
let sum = 0
for (let i = 0; i < n; i++) sum += values[i]
const mean = sum / n
let acc = 0
for (let i = 0; i < n; i++) {
const d = values[i] - mean
acc += d * d
}
return Math.sqrt(acc / n)
}
/**
* Bin width suggested by a named rule.
*
* - `sturges` : span / (log2(n) + 1). Assumes roughly normal data; under-bins
* large samples but never produces a silly count.
* - `rice` : span / (2 * n^(1/3)). A simple, slightly more generous count.
* - `sqrt` : span / sqrt(n). The spreadsheet rule; included because people
* expect it.
* - `scott` : 3.49 * sd * n^(-1/3). Optimal for normal data.
* - `fd` : 2 * IQR * n^(-1/3) (Freedman-Diaconis). Robust to outliers,
* which is why it is the usual default, but it collapses to 0 when more than
* half the values are identical.
* - `auto` : the narrower of `fd` and `sturges`, falling back to `sturges`
* when the IQR is 0. Same compromise numpy makes: FD's robustness with a
* guard against its degenerate case.
*
* @param {number[]} sorted - ascending, finite
* @param {number} span - hi - lo (> 0)
* @param {string} rule
* @returns {{ width: number, rule: string }}
*/
export function widthForRule(sorted, span, rule) {
const n = sorted.length
const byCount = (/** @type {number} */ count) =>
span / Math.max(1, Math.ceil(count))
switch (rule) {
case 'sqrt':
return { width: byCount(Math.sqrt(n)), rule: 'sqrt' }
case 'rice':
return { width: byCount(2 * Math.cbrt(n)), rule: 'rice' }
case 'scott': {
const sd = stdDev(sorted)
if (sd > 0) return { width: 3.49 * sd * Math.pow(n, -1 / 3), rule: 'scott' }
return { width: byCount(Math.log2(n) + 1), rule: 'sturges' }
}
case 'fd': {
const iqr = quantileSorted(sorted, 0.75) - quantileSorted(sorted, 0.25)
if (iqr > 0) return { width: 2 * iqr * Math.pow(n, -1 / 3), rule: 'fd' }
return { width: byCount(Math.log2(n) + 1), rule: 'sturges' }
}
case 'auto': {
const sturges = byCount(Math.log2(n) + 1)
const iqr = quantileSorted(sorted, 0.75) - quantileSorted(sorted, 0.25)
if (iqr <= 0) return { width: sturges, rule: 'sturges' }
const fd = 2 * iqr * Math.pow(n, -1 / 3)
return fd < sturges ? { width: fd, rule: 'fd' } : { width: sturges, rule: 'sturges' }
}
case 'sturges':
default:
return { width: byCount(Math.log2(n) + 1), rule: 'sturges' }
}
}
/**
* Choose bin edges for a set of observations.
*
* Precedence: explicit `binWidth` > explicit bin count > named rule. `range`
* frames the axis independently of the data, so several histograms can share
* one scale.
*
* @param {number[]} values - finite observations (any order)
* @param {Object} [opts]
* @param {string|number} [opts.bins] - a rule name, or a fixed bin count
* @param {number} [opts.binWidth] - explicit width, wins over `bins`
* @param {number[]} [opts.range] - [lo, hi] override for the binned extent
* @returns {Binning|null} null when there is nothing to bin
*/
export function computeBinning(values, opts = {}) {
if (!Array.isArray(values) || values.length === 0) return null
const sorted = values.slice().sort((a, b) => a - b)
let lo = sorted[0]
let hi = sorted[sorted.length - 1]
const range = opts.range
if (Array.isArray(range) && range.length === 2) {
const rLo = Number(range[0])
const rHi = Number(range[1])
if (isFinite(rLo) && isFinite(rHi) && rHi > rLo) {
lo = rLo
hi = rHi
}
}
// Every observation identical (or a single point): one bin centred on it,
// wide enough to draw. Without this the span is 0 and every rule divides by
// zero.
if (!(hi > lo)) {
const pad = Math.abs(lo) > 0 ? Math.abs(lo) * 0.05 : 0.5
return {
edges: [lo - pad, lo + pad],
binWidth: pad * 2,
rule: 'single',
capped: false,
}
}
const span = hi - lo
let width
let rule
if (typeof opts.binWidth === 'number' && opts.binWidth > 0) {
width = opts.binWidth
rule = 'binWidth'
} else if (typeof opts.bins === 'number' && opts.bins >= 1) {
width = span / Math.floor(opts.bins)
rule = 'count'
} else {
const chosen = widthForRule(
sorted,
span,
typeof opts.bins === 'string' ? opts.bins : 'auto',
)
width = chosen.width
rule = chosen.rule
}
if (!isFinite(width) || width <= 0) width = span
let count = Math.ceil(span / width)
if (!isFinite(count) || count < 1) count = 1
let capped = false
if (count > MAX_BINS) {
count = MAX_BINS
width = span / count
capped = true
}
// Rebuild the width from the final count so the edges tile [lo, hi] exactly:
// a rule-derived width usually leaves a partial last bin, and a bar half the
// width of its neighbours reads as a data feature rather than a rounding
// artifact.
width = span / count
const edges = new Array(count + 1)
for (let k = 0; k <= count; k++) edges[k] = lo + k * width
// Guard the last edge against float drift so the maximum observation always
// lands inside the final bin.
edges[count] = Math.max(edges[count], hi)
return { edges, binWidth: width, rule, capped }
}
/**
* Index of the bin containing `v`, or -1 when it falls outside the edges.
* Bins are half-open [lo, hi) except the last, which includes its upper edge.
*
* @param {number} v
* @param {number[]} edges
* @returns {number}
*/
export function binIndexOf(v, edges) {
const last = edges.length - 1
if (!(v >= edges[0]) || v > edges[last]) return -1
if (v === edges[last]) return last - 1
// Uniform edges: arithmetic beats a search, which matters at 100k+ points.
const width = (edges[last] - edges[0]) / last
if (width > 0) {
let k = Math.floor((v - edges[0]) / width)
if (k < 0) k = 0
if (k > last - 1) k = last - 1
// Correct for float drift at a boundary rather than trusting the divide.
if (v < edges[k]) k--
else if (v >= edges[k + 1]) k++
if (k < 0 || k > last - 1) return -1
return k
}
let lo = 0
let hi = last - 1
while (lo <= hi) {
const mid = (lo + hi) >> 1
if (v < edges[mid]) hi = mid - 1
else if (v >= edges[mid + 1]) lo = mid + 1
else return mid
}
return -1
}
/**
* Count observations per bin.
* @param {number[]} values
* @param {number[]} edges
* @returns {number[]}
*/
export function binCounts(values, edges) {
const counts = new Array(Math.max(0, edges.length - 1)).fill(0)
for (let i = 0; i < values.length; i++) {
const k = binIndexOf(values[i], edges)
if (k >= 0) counts[k]++
}
return counts
}
/**
* The observations a given bar aggregates.
*
* This is the row source an object/aggregate transition needs: a histogram bar
* can name its rows exactly, without any of them being retained at render
* time. Recomputed on demand from the same inputs the binning used.
*
* @param {number[]} values
* @param {number[]} edges
* @param {number} k - bin index
* @returns {number[]}
*/
export function rowsForBin(values, edges, k) {
/** @type {number[]} */
const out = []
if (k < 0 || k >= edges.length - 1) return out
for (let i = 0; i < values.length; i++) {
if (binIndexOf(values[i], edges) === k) out.push(values[i])
}
return out
}
/**
* Every bar's rows at once, as one bucket per bin in bin order.
*
* The same answer `rowsForBin` gives, in a single pass. Asking that function
* for each bin in turn re-walks the whole sample every time, which is O(n·bins)
* and turns a 50k-observation, 100-bin explode into five million comparisons on
* the click. Bins with no observations keep their (empty) slot: the caller maps
* bucket k onto bar k positionally, so a compacted array would silently shift
* every bar after the first gap.
*
* @param {number[]} values
* @param {number[]} edges
* @returns {number[][]} length = edges.length - 1
*/
export function rowsByBin(values, edges) {
const n = Math.max(0, edges.length - 1)
/** @type {number[][]} */
const buckets = new Array(n)
for (let k = 0; k < n; k++) buckets[k] = []
for (let i = 0; i < values.length; i++) {
const k = binIndexOf(values[i], edges)
if (k >= 0) buckets[k].push(values[i])
}
return buckets
}
/**
* Five-number summary of a sample, in the order boxPlot draws it.
*
* Quartiles use linear interpolation between ranks (R type 7 / numpy default),
* so a sample of 4 and a sample of 4000 are summarised the same way.
*
* Whiskers:
* - `minmax` : the extremes. Nothing is hidden, which matters because the
* chart has no separate outlier mark unless observations are also drawn.
* - `tukey` : the furthest observations inside q1 - 1.5*IQR and
* q3 + 1.5*IQR, the convention most statistics packages use. Anything
* beyond the fence is outside the whisker, so pair it with
* `plotOptions.boxPlot.points.show` or those points become invisible.
*
* @param {number[]} values - finite observations (any order)
* @param {Object} [opts]
* @param {string} [opts.whiskers] - 'minmax' (default) | 'tukey'
* @returns {{ summary: number[], outliers: number[], iqr: number } | null}
*/
export function fiveNumberSummary(values, opts = {}) {
if (!Array.isArray(values) || values.length === 0) return null
const sorted = values.slice().sort((a, b) => a - b)
const q1 = quantileSorted(sorted, 0.25)
const median = quantileSorted(sorted, 0.5)
const q3 = quantileSorted(sorted, 0.75)
const iqr = q3 - q1
let lo = sorted[0]
let hi = sorted[sorted.length - 1]
/** @type {number[]} */
let outliers = []
if (opts.whiskers === 'tukey' && iqr > 0) {
const loFence = q1 - 1.5 * iqr
const hiFence = q3 + 1.5 * iqr
let i = 0
while (i < sorted.length && sorted[i] < loFence) i++
let j = sorted.length - 1
while (j >= 0 && sorted[j] > hiFence) j--
if (i <= j) {
lo = sorted[i]
hi = sorted[j]
outliers = sorted.slice(0, i).concat(sorted.slice(j + 1))
}
}
return { summary: [lo, q1, median, q3, hi], outliers, iqr }
}
/**
* Kernel density estimate of a sample, as the `[value, weight]` pairs a violin
* draws.
*
* Gaussian kernel; bandwidth from Silverman's rule of thumb, which uses the
* smaller of the standard deviation and a scaled IQR so one distant outlier
* cannot smear the whole curve flat. The estimate is evaluated on an evenly
* spaced grid padded by two bandwidths, so the curve reaches zero instead of
* being cut off mid-slope.
*
* @param {number[]} values - finite observations (any order)
* @param {Object} [opts]
* @param {number} [opts.bandwidth] - explicit bandwidth, overrides the rule
* @param {number} [opts.resolution] - grid points (default 64)
* @returns {{ density: Array<[number, number]>, bandwidth: number } | null}
*/
export function kernelDensity(values, opts = {}) {
if (!Array.isArray(values) || values.length === 0) return null
const sorted = values.slice().sort((a, b) => a - b)
const n = sorted.length
let h = opts.bandwidth
if (!(typeof h === 'number' && h > 0)) {
const sd = stdDev(sorted)
const iqr = quantileSorted(sorted, 0.75) - quantileSorted(sorted, 0.25)
const spread = iqr > 0 ? Math.min(sd, iqr / 1.349) : sd
h = 0.9 * spread * Math.pow(n, -1 / 5)
}
if (!isFinite(h) || h <= 0) {
// Every observation identical: no spread to estimate. A hairline spike at
// the value is the honest picture.
const v = sorted[0]
const eps = Math.abs(v) > 0 ? Math.abs(v) * 1e-3 : 1e-3
return {
density: [
[v - eps, 0],
[v, 1],
[v + eps, 0],
],
bandwidth: eps,
}
}
const steps = Math.max(8, Math.floor(opts.resolution || 64))
const lo = sorted[0] - 2 * h
const hi = sorted[n - 1] + 2 * h
const step = (hi - lo) / (steps - 1)
const norm = 1 / (n * h * Math.sqrt(2 * Math.PI))
/** @type {Array<[number, number]>} */
const density = []
for (let g = 0; g < steps; g++) {
const x = lo + g * step
let sum = 0
for (let i = 0; i < n; i++) {
const z = (x - sorted[i]) / h
sum += Math.exp(-0.5 * z * z)
}
density.push([x, sum * norm])
}
return { density, bandwidth: h }
}
/**
* Apply cumulative accumulation and the y normalization to raw counts.
*
* @param {number[]} counts
* @param {Object} [opts]
* @param {string} [opts.normalize] - 'count' | 'relative' | 'density'
* @param {boolean} [opts.cumulative]
* @param {number} [opts.binWidth]
* @returns {number[]}
*/
export function normalizeCounts(counts, opts = {}) {
let out = counts.slice()
if (opts.cumulative) {
let acc = 0
out = out.map((c) => (acc += c))
}
const total = counts.reduce((a, b) => a + b, 0)
if (total <= 0) return out
if (opts.normalize === 'relative') {
return out.map((c) => (c / total) * 100)
}
if (opts.normalize === 'density') {
const w = opts.binWidth
if (typeof w === 'number' && w > 0) return out.map((c) => c / (total * w))
}
return out
}