apexcharts
Version:
A JavaScript Chart Library
536 lines (495 loc) • 19 kB
JavaScript
// @ts-check
/**
* ApexCharts — statistics feature.
*
* Opt-in support for chart types whose series carries **raw observations**
* rather than the values it draws. The library computes the statistic:
*
* - `histogram` : bins a sample into counts
* - `boxPlot` : derives the five-number summary (quartiles + whiskers)
* - `violin` : estimates the density (Gaussian KDE)
*
* All three were the same gap. boxPlot demanded `[min, q1, median, q3, max]`
* and violin demanded a density profile, so both asked the caller to do the
* statistics that give the chart its meaning. Supplying the sample is now
* enough, and the observations live in the one field both types already use for
* jitter dots (`points`).
*
* Usage:
*
* import ApexCharts from 'apexcharts/core'
* import 'apexcharts/bar'
* import 'apexcharts/features/stats'
*
* or simply `import ApexCharts from 'apexcharts/histogram'`, which pulls in
* the bar renderer and this feature together.
*
* Only histogram REQUIRES the feature (there is nothing to draw without the
* bins). For boxPlot and violin it is purely additive: precomputed input keeps
* working untouched, with or without this import.
*
* The transforms register through SeriesTransformRegistry, so core keeps only
* a registry lookup and a bundle that never asks for a raw-sample type never
* pays for the statistics.
*
* @module features/stats
*/
import ApexCharts from '../apexcharts'
import Utils from '../utils/Utils'
import { registerSeriesTransform } from '../modules/SeriesTransformRegistry'
import { registerRowSource } from '../modules/RowSourceRegistry'
import {
binCounts,
computeBinning,
fiveNumberSummary,
kernelDensity,
normalizeCounts,
rowsByBin,
} from '../charts/common/Stats'
/**
* Collect the finite observations out of one histogram series' data. Raw
* values are the point of a histogram, so the accepted forms are the ways
* people actually hold a sample: a flat number array, `{ y }` / `{ x }`
* objects, or one-element arrays.
*
* @param {any} data
* @returns {number[]}
*/
function histogramValues(data) {
/** @type {number[]} */
const out = []
if (!Array.isArray(data)) return out
for (let i = 0; i < data.length; i++) {
const d = data[i]
/** @type {any} */
let raw = d
if (Array.isArray(d)) raw = d.length === 1 ? d[0] : d[1]
else if (d && typeof d === 'object') raw = d.y !== undefined ? d.y : d.x
const v = Utils.parseNumber(raw)
if (v !== null && isFinite(v)) out.push(v)
}
return out
}
/**
* Histogram transform. `chart.type: 'histogram'` renders through the bar
* pathway, so the raw observations the user supplied are binned here, before
* any parsing, into one `{ x: binMidpoint, y: count }` row per bar.
*
* All series share one set of edges, derived from their combined extent, so
* overlaid distributions stay comparable; binning each series to its own range
* would put different bars at the same x.
*
* parseData writes what this returns back to `cnf.series`, so binning the
* incoming series directly would re-bin the counts on the next render (a
* resize, a legend toggle) and quietly destroy the distribution. The raw
* observations are stashed once per data push and binning always reads the
* stash, the same contract the zoom-aware downsampler uses for its raw data.
*
* @param {any[]} ser
* @param {any} w
* @returns {any[]}
*/
function histogramTransform(ser, w) {
const cnf = w.config
const gl = w.globals
if (!Array.isArray(ser)) return ser
if (!gl.histogramRawSeries) {
gl.histogramRawSeries = ser.map((/** @type {any} */ s) => ({
...s,
data: Array.isArray(s?.data) ? s.data.slice() : s?.data,
}))
}
const raw = gl.histogramRawSeries
const hcfg = cnf.plotOptions?.histogram || {}
const perSeries = raw.map((/** @type {any} */ s) => histogramValues(s?.data))
/** @type {number[]} */
let all = []
if (perSeries.length === 1) {
all = perSeries[0]
} else {
for (const vals of perSeries) all = all.concat(vals)
}
const binning = computeBinning(all, {
bins: hcfg.bins,
binWidth: hcfg.binWidth,
range: hcfg.range,
})
if (!binning) {
w.histogramData = {
edges: [],
binWidth: 0,
counts: [],
rule: '',
capped: false,
}
return raw
}
const { edges, binWidth } = binning
/** @type {number[][]} */
const counts = perSeries.map((/** @type {number[]} */ vals) =>
binCounts(vals, edges),
)
w.histogramData = {
edges,
binWidth,
counts,
rule: binning.rule,
capped: binning.capped,
}
// A hidden series draws nothing, but it still counts towards the edges: the
// bins are the frame the distributions are compared in, and re-deriving them
// on a legend click would slide every remaining bar sideways. Emitted here
// rather than left to the caller, because this transform rebuilds the rows
// from the stash and would otherwise undo the collapse that put them there.
const collapsed = gl.collapsedSeriesIndices || []
return raw.map((/** @type {any} */ s, /** @type {number} */ i) => {
if (collapsed.indexOf(i) !== -1) return { ...s, data: [] }
const ys = normalizeCounts(counts[i], {
normalize: hcfg.normalize,
cumulative: hcfg.cumulative,
binWidth,
})
/** @type {any[]} */
const data = []
for (let k = 0; k < ys.length; k++) {
data.push({ x: (edges[k] + edges[k + 1]) / 2, y: ys[k] })
}
return { ...s, data }
})
}
/**
* The datum objects whose summary / density this feature computed.
*
* parseData writes the transform's output back to `config.series`, so the next
* pass sees a datum that already carries a summary and would leave it alone.
* That froze the statistic at whatever the first render produced: changing
* `plotOptions.boxPlot.whiskers` or `plotOptions.violin.kde` through
* updateOptions re-parsed the data but could never re-derive it.
*
* A WeakSet of the objects we created distinguishes "the user precomputed this"
* from "we computed this last render", without writing a marker into the user's
* data or retaining anything (the entries die with the datum). Both transforms
* keep the observations on the datum, so re-deriving needs no stashed copy.
*/
const derivedData = new WeakSet()
/**
* The observations attached to one boxPlot / violin datum, or null when the
* datum does not carry a sample.
*
* `points` is the field both types already use for jitter dots, so a sample
* lives in exactly one place whether the library summarises it or the user
* pre-summarised it. Violin additionally accepts a flat number array as `y`,
* which is unambiguous there because a density profile is an array of PAIRS.
*
* @param {any} d - one datum
* @param {boolean} allowFlatY
* @returns {number[]|null}
*/
function observationsOf(d, allowFlatY) {
if (!d || typeof d !== 'object' || Array.isArray(d)) return null
/** @type {any} */
let raw = null
if (Array.isArray(d.points)) raw = d.points
else if (Array.isArray(d.y?.points)) raw = d.y.points
else if (allowFlatY && Array.isArray(d.y) && typeof d.y[0] === 'number') {
raw = d.y
}
if (!raw) return null
/** @type {number[]} */
const out = []
for (let i = 0; i < raw.length; i++) {
const v = Utils.parseNumber(raw[i])
if (v !== null && isFinite(v)) out.push(v)
}
return out.length ? out : null
}
/**
* boxPlot transform: derive the five-number summary from raw observations.
*
* A boxPlot has always required the summary itself (`y: [min, q1, median, q3,
* max]`), which means computing quartiles before you can draw a box. Supply
* `points` instead and the summary is computed here.
*
* A datum that already carries a 5-number `y` is left exactly as it was, so a
* 5-number y is never reinterpreted and existing charts cannot change shape.
* That also makes the transform idempotent: parseData writes the result back to
* config.series, and a second pass sees a summary and does nothing.
*
* @param {any[]} ser
* @param {any} w
* @returns {any[]}
*/
function boxPlotTransform(ser, w) {
if (!Array.isArray(ser)) return ser
const whiskers = w.config.plotOptions?.boxPlot?.whiskers || 'minmax'
return ser.map((/** @type {any} */ s) => {
if (!Array.isArray(s?.data)) return s
let touched = false
const data = s.data.map((/** @type {any} */ d) => {
// An existing summary wins: this is the documented input shape. Unless we
// are the ones who derived it, in which case the options that shaped it
// may have changed.
if (Array.isArray(d?.y) && d.y.length === 5 && !derivedData.has(d)) {
return d
}
const values = observationsOf(d, false)
if (!values) return d
const summary = fiveNumberSummary(values, { whiskers })
if (!summary) return d
touched = true
const next = { ...d, y: summary.summary, points: values }
derivedData.add(next)
return next
})
return touched ? { ...s, data } : s
})
}
/**
* violin transform: estimate the density from raw observations.
*
* A violin has always required a precomputed density profile
* (`y: { density: [[value, weight], ...] }`), which means running a kernel
* density estimate before you can draw a violin. Supply the observations
* (`points`, or a flat number array as `y`) and the estimate happens here.
*
* An existing density is never recomputed, so the transform is idempotent for
* the same reason the boxPlot one is.
*
* @param {any[]} ser
* @param {any} w
* @returns {any[]}
*/
function violinTransform(ser, w) {
if (!Array.isArray(ser)) return ser
const kde = w.config.plotOptions?.violin?.kde || {}
return ser.map((/** @type {any} */ s) => {
if (!Array.isArray(s?.data)) return s
let touched = false
const data = s.data.map((/** @type {any} */ d) => {
if (
Array.isArray(d?.y?.density) &&
d.y.density.length &&
!derivedData.has(d)
) {
return d
}
const values = observationsOf(d, true)
if (!values) return d
const est = kernelDensity(values, {
bandwidth: kde.bandwidth,
resolution: kde.resolution,
})
if (!est) return d
touched = true
const next = { ...d, y: { density: est.density, points: values } }
derivedData.add(next)
return next
})
return touched ? { ...s, data } : s
})
}
/* -------------------------------------------------------------------------- *
* Row sources: the rows behind each mark.
*
* These three types are the only ones in the library that can answer, because
* they are the only ones handed the raw observations to begin with. An ordinary
* bar aggregates rows nobody ever gave us. See RowSourceRegistry for the
* ordering contract, which the morph mapping depends on.
* -------------------------------------------------------------------------- */
/**
* Default ceiling on the dots one explode may produce. The unit engine draws
* one element per dot, so this is the same reason (and the same number)
* `Jitter.js` caps its packed points at.
*/
const DEFAULT_MAX_ROWS = 3000
/**
* Thin every cluster by ONE shared stride so the relative sizes survive.
*
* A per-cluster budget would pull every cluster towards the same count, which
* flattens exactly the shape the explode exists to show: the tall bins are tall
* because they hold more rows. One global stride keeps the proportions and only
* lowers the resolution.
*
* @param {any[][]} clusters - rows per cluster
* @param {number} maxRows
* @returns {{ clusters: any[][], stride: number, total: number, kept: number }}
*/
function thinClusters(clusters, maxRows) {
let total = 0
let widest = 0
for (const c of clusters) {
total += c.length
if (c.length > widest) widest = c.length
}
if (total <= maxRows) return { clusters, stride: 1, total, kept: total }
/** @param {number} s */
const keptAt = (s) => {
let n = 0
for (const c of clusters) n += Math.ceil(c.length / s)
return n
}
// `ceil(total / maxRows)` is the floor, not the answer: rounding UP inside
// every cluster (so a cluster never vanishes entirely) means the total can
// overshoot by as much as one row per cluster. Walk the stride up until it
// genuinely fits, so maxRows is a ceiling rather than an aspiration.
let stride = Math.max(2, Math.ceil(total / maxRows))
while (stride < widest && keptAt(stride) > maxRows) stride++
let kept = 0
const out = clusters.map((rows) => {
/** @type {any[]} */
const keepList = []
for (let i = 0; i < rows.length; i += stride) keepList.push(rows[i])
kept += keepList.length
return keepList
})
// Once every non-empty cluster is down to a single row there is nothing left
// to thin: dropping clusters would break the ordering contract, so the cap
// loses and the warning reports what actually happened.
return { clusters: out, stride, total, kept }
}
/**
* Turn per-cluster row arrays into the unit-chart series shape, applying the
* cap and warning when it bites. Silence would read as data loss.
*
* Each datum carries the observation as `y` (so bubble sizing and colour scales
* can read it), a stable `id` (so `transition: 'identity'` can follow one
* observation across any relayout), and the colour of the mark it came out of:
* rows inherit their mark's colour, which is what makes an explode look like
* one thing coming apart rather than a new chart appearing.
*
* @param {any} w
* @param {Array<{ name: string, realIndex: number, rows: any[] }>} clusters
* @param {any} [opts]
* @returns {any[]}
*/
function toUnitSeries(w, clusters, opts) {
const maxRows = opts && opts.maxRows != null ? opts.maxRows : DEFAULT_MAX_ROWS
const thinned = thinClusters(
clusters.map((c) => c.rows),
maxRows,
)
if (thinned.stride > 1) {
console.warn(
`ApexCharts: rowSeries() thinned ${thinned.total} rows to ${thinned.kept} ` +
`(every ${thinned.stride}${thinned.stride === 2 ? 'nd' : thinned.stride === 3 ? 'rd' : 'th'} row) ` +
`to stay under maxRows=${maxRows}. Raise maxRows to draw more.`,
)
}
const colors = (w.globals && w.globals.colors) || []
return clusters.map((c, i) => {
const fillColor = colors[c.realIndex] || colors[0]
return {
name: c.name,
data: thinned.clusters[i].map((v, q) => ({
id: `${c.realIndex}:${i}:${q}`,
x: c.name,
y: v,
...(fillColor ? { fillColor } : {}),
})),
}
})
}
/**
* Histogram row source: the observations behind every bar.
*
* One cluster per (series, bin), in draw order, which is series-major because
* that is how the bar renderer emits its groups. Bins with no observations keep
* their empty cluster so cluster `k` stays aligned with bar `k`.
*
* A collapsed series draws no bars at all (its transform emits `data: []`), so
* it contributes no clusters either. Emitting them anyway would push every
* later cluster onto the wrong bar.
*
* @param {any} w
* @param {any} [opts]
* @returns {any[]|null}
*/
function histogramRows(w, opts) {
const gl = w.globals
const hd = w.histogramData
const raw = gl && gl.histogramRawSeries
if (!hd || !Array.isArray(hd.edges) || hd.edges.length < 2) return null
if (!Array.isArray(raw) || !raw.length) return null
const collapsed = (gl && gl.collapsedSeriesIndices) || []
const edges = hd.edges
/** @type {Array<{ name: string, realIndex: number, rows: any[] }>} */
const clusters = []
raw.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
if (collapsed.indexOf(i) !== -1) return
const buckets = rowsByBin(histogramValues(s && s.data), edges)
const seriesName = (w.seriesData && w.seriesData.seriesNames?.[i]) || s?.name
buckets.forEach((rows, k) => {
const range = `${formatEdge(edges[k])}-${formatEdge(edges[k + 1])}`
clusters.push({
// Only qualify by series when there is more than one to tell apart.
name: raw.length > 1 && seriesName ? `${seriesName} ${range}` : range,
realIndex: i,
rows,
})
})
})
return clusters.length ? toUnitSeries(w, clusters, opts) : null
}
/**
* Bin edges are derived, so they arrive with float noise (a "24.000000000000004"
* boundary). Cluster names are read by people.
* @param {number} v
* @returns {string}
*/
function formatEdge(v) {
if (!isFinite(v)) return String(v)
const r = Math.round(v)
return Math.abs(v - r) < 1e-6 ? String(r) : String(Number(v.toFixed(2)))
}
/**
* Build a row source for boxPlot / violin, whose observations are already on
* the chart state, one dense array per (series, category), in draw order. The
* two types differ only in which state slice holds them.
*
* @param {(w: any) => any[][] | null} pick - (w) => points[realIndex][j]
* @returns {(w: any, opts?: any) => any[]|null}
*/
function pointsRowSource(pick) {
return (w, opts) => {
const perSeries = pick(w)
if (!Array.isArray(perSeries) || !perSeries.length) return null
const collapsed = (w.globals && w.globals.collapsedSeriesIndices) || []
// `globals.labels` holds the axis POSITIONS once string categories have
// been mapped to numbers (a boxPlot of 'Alpha'/'Beta' has labels [1, 2]).
// The names people gave live on categoryLabels.
const labels =
(w.globals && (w.globals.categoryLabels?.length
? w.globals.categoryLabels
: w.globals.labels)) || []
/** @type {Array<{ name: string, realIndex: number, rows: any[] }>} */
const clusters = []
perSeries.forEach((/** @type {any} */ byCat, /** @type {number} */ i) => {
if (collapsed.indexOf(i) !== -1) return
if (!Array.isArray(byCat)) return
const seriesName = w.seriesData && w.seriesData.seriesNames?.[i]
byCat.forEach((/** @type {any} */ pts, /** @type {number} */ j) => {
const label = labels[j] != null ? String(labels[j]) : `#${j + 1}`
clusters.push({
name:
perSeries.length > 1 && seriesName
? `${seriesName} ${label}`
: label,
realIndex: i,
rows: Array.isArray(pts) ? pts.slice() : [],
})
})
})
return clusters.length ? toUnitSeries(w, clusters, opts) : null
}
}
const boxPlotRows = pointsRowSource((w) => w.candleData?.seriesBoxPoints)
const violinRows = pointsRowSource((w) => w.violinData?.seriesViolinPoints)
registerSeriesTransform('histogram', histogramTransform)
registerSeriesTransform('boxPlot', boxPlotTransform)
registerSeriesTransform('violin', violinTransform)
registerRowSource('histogram', histogramRows)
registerRowSource('boxPlot', boxPlotRows)
registerRowSource('violin', violinRows)
export default ApexCharts
export { histogramTransform, boxPlotTransform, violinTransform }
export { histogramRows, boxPlotRows, violinRows }