apexcharts
Version:
A JavaScript Chart Library
1,251 lines (1,160 loc) • 83.7 kB
JavaScript
// @ts-check
import Graphics from '../modules/Graphics'
import Utils from '../utils/Utils'
import { Environment } from '../utils/Environment'
import { BrowserAPIs } from '../ssr/BrowserAPIs'
import { prefersReducedMotion } from '../modules/Animations'
/**
* ApexCharts Unit Class - dot-cluster / pictogram ("unit") chart.
*
* Renders ONE mark per unit of value (not one shape per aggregate magnitude).
* Each category's dots pack into an organic disc via a phyllotaxis (sunflower)
* spiral, so the boundary is a smooth circle and the local packing is hex-ish -
* no force simulation / collision step is used (the layout is fully analytic).
*
* Two layouts:
* - 'grouped' (default): each category is its own cluster, laid out in a row.
* - 'packed': a single blob; dots are coloured by category and, when
* `sortByGroup` is on, ordered smallest-group-first so the minority nests
* in the centre (the phyllotaxis assigns the smallest radii to the lowest
* indices).
*
* Data is the pie/donut flat model: `series: number[]` + `labels`, or the
* object form `[{ data: [{x,y}] }]`. Each value is a COUNT; the number of dots
* is `round(value / unitValue)`, capped by `maxUnits`.
*
* This is a PREMIUM chart type: without a valid license the chart still renders
* but carries the trial watermark (see modules/license/LicenseEnforcer).
*
* @module Unit
**/
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5))
/** @param {number} t */
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3)
}
export default class Unit {
/**
* @param {import('../types/internal').ChartStateW} w
* @param {import('../types/internal').ChartContext} ctx
*/
constructor(w, ctx) {
this.ctx = ctx
this.w = w
/** @type {number} shared dot radius, set by the layout pass */
this._lastDotR = 1
/** @type {{cells:{x:number,y:number}[]}|null} small-multiple grid track cells */
this._gridTrack = null
/** @type {number} denominator mapping a value -> filled cells (grid split) */
this._gridDenom = 1
/** @type {any} scatter (beeswarm) axis geometry, set by _layoutScatter */
this._scatterAxis = null
}
/**
* @param {any[]} series - flat count array (non-axis / pie-shaped data)
* @returns {any} the chart's root group element
*/
draw(series) {
const w = this.w
const graphics = new Graphics(w, this.ctx)
const ret = graphics.group({ class: 'apexcharts-unit' })
if (w.globals.noData || !Array.isArray(series) || series.length === 0) {
return ret
}
const opts = w.config.plotOptions.unit
const layout =
opts.layout === 'packed'
? 'packed'
: opts.layout === 'columns'
? 'columns'
: opts.layout === 'grid'
? 'grid'
: opts.layout === 'scatter'
? 'scatter'
: 'grouped'
// Keying decides which previous dot a new dot tweens from on an update:
// - 'group' (default): key "i:j" - a dot stays within its category slot.
// EXCEPTION: a 'packed' blob and a 'grid' waffle have no stable
// per-category slots (a category's slots are offset by the - changing -
// preceding categories' counts, so keying "i:j" would re-spin/re-shuffle
// every dot when a count moves). There we key by the physical slot
// instead, so a cell keeps its place: the colour boundary breathes in/out
// and only the rim adds/removes dots.
// - 'flow': key by GLOBAL draw order - the anonymous crowd migrates across
// a regroup (category count/identity changing) instead of fading in/out.
// - 'identity': key by each datum's id/name - a SPECIFIC unit migrates
// across any regroup or relayout, keeping its colour and size. Needs the
// per-unit object form with unique ids/names.
const transition = opts.transition
const flow = transition === 'flow'
const identity = transition === 'identity'
const unitValue = opts.unitValue > 0 ? opts.unitValue : 1
// value -> dot count. A non-zero value always yields at least one dot so a
// tiny category (e.g. 3 of 545) never vanishes entirely.
let counts = series.map((v) => {
const n = Math.abs(Utils.parseNumber(v)) / unitValue
return n > 0 ? Math.max(1, Math.round(n)) : 0
})
counts = this._applyMaxUnits(counts, opts.maxUnits)
const total = counts.reduce((a, b) => a + b, 0)
/** @type {{ i: number, cx: number, cy: number, outerR: number, dots: {x:number,y:number,slot?:number,r?:number}[] }[]} */
const clusters =
layout === 'packed'
? this._layoutPacked(counts, opts)
: layout === 'columns'
? this._layoutColumns(counts, opts)
: layout === 'grid'
? this._layoutGrid(counts, opts)
: layout === 'scatter'
? this._layoutScatter(opts)
: this._layoutGrouped(counts, opts)
// Small-multiple waffles paint a faint track backdrop (the "of N" cells)
// behind every tile's filled dots, and carry a per-tile label.
const gridSplit = layout === 'grid' && !!(opts.grid && opts.grid.split)
if (gridSplit) this._drawGridTrack(ret, graphics, opts)
// Scatter (beeswarm) paints its value X axis + category lanes behind the dots.
if (layout === 'scatter') this._drawScatterAxes(ret, graphics)
const dotR = this._lastDotR
const animate = this._shouldAnimate()
// Cross-type morph (bar/wedge -> unit): when the optional `morph` feature
// captured an outgoing bar/radial chart, each cluster's dots burst outward
// from the shape they replace instead of gathering from the plot centre.
const morph = this.ctx && this.ctx.morphTypeChange
const morphActive =
animate &&
!!morph &&
typeof morph.isActive === 'function' &&
morph.isActive() &&
typeof morph.getInitialCenterFor === 'function'
// Positions from the PREVIOUS render, keyed "i:j". On an update this lets a
// dot glide from its old slot to its new one (the keyed transition) instead
// of re-gathering from the centre. Dots with no previous slot are "enter"s
// and fly out from the centre + fade in. Ignored during a cross-type morph
// (the previous chart was not a unit chart).
const prev = animate && !morphActive && this.ctx ? this.ctx._unitPrevDots : null
/** @type {Map<string, {x:number,y:number,fill:string,r?:number}>} */
const nextPrev = new Map()
/** @type {{ node: SVGElement, x: number, y: number, cx0: number, cy0: number, r0?: number, r1?: number, delay: number, isEnter: boolean, fill0?: string, fill1?: string, _c0?: number[]|null, _c1?: number[]|null }[]} */
const animDots = []
// Per-unit data (one datum per dot) when the caller passed the object form
// series: [{ name, data: [...] }]. Lets a dot carry its own colour + info.
const unitData = w.seriesData.unitData || []
// Opt-in bubble sizing: scale each dot's radius by its datum value. The
// lattice is already spaced for the largest bubble (see _fixedRadius), so
// dots never overlap. Needs per-unit values; otherwise stays uniform.
const sizeStats = this._bubbleStats(unitData, opts, dotR)
// Running index across ALL dots of ALL clusters, used as the key in 'flow'
// mode so a dot is identified by its global order, not its category slot.
let gIndex = 0
clusters.forEach((cluster) => {
const color = w.globals.colors[cluster.i] || w.globals.colors[0] || '#008FFB'
const elSeries = graphics.group({
class: 'apexcharts-series',
seriesName: Utils.escapeString(
w.seriesData.seriesNames[cluster.i] || `series-${cluster.i + 1}`,
),
rel: cluster.i + 1,
'data:realIndex': cluster.i,
})
// Burst origin for this cluster when morphing from a bar/wedge (the
// captured shape's centre). All of the cluster's dots start stacked here
// and fan out to their slots.
const burst = morphActive ? morph.getInitialCenterFor(cluster.i) : null
const catData = unitData[cluster.i]
cluster.dots.forEach((d, j) => {
// A per-unit fillColor (object-form data) overrides the category colour.
const datum = catData ? catData[j] : undefined
const dotFill =
datum && typeof datum === 'object' && datum.fillColor
? datum.fillColor
: color
// Bubble sizing scales this dot's radius by its value; otherwise every
// dot shares the reference radius. The scatter layout may precompute a
// per-dot radius (bubble scatter / beeswarm) and stash it on the dot.
const rj =
d.r != null
? d.r
: sizeStats
? this._radiusForValue(this._unitValueOf(datum), sizeStats)
: dotR
const el = this._drawDot(graphics, opts, rj, dotFill, cluster.i, j)
elSeries.add(el)
// Identity keying: a specific unit (by id/name) persists across any
// regroup or relayout. Flow: anonymous crowd by global draw order.
// Group (default): the category slot.
let key
if (identity) {
// Needs an explicit id/name; a bare primitive is not a stable identity
// (duplicates would collide), so those fall back to global order.
const id =
datum && typeof datum === 'object'
? datum.id != null
? datum.id
: datum.name
: undefined
key = id != null ? `id:${id}` : `g:${gIndex}`
} else if (flow) {
key = String(gIndex)
} else if (d.slot != null) {
// Packed blob: key by physical spiral slot so a shell keeps its place
// (a dot near the colour boundary just recolours instead of the whole
// outer ring re-spinning across the disc when the inner count shifts).
key = `slot:${d.slot}`
} else {
key = `${cluster.i}:${j}`
}
gIndex++
nextPrev.set(key, { x: d.x, y: d.y, fill: dotFill, r: rj })
if (animate) {
const from = prev && prev.get(key)
// Priority: previous slot (keyed update) -> burst origin (morph) ->
// cluster centre (fresh mount). The first two keep dots opaque; a
// fresh mount fades them in.
const anchor = from || burst
// A fresh mount fades in: a small-multiple cell fades IN PLACE (a
// waffle fills cell-by-cell, it does not fly from a centre); other
// layouts gather out from the cluster centre.
const cx0 = anchor ? anchor.x : gridSplit ? d.x : cluster.cx
const cy0 = anchor ? anchor.y : gridSplit ? d.y : cluster.cy
el.node.style.opacity = anchor ? '1' : '0'
this._placeDot(el.node, opts, cx0, cy0)
animDots.push({
node: el.node,
x: d.x,
y: d.y,
cx0,
cy0,
// Radius tween: an identity-kept dot grows/shrinks from its previous
// size to its new one (e.g. bubble sizing turning on) instead of
// snapping. Enters/uniform updates keep r0 === r1 (no-op).
r0: from && from.r != null ? from.r : rj,
r1: rj,
// Colour tween: a dot that flows into a differently coloured group
// recolours as it travels rather than snapping at the first frame.
fill0: from ? from.fill : dotFill,
fill1: dotFill,
delay: 0, // assigned below (staggered by global order)
isEnter: !anchor,
})
} else {
this._placeDot(el.node, opts, d.x, d.y)
}
})
// Per-cluster label: a curved arc over a 'grouped' blob, a straight label
// by a 'columns' bar, or a straight per-tile label on a small-multiple
// waffle (a single 'grid' has no per-category labels - the legend carries
// them; packed interleaves categories, so a per-category label would be
// meaningless). Skip a hidden/empty category (0 dots) so a legend-toggled
// series leaves no ghost label. For a tile the percentage is value/denom
// (its own fill), not value/grand-total.
if (
(layout === 'grouped' || layout === 'columns' || gridSplit) &&
opts.clusterLabels &&
opts.clusterLabels.show &&
counts[cluster.i] > 0
) {
const labelTotal = gridSplit ? this._gridDenom : total
this._drawClusterLabel(elSeries, cluster, counts[cluster.i], labelTotal, opts, color)
}
ret.add(elSeries)
})
// Exit ghosts: dots present last render but gone now (a cluster shrank, or
// a whole category disappeared on a dataset switch) fade + collapse toward
// the plot centre so the removal reads as motion rather than a pop-out.
if (prev) {
const exits = this._collectExits(prev, nextPrev, opts)
if (exits.length) {
const exitGroup = graphics.group({ class: 'apexcharts-unit-exits' })
ret.add(exitGroup)
this._runExits(exitGroup, exits, opts)
}
}
// Remember this render's slots so the NEXT update can tween old -> new.
// Stored even when not animating (e.g. resize) so the following update
// starts from the correct on-screen positions.
if (this.ctx) this.ctx._unitPrevDots = nextPrev
if (animate && animDots.length) {
this._runGather(animDots)
}
return ret
}
/**
* Cap total dots to `maxUnits`, scaling every category down proportionally
* (a non-zero category keeps at least one dot). Warns once when it clips.
* @param {number[]} counts
* @param {number} maxUnits
* @returns {number[]}
*/
_applyMaxUnits(counts, maxUnits) {
const total = counts.reduce((a, b) => a + b, 0)
if (!maxUnits || maxUnits <= 0 || total <= maxUnits) return counts
const scale = maxUnits / total
console.warn(
`[ApexCharts] unit chart: ${total} dots exceeds maxUnits (${maxUnits}); ` +
`counts were scaled down proportionally. Raise plotOptions.unit.maxUnits ` +
`or use plotOptions.unit.unitValue to represent more units per dot.`,
)
return counts.map((c) => (c > 0 ? Math.max(1, Math.round(c * scale)) : 0))
}
/**
* Lay out each category as its own cluster in a horizontal row. All clusters
* share one dot radius (so dot size is comparable across clusters); the blob
* radius encodes the count.
* @param {number[]} counts
* @param {any} opts
*/
_layoutGrouped(counts, opts) {
const w = this.w
const gw = w.layout.gridWidth
const gh = w.layout.gridHeight
const labelSpace = opts.clusterLabels && opts.clusterLabels.show ? 30 : 6
// Only VISIBLE (non-empty) categories claim a cell, so a legend-hidden
// category leaves NO gap: the remaining clusters re-flow to fill the row
// (an empty category still returns a cluster, but with no dots and a neutral
// centre). Its slot in the row drives the equal-cell centre.
const visible = counts.map((_, i) => i).filter((i) => counts[i] > 0)
const Kv = Math.max(1, visible.length)
const slotOf = new Array(counts.length).fill(-1)
visible.forEach((i, s) => (slotOf[i] = s))
const cellW = gw / Kv
const availH = gh - labelSpace
const maxCount = Math.max(1, ...counts)
const pad = Math.min(cellW, availH) * 0.08
const availR = Math.max(4, Math.min(cellW, availH) / 2 - pad)
const step = this._resolveStep(opts, availR, maxCount)
this._lastDotR = this._dotRadiusFromStep(step, opts)
const dotR = this._lastDotR
const cy = labelSpace + availH / 2
const outerRs = counts.map((n) => step * Math.sqrt(Math.max(1, n)) + dotR)
// Cluster centres. Default is one equal-width cell per VISIBLE category.
// With a FIXED dot size (image / explicit size / bubble maxRadius) a large
// cluster can outgrow its cell and bleed into its neighbours, since it cannot
// shrink to fit. When any adjacent pair would overlap, re-pack the row: give
// each cluster a slot sized to its own blob, lay them left-to-right with a
// gap, and centre the group. Auto-sized clusters always fit their cell, so
// the equal-cell path is kept untouched for them (and for well-separated
// fixed clusters) - this only re-flows a layout that would otherwise collide.
const cellCentre = (/** @type {number} */ i) =>
slotOf[i] >= 0 ? cellW * (slotOf[i] + 0.5) : gw / 2
let centers = counts.map((_, i) => cellCentre(i))
const visOuter = visible.map((i) => outerRs[i])
let overlap = false
for (let s = 1; s < Kv; s++) {
if (
centers[visible[s]] - centers[visible[s - 1]] <
visOuter[s] + visOuter[s - 1]
) {
overlap = true
break
}
}
if (overlap) {
const gap = Math.max(2 * dotR, 8)
const totalW = visOuter.reduce((a, r) => a + 2 * r, 0) + gap * (Kv - 1)
/** @type {number[]} */
let visCenters
if (totalW <= gw) {
// Fits: lay clusters side by side (blob width + gap) and centre the row.
let x = (gw - totalW) / 2
visCenters = visOuter.map((r) => {
const c = x + r
x += 2 * r + gap
return c
})
} else if (Kv === 1) {
visCenters = [gw / 2]
} else {
// Over capacity: the fixed-size blobs need more width than the plot has
// and cannot shrink. Anchor the first blob flush-left and the last
// flush-right, spacing the rest evenly, so everything stays on-canvas
// (middle blobs may touch). Sizing them down removes the crowding.
const lo = visOuter[0]
const hi = gw - visOuter[Kv - 1]
visCenters = visOuter.map((_, s) => lo + ((hi - lo) * s) / (Kv - 1))
}
centers = counts.map((_, i) =>
slotOf[i] >= 0 ? visCenters[slotOf[i]] : gw / 2,
)
}
return counts.map((n, i) => ({
i,
cx: centers[i],
cy,
outerR: outerRs[i],
dots: this._spiral(centers[i], cy, n, step, 0),
}))
}
/**
* Lay out all categories into ONE packed blob. Dots are assigned spiral
* indices in category order (smallest-first when sortByGroup), so the
* minority group nests in the centre.
* @param {number[]} counts
* @param {any} opts
*/
_layoutPacked(counts, opts) {
const w = this.w
const gw = w.layout.gridWidth
const gh = w.layout.gridHeight
const labelSpace = 6
const total = Math.max(1, counts.reduce((a, b) => a + b, 0))
const availR = Math.max(
4,
Math.min(gw, gh - labelSpace) / 2 - Math.min(gw, gh) * 0.06,
)
const step = this._resolveStep(opts, availR, total)
this._lastDotR = this._dotRadiusFromStep(step, opts)
const cx = gw / 2
const cy = labelSpace + (gh - labelSpace) / 2
const order = counts.map((_, i) => i)
if (opts.sortByGroup !== false) {
order.sort((a, b) => counts[a] - counts[b])
}
const clusters = counts.map((_, i) => ({
i,
cx,
cy,
outerR: step * Math.sqrt(total) + this._lastDotR,
/** @type {{x:number,y:number,slot?:number}[]} */ dots: [],
}))
let gi = 0
order.forEach((catI) => {
for (let j = 0; j < counts[catI]; j++) {
const r = step * Math.sqrt(gi + 0.5)
const theta = gi * GOLDEN_ANGLE
// Tag each dot with its physical spiral slot. In a packed blob there are
// no stable per-category slots (the outer group's indices are offset by
// the inner group's count), so the keyed transition must key by this
// physical slot, not by "category:slot". See the keying note in draw().
clusters[catI].dots.push({
x: cx + r * Math.cos(theta),
y: cy + r * Math.sin(theta),
slot: gi,
})
gi++
}
})
return clusters
}
/**
* Lay out each category as a vertical BAR built from stacked dots (a unit /
* waffle column). Every bar shares one dot size and one width (the same
* number of dot columns); the bar's HEIGHT encodes its count. Dots fill each
* bar bottom-up, row by row. This is the "dot bar" state the circle layouts
* morph into: with `transition:'flow'` the dots glide straight from their
* circle slots into these bar slots (see the storyboard sample).
* @param {number[]} counts
* @param {any} opts
*/
_layoutColumns(counts, opts) {
const w = this.w
const gw = w.layout.gridWidth
const gh = w.layout.gridHeight
// Reserve room for the cluster label on whichever side it sits (top by
// default, bottom when clusterLabels.position === 'bottom'); the opposite
// side keeps a small default margin.
const labelsOn = !!(opts.clusterLabels && opts.clusterLabels.show)
const labelsBelow = labelsOn && opts.clusterLabels.position === 'bottom'
const topPad = labelsOn && !labelsBelow ? 30 : 6
// Only VISIBLE (non-empty) categories claim a column slot, so a legend-hidden
// bar leaves NO gap: the remaining bars re-flow to fill the row.
const visible = counts.map((_, i) => i).filter((i) => counts[i] > 0)
const Kv = Math.max(1, visible.length)
const slotOf = new Array(counts.length).fill(-1)
visible.forEach((i, s) => (slotOf[i] = s))
const cellW = gw / Kv
const barW = cellW * 0.62 // gap between neighbouring bars
// Keep the baseline off the legend / plot edge, plus a label band below the
// bars when the labels sit there.
const bottomPad = Math.max(8, gh * 0.04) + (labelsBelow ? 30 : 0)
const availH = Math.max(4, gh - topPad - bottomPad)
const maxCount = Math.max(1, ...counts)
const spacing = opts.spacing > 0 ? opts.spacing : 1
// Resolve the dot pitch (centre-to-centre) and the column count. For a
// fixed dot size the pitch is given, so choose the FEWEST columns that keep
// the tallest bar within the plot height: this fills the height and reads as
// a tall bar (a width-first choice would make squat, wide slabs). Clamped to
// the per-bar width budget, above which the bar has to grow more columns.
// For 'auto' size, pick the column count that lets the dots be as LARGE as
// possible while the tallest bar still fits the available height.
// The columns layout can size its dots independently of the pinned circle-
// beat `size` (a storyboard often pins a constant size so dots do not resize
// while migrating). `plotOptions.unit.columns.size`: 'auto' fills the plot
// height, a number pins a columns-only size, 'inherit'/undefined uses the
// global size. Image icons keep their intrinsic size (no override).
const colSize = opts.columns ? opts.columns.size : undefined
let fixed
if (opts.shape !== 'image' && colSize === 'auto') {
fixed = null
} else if (
opts.shape !== 'image' &&
typeof colSize === 'number' &&
colSize > 0
) {
fixed = colSize
} else {
fixed = this._fixedRadius(opts)
}
let cols = 1
let pitch = 0
if (fixed) {
pitch = 2 * fixed * spacing
this._lastDotR = fixed
const rowsCap = Math.max(1, Math.floor(availH / pitch))
const maxColsByWidth = Math.max(1, Math.floor(barW / pitch))
cols = Math.max(1, Math.min(maxColsByWidth, Math.ceil(maxCount / rowsCap)))
} else {
let best = 0
const maxCols = Math.max(1, Math.min(40, Math.round(barW / 4)))
for (let c = 1; c <= maxCols; c++) {
const rows = Math.ceil(maxCount / c)
const d = Math.min(barW / c, availH / rows)
if (d > best) {
best = d
cols = c
}
}
pitch = best
this._lastDotR = Math.max(1, pitch / (2 * spacing))
}
const r = this._lastDotR
// Vertically CENTRE the tallest bar within the band [topPad, gh-bottomPad]
// so a fixed dot size (bars shorter than the plot) does not dump all the
// slack above the row and crowd the legend. Every bar shares this baseline
// (bottom-aligned to it) so their heights stay directly comparable. For
// 'auto' sizing the tallest bar already fills the band, so this is a no-op.
const maxRows = Math.ceil(maxCount / cols)
const tallestBarH = Math.min(availH, maxRows * pitch)
const bottom = topPad + (availH + tallestBarH) / 2
return counts.map((n, i) => {
const cx = slotOf[i] >= 0 ? cellW * (slotOf[i] + 0.5) : gw / 2
const rows = Math.ceil(Math.max(1, n) / cols)
const barH = rows * pitch
// Centre the block of `cols` columns under the cell centre.
const left = cx - (cols * pitch) / 2 + pitch / 2
/** @type {{x:number,y:number}[]} */
const dots = []
for (let j = 0; j < n; j++) {
const rowIdx = Math.floor(j / cols)
const colIdx = j % cols
dots.push({
x: left + colIdx * pitch,
y: bottom - r - rowIdx * pitch,
})
}
return {
i,
cx,
cy: bottom - barH / 2,
outerR: barH / 2,
// Flag read by _drawClusterLabel: a bar takes a straight label (above
// or below per clusterLabels.position), never a curved arc.
flat: true,
dots,
}
})
}
/**
* Lay out ALL categories into ONE regular lattice - a waffle / grid. Dots take
* sequential slots in DECLARED category order and fill row-major, `columns`
* wide, so each category owns a contiguous band of cells: a part-to-whole
* square "pie". `grid.total` (optional) re-allocates the cells to a fixed
* budget (e.g. 100) by largest remainder, so the grid reads as exact
* percentages regardless of the raw totals; without it there is one cell per
* unit (respecting unitValue / maxUnits). `grid.fillFrom` picks the first row.
* The category bands follow the legend order (no smallest-first sort), and
* each physical slot is keyed so a proportion change recolours boundary cells
* in place rather than reshuffling the whole grid.
* @param {number[]} counts
* @param {any} opts
*/
_layoutGrid(counts, opts) {
// Small-multiple / trellis mode: one mini-waffle per category instead of a
// single shared lattice. Handled separately (own track backdrop + per-tile
// labels + physical per-tile keying).
if (opts.grid && opts.grid.split) return this._layoutGridSplit(counts, opts)
// Single grid: no track backdrop.
this._gridTrack = null
const w = this.w
const gw = w.layout.gridWidth
const gh = w.layout.gridHeight
const gcfg = opts.grid || {}
const cols = Math.max(1, Math.round(gcfg.columns > 0 ? gcfg.columns : 10))
const fillFrom = gcfg.fillFrom === 'top' ? 'top' : 'bottom'
// Fixed-budget (percentage) mode vs one-cell-per-unit.
const cells =
gcfg.total > 0
? this._largestRemainder(counts, Math.round(gcfg.total))
: counts.slice()
const totalCells = cells.reduce((a, b) => a + b, 0)
const rows = Math.max(1, Math.ceil(Math.max(1, totalCells) / cols))
// No per-category cluster labels on a single grid (the legend carries the
// categories), so only a small top margin is reserved.
const labelSpace = 6
const spacing = opts.spacing > 0 ? opts.spacing : 1
const availW = Math.max(4, gw)
const availH = Math.max(4, gh - labelSpace)
// A fixed dot size (image / explicit size / bubble max) sets the pitch;
// otherwise fit the cols x rows lattice into the plot.
const fixed = this._fixedRadius(opts)
let pitch = 0
if (fixed) {
pitch = 2 * fixed * spacing
this._lastDotR = fixed
} else {
pitch = Math.min(availW / cols, availH / rows)
this._lastDotR = Math.max(1, pitch / (2 * spacing))
}
const blockW = cols * pitch
const blockH = rows * pitch
// Centre the lattice block in the plot.
const originX = (gw - blockW) / 2 + pitch / 2
const topY = labelSpace + (availH - blockH) / 2
// Bottom-fill: row 0 sits at the BOTTOM of the block; top-fill: at the top.
const rowY = (/** @type {number} */ rowIdx) =>
fillFrom === 'bottom'
? topY + blockH - pitch / 2 - rowIdx * pitch
: topY + pitch / 2 + rowIdx * pitch
const clusters = counts.map((_, i) => ({
i,
cx: gw / 2,
cy: labelSpace + availH / 2,
outerR: Math.max(blockW, blockH) / 2,
/** @type {{x:number,y:number,slot?:number}[]} */ dots: [],
}))
let k = 0
for (let ci = 0; ci < cells.length; ci++) {
for (let j = 0; j < cells[ci]; j++) {
const col = k % cols
const rowIdx = Math.floor(k / cols)
clusters[ci].dots.push({
x: originX + col * pitch,
y: rowY(rowIdx),
slot: k,
})
k++
}
}
return clusters
}
/**
* Small-multiple ("trellis") waffles: ONE mini-waffle per category, laid out
* in a near-square grid of tiles. Each tile has `grid.total` cells (default
* 100 -> a 10x10 tile) and fills a fraction of them equal to the category's
* value over a denominator (`grid.max`, else the largest count so the leader
* fills its tile and every other tile stays proportionally full - no empty
* tiles for arbitrary data). The unfilled cells are drawn as a faint TRACK
* backdrop (see _drawGridTrack) so each tile reads as a part-to-whole "of N".
* Only VISIBLE (non-zero) categories claim a tile, so a legend hide drops the
* tile and the rest re-flow. Each filled cell is keyed by a physical
* `tile*cells + localCell` slot, so a value change grows/shrinks a tile's fill
* in place instead of reshuffling.
* @param {number[]} counts @param {any} opts
*/
_layoutGridSplit(counts, opts) {
const w = this.w
const gw = w.layout.gridWidth
const gh = w.layout.gridHeight
const gcfg = opts.grid || {}
const cols = Math.max(1, Math.round(gcfg.columns > 0 ? gcfg.columns : 10))
const fillFrom = gcfg.fillFrom === 'top' ? 'top' : 'bottom'
const cellsPerTile = Math.max(1, Math.round(gcfg.total > 0 ? gcfg.total : 100))
const rowsPerTile = Math.max(1, Math.ceil(cellsPerTile / cols))
// Visible (non-empty) categories each get a tile; a hidden/empty category
// drops out and the remaining tiles re-flow (as grouped/columns do).
const visible = counts.map((_, i) => i).filter((i) => counts[i] > 0)
const K = Math.max(1, visible.length)
// Value -> filled-cell denominator. Default = the largest count (leader
// fills its tile, the rest proportional); `grid.max` pins an explicit whole
// (e.g. 100 so a value of 35 fills 35 of 100 cells - a percentage waffle).
const denom = gcfg.max > 0 ? gcfg.max : Math.max(1, ...counts)
// Arrange the tiles in a near-square grid unless grid.tileColumns pins it.
const tileCols = Math.max(
1,
Math.round(gcfg.tileColumns > 0 ? gcfg.tileColumns : Math.ceil(Math.sqrt(K))),
)
const tileRows = Math.max(1, Math.ceil(K / tileCols))
// Reserve a per-tile label band on the side the label sits (top by default).
const labelsOn = !(opts.clusterLabels && opts.clusterLabels.show === false)
const labelsBelow =
labelsOn && opts.clusterLabels && opts.clusterLabels.position === 'bottom'
const topBand = labelsOn && !labelsBelow ? 22 : 4
const botBand = labelsOn && labelsBelow ? 22 : 4
const tileW = gw / tileCols
const tileH = gh / tileRows
const availTileW = Math.max(4, tileW * 0.86) // gutter between neighbouring tiles
const availTileH = Math.max(4, tileH - topBand - botBand)
const spacing = opts.spacing > 0 ? opts.spacing : 1
// Dot pitch: a fixed size sets it; otherwise fit cols x rowsPerTile per tile.
const fixed = this._fixedRadius(opts)
let pitch = 0
if (fixed) {
pitch = 2 * fixed * spacing
this._lastDotR = fixed
} else {
pitch = Math.min(availTileW / cols, availTileH / rowsPerTile)
this._lastDotR = Math.max(1, pitch / (2 * spacing))
}
const blockW = cols * pitch
const blockH = rowsPerTile * pitch
const rowY = (/** @type {number} */ topY, /** @type {number} */ rowIdx) =>
fillFrom === 'bottom'
? topY + blockH - pitch / 2 - rowIdx * pitch
: topY + pitch / 2 + rowIdx * pitch
/** @type {{x:number,y:number}[]} */
const track = []
/** @type {{ i:number, cx:number, cy:number, outerR:number, flat:boolean, split:boolean, dots:{x:number,y:number,slot?:number}[] }[]} */
const clusters = []
visible.forEach((ci, t) => {
const tc = t % tileCols
const tr = Math.floor(t / tileCols)
const tileX = tc * tileW
const tileYtop = tr * tileH
const originX = tileX + (tileW - blockW) / 2 + pitch / 2
const topY = tileYtop + topBand + (availTileH - blockH) / 2
const cellXY = (/** @type {number} */ k) => ({
x: originX + (k % cols) * pitch,
y: rowY(topY, Math.floor(k / cols)),
})
// Full lattice -> track backdrop.
for (let k = 0; k < cellsPerTile; k++) track.push(cellXY(k))
// Filled cells -> series dots (keyed by physical per-tile slot).
const filled = Math.max(
0,
Math.min(cellsPerTile, Math.round((counts[ci] / denom) * cellsPerTile)),
)
/** @type {{x:number,y:number,slot?:number}[]} */
const dots = []
for (let k = 0; k < filled; k++) {
const p = cellXY(k)
dots.push({ x: p.x, y: p.y, slot: t * cellsPerTile + k })
}
clusters.push({
i: ci,
cx: tileX + tileW / 2,
cy: topY + blockH / 2,
outerR: blockH / 2,
// Straight per-tile label (never a curved arc), placed by position.
flat: true,
split: true,
dots,
})
})
// Denominator drives the per-tile label percentage (value / denom).
this._gridDenom = denom
this._gridTrack = { cells: track }
return clusters
}
/**
* Draw the faint "track" backdrop for the small-multiple grid: every cell of
* every tile's full lattice, so the filled (coloured) cells drawn on top read
* as a fraction of the whole. Static (redrawn each render, never animated);
* painted BEHIND the series groups. `grid.trackColor` overrides the default
* theme-neutral grey.
* @param {any} ret @param {Graphics} graphics @param {any} opts
*/
_drawGridTrack(ret, graphics, opts) {
const track = this._gridTrack
if (!track || !track.cells || !track.cells.length) return
const r = this._lastDotR
const gcfg = opts.grid || {}
const trackColor = gcfg.trackColor || 'rgba(128,128,128,0.14)'
const g = graphics.group({ class: 'apexcharts-unit-track' })
track.cells.forEach((c) => {
let el
if (opts.shape === 'square') {
const side = r * 2
el = graphics.drawRect(0, 0, side, side, opts.borderRadius || 0, trackColor, 1, 0, 'none')
el.node.setAttribute('fill', trackColor)
el.node.setAttribute('x', String(c.x - r))
el.node.setAttribute('y', String(c.y - r))
} else {
el = graphics.drawCircle(r, { fill: trackColor, 'stroke-width': 0, stroke: 'none' })
el.node.setAttribute('fill', trackColor)
el.node.setAttribute('cx', String(c.x))
el.node.setAttribute('cy', String(c.y))
}
el.node.classList.add('apexcharts-unit-track-cell')
g.add(el)
})
ret.add(g)
}
/**
* Scatter / beeswarm layout: position every unit on a real numeric X value
* axis by its own value (`_unitValueOf`), laned by category on Y. Within a
* lane an anti-overlap "swarm" pack (or a random jitter) spreads the dots off
* the centre line so equal / close values do not stack on top of each other.
* This is the unit chart's answer to "put these on axes": one dot per datum,
* placed by data, with a drawn value axis + category lanes (see
* _drawScatterAxes). Needs the per-unit object form (each datum a numeric
* `value`/`y`); flat counts have no per-unit value, so their lanes stay empty.
* @param {any} opts
*/
_layoutScatter(opts) {
const w = this.w
const scfg = opts.scatter || {}
// 2D value-value scatter: each datum's own x AND y on two numeric axes.
if (scfg.y === 'value') return this._layoutScatter2D(opts)
const gw = w.layout.gridWidth
const gh = w.layout.gridHeight
const unitData = w.seriesData.unitData || []
const names = w.seriesData.seriesNames || []
const valueOf = (/** @type {any} */ d) => this._unitValueOf(d)
// Optional bubble sizing (by a separate `sizeField`): a bubble beeswarm.
const sizeStats = this._scatterSizeStats(scfg, unitData)
// Per-category numeric values; a category is VISIBLE only if it has at least
// one usable value (so a legend-hidden / empty lane drops and the rest
// re-flow, as the other layouts do).
const catVals = unitData.map((cat) =>
Array.isArray(cat) ? cat.map(valueOf) : [],
)
const isNum = (/** @type {any} */ v) => v != null && isFinite(v)
const visible = catVals
.map((_, i) => i)
.filter((i) => catVals[i].some(isNum))
const Kv = Math.max(1, visible.length)
// Global x range across all units, nice-numbered (unless overridden).
let vmin = Infinity
let vmax = -Infinity
catVals.forEach((vs) =>
vs.forEach((v) => {
if (v != null && isFinite(v)) {
if (v < vmin) vmin = v
if (v > vmax) vmax = v
}
}),
)
if (vmin === Infinity) {
vmin = 0
vmax = 1
}
const tickAmount = Math.max(2, Math.round(scfg.tickAmount > 0 ? scfg.tickAmount : 5))
const nice = this._niceScale(
scfg.xMin != null ? scfg.xMin : vmin,
scfg.xMax != null ? scfg.xMax : vmax,
tickAmount,
)
const xMin = scfg.xMin != null ? scfg.xMin : nice.min
const xMax = scfg.xMax != null ? scfg.xMax : nice.max
const xSpan = xMax - xMin || 1
// Plot box: left gutter for lane labels, bottom gutter for the axis.
const laneW =
scfg.laneLabelWidth != null ? Math.max(0, scfg.laneLabelWidth) : Kv > 1 ? 92 : 8
const bottomGutter = 30 + (scfg.xTitle ? 20 : 0)
const plotL = laneW
const plotR = gw - 8
const plotT = 10
const plotB = gh - bottomGutter
const plotW = Math.max(4, plotR - plotL)
const plotH = Math.max(4, plotB - plotT)
const plotX = (/** @type {number} */ v) =>
plotL + ((v - xMin) / xSpan) * plotW
const laneH = plotH / Kv
const laneCy = (/** @type {number} */ slot) => plotT + laneH * (slot + 0.5)
// Dot radius: fixed if set, else a SMALL auto radius (a swarm reads best
// with small dots) bounded by lane height + peak lane density, capped ~6px.
let r = 0
const fixed = this._fixedRadius(opts)
if (fixed) {
r = fixed
} else {
const maxLane = Math.max(
1,
...visible.map((i) => catVals[i].filter(isNum).length),
)
r = Math.max(
2,
Math.min(6, laneH * 0.12, plotW / (2.5 * Math.sqrt(maxLane))),
)
}
this._lastDotR = r
const spacing = opts.spacing > 0 ? opts.spacing : 1
const step = Math.max(0.5, r * spacing) // vertical pack step (~half a dot)
const jitter = scfg.spread === 'jitter'
/** @type {{ i:number, cx:number, cy:number, outerR:number, dots:{x:number,y:number,r?:number}[] }[]} */
const clusters = []
/** @type {{ i:number, cy:number, name:string }[]} */
const lanes = []
// Bubble beeswarm: the largest possible dot radius bounds the pack's
// x-window break (a pair collides within r_i + r_j <= 2*maxR).
const maxR = sizeStats ? sizeStats.rMax : r
visible.forEach((ci, slot) => {
const cy = laneCy(slot)
lanes.push({ i: ci, cy, name: names[ci] || `series-${ci + 1}` })
const cat = unitData[ci] || []
// Points keep their original datum index j so draw() can still resolve
// unitData[i][j] for per-unit colour + tooltip. A non-numeric datum is
// pinned to the axis start (defensive; scatter expects numeric values).
// With bubble sizing each point carries its own radius `r` (used by the
// pack + read back by draw()); otherwise the shared radius applies.
const pts = cat.map((d, j) => {
const v = valueOf(d)
/** @type {{j:number,px:number,y:number,r?:number}} */
const p = { j, px: plotX(isNum(v) ? v : xMin), y: cy }
if (sizeStats) p.r = this._scatterRadius(d, sizeStats, r)
return p
})
if (jitter) {
// Deterministic pseudo-jitter by index (no Math.random - keeps SSR +
// re-render stable): spread within the lane band.
const halfLane = Math.max(maxR, laneH / 2 - maxR)
pts.forEach((p, k) => {
const t = ((k * 9301 + 49297) % 233280) / 233280 // LCG in [0,1)
p.y = cy + (t * 2 - 1) * halfLane
})
} else {
this._beeswarm(pts, cy, r, step, maxR)
}
// draw() indexes cluster.dots[j] against unitData[i][j], so keep j order.
clusters.push({
i: ci,
cx: (plotL + plotR) / 2,
cy,
outerR: laneH / 2,
dots: pts.map((p) => ({ x: p.px, y: p.y, r: p.r })),
})
})
// Tick values for the axis chrome.
/** @type {number[]} */
const ticks = []
const spacingT = nice.spacing || xSpan / Math.max(1, tickAmount - 1)
if (scfg.xMin != null || scfg.xMax != null) {
for (let k = 0; k < tickAmount; k++) {
ticks.push(xMin + (xSpan * k) / (tickAmount - 1))
}
} else {
for (let v = xMin; v <= xMax + spacingT * 0.5; v += spacingT) {
ticks.push(Math.abs(v) < spacingT * 1e-9 ? 0 : v)
}
}
this._scatterAxis = {
mode: '1d',
plotL,
plotR,
plotT,
plotB,
xMin,
xMax,
plotX,
ticks,
lanes,
xTitle: scfg.xTitle,
formatter: typeof scfg.xFormatter === 'function' ? scfg.xFormatter : null,
gridlines: scfg.gridlines !== false,
}
return clusters
}
/**
* 2D value-value scatter: each datum is a point at (`x`, `y`) on two numeric
* axes (a scatter / bubble plot in the unit family - premium, keyed
* transitions, per-unit colour/tooltip). Category = colour (one series group
* per category). With `scatter.sizeRange` set, each dot is a BUBBLE scaled (by
* area) from its `sizeField` (default 'z'). Needs the object form with numeric
* `x` + `y`.
* @param {any} opts
*/
_layoutScatter2D(opts) {
const w = this.w
const gw = w.layout.gridWidth
const gh = w.layout.gridHeight
const scfg = opts.scatter || {}
const unitData = w.seriesData.unitData || []
const isNum = (/** @type {any} */ v) => typeof v === 'number' && isFinite(v)
const xOf = (/** @type {any} */ d) =>
d && typeof d === 'object' ? d.x : null
const yOf = (/** @type {any} */ d) =>
d && typeof d === 'object' ? (d.y != null ? d.y : d.value) : null
// A category is visible if it has at least one point with numeric x AND y.
const visible = unitData
.map((_, i) => i)
.filter((i) =>
(unitData[i] || []).some((d) => isNum(xOf(d)) && isNum(yOf(d))),
)
// Global x + y ranges (nice-numbered unless pinned).
let xmn = Infinity
let xmx = -Infinity
let ymn = Infinity
let ymx = -Infinity
unitData.forEach((cat) =>
(cat || []).forEach((d) => {
const x = xOf(d)
const y = yOf(d)
if (isNum(x) && isNum(y)) {
if (x < xmn) xmn = x
if (x > xmx) xmx = x
if (y < ymn) ymn = y
if (y > ymx) ymx = y
}
}),
)
if (xmn === Infinity) {
xmn = 0
xmx = 1
ymn = 0
ymx = 1
}
const xTicksN = Math.max(2, Math.round(scfg.tickAmount > 0 ? scfg.tickAmount : 5))
const yTicksN = Math.max(2, Math.round(scfg.yTickAmount > 0 ? scfg.yTickAmount : 5))
const nx = this._niceScale(
scfg.xMin != null ? scfg.xMin : xmn,
scfg.xMax != null ? scfg.xMax : xmx,
xTicksN,
)
const ny = this._niceScale(
scfg.yMin != null ? scfg.yMin : ymn,
scfg.yMax != null ? scfg.yMax : ymx,
yTicksN,
)
const xMin = scfg.xMin != null ? scfg.xMin : nx.min
const xMax = scfg.xMax != null ? scfg.xMax : nx.max
const yMin = scfg.yMin != null ? scfg.yMin : ny.min
const yMax = scfg.yMax != null ? scfg.yMax : ny.max
const xSpan = xMax - xMin || 1
const ySpan = yMax - yMin || 1
// Plot box: left gutter for the Y axis (ticks + rotated title), bottom for X.
const leftGutter = 46 + (scfg.yTitle ? 18 : 0)
const bottomGutter = 30 + (scfg.xTitle ? 20 : 0)
const plotL = leftGutter
const plotR = gw - 12
const plotT = 10
const plotB = gh - bottomGutter
const plotW = Math.max(4, plotR - plotL)
const plotH = Math.max(4, plotB - plotT)
const plotX = (/** @type {number} */ v) => plotL + ((v - xMin) / xSpan) * plotW
// Y grows upward: larger value -> smaller pixel.
const plotY = (/** @type {number} */ v) => plotB - ((v - yMin) / ySpan) * plotH
const sizeStats = this._scatterSizeStats(scfg, unitData)
const baseR = this._fixedRadius(opts) || 5
this._lastDotR = baseR
/** @type {{ i:number, cx:number, cy:number, outerR:number, dots:{x:number,y:number,r?:number}[] }[]} */
const clusters = []
visible.forEach((ci) => {
const cat = unitData[ci] || []
const dots = cat.map((d) => {
const x = xOf(d)
const y = yOf(d)
return {
x: plotX(isNum(x) ? x : xMin),
y: plotY(isNum(y) ? y : yMin),
r: sizeStats ? this._scatterRadius(d, sizeStats, baseR) : undefined,
}
})
clusters.push({
i: ci,
cx: (plotL + plotR) / 2,
cy: (plotT + plotB) / 2,
outerR: plotH / 2,
dots,
})
})
const mkTicks = (
/** @type {number} */ lo,
/** @type {number} */ hi,
/** @type {number} */ span,
/** @type {number} */ spacing,
/** @type {boolean} */ pinned,
/** @type {number} */ n,
) => {
/** @type {number[]} */
const out = []
if (pinned) {
for (let k = 0; k < n; k++) out.push(lo + (span * k) / (n - 1))
} else {
const sp = spacing || span / Math.max(1, n - 1)
for (let v = lo; v <= hi + sp * 0.5; v += sp) {
out.push(Math.abs(v) < sp * 1e-9 ? 0 : v)
}
}
return out
}
this._scatterAxis = {
mode: '2d',
plotL,
plotR,
plotT,
plotB,
plotX,
plotY,
xTicks: mkTicks(
xMin,
xMax,
xSpan,
nx.spacing,
scfg.xMin != null || scfg.xMax != null,
xTicksN,
),
yTicks: mkTicks(
yMin,
yMax,
ySpan,
ny.spacing,
scfg.yMin != null || scfg.yMax != null,
yTicksN,
),
xTitle: scfg.xTitle,
yTitle: scfg.yTitle,
xFormatter: typeof scfg.xFormatter === 'function' ? scfg.xFormatter : null,
yFormatter: typeof scfg.yFormatter === 'function' ? scfg.yFormatter : null,
gridlines: scfg.gridlines !== false,
}
return clusters
}
/**
* Bubble size stats for the scatter layout, or null when `scatter.sizeRange`
* is not a `[minR, maxR]` pair. Reads the global range of each datum's
* `sizeField` (default 'z') so a value maps to a radius (area scale) in
* _scatterRadius.
* @param {any} scfg @param {any[][]} unitData
* @returns {{zmin:number,zmax:number,rMin:number,rMax:number,field:string}|null}
*/
_scatterSizeStats(scfg, unitData) {
const range = scfg && scfg.sizeRange
if (!Array.isArray(range) || range.length < 2) return null
const rMin = Math.max(0.5, +range[0])
const rMax = Math.max(rMin, +range[1])
const field = scfg.sizeField || 'z'
let zmin = Infinity
let zmax = -Infinity
unitData.forEach((cat) =>
(cat || []).forEach((d) => {
const z = d && typeof d === 'object' ? d[field] : null
if (typeof z === 'number' && isFinite(z)) {
if (z < zmin) zmin = z
if (z > zmax) zmax = z
}
}),
)
if (zmin === Infinity) return null
return { zmin, zmax, rMin, rMax, field }
}
/**
* Radius for one datum under the bubble size stats: area proportional to the
* `sizeField` value (so radius grows with sqrt), between rMin and rMax. A
* missing value collapses to rMin.
* @param {any} d
* @param {{zmin:number,zmax:number,rMin:number,rMax:number,field:string}} st
* @param {number} fallback @returns {number}
*/
_scatterRadius(d, st, fallback) {
if (!st) return fallback
const z = d && typeof d === 'object' ? d[st.field] : null
if (typeof z !== 'number' || !isFinite(z)) return st.rMin
const t = st.zmax > st.zmin ? (z - st.zmin) / (st.zmax - st.zmin) : 1
const tc = Math.max(0, Math.min(1, t))
const aMin = st.rMin * st.rMin
const aMax = st.rMax * st.rMax
return Math.sqrt(aMin + tc * (aMax - aMin))
}
/**
* One-dimensional anti-overlap "beeswarm" pack: given points with a fixed x
* (`px`) and a lane centre `cy`, assign each a y so no two dots overlap (centre
* distance >= r_i + r_j). Greedy in ascending-x order, trying offsets 0, +step,
* -step, +2step ... and taking the SMALLEST that clears every already-placed
* neighbour still within reach in x. No-overlap always wins: a very dense lane
* grows a taller swarm rather than stacking dots (offsets are not hard-clamped
* to the lane). Each point may carry its own radius `r` (bubble beeswarm),
* else `rFallback` applies; `maxR` bounds the x-window break. Mutates each
* point's `.y`. Deterministic (no physics, no randomness).
* @param {{px:number,y:number,r?:number}[]} pts @param {number} cy
* @param {number} rFallback @param {number} step @param {number} [maxR]
*/
_b