apexcharts
Version:
A JavaScript Chart Library
3,528 lines • 141 kB
JavaScript
// @ts-check
import { makeSpring, resolveSpring, retarget, stepSpring } from 'apex-commons'
import Graphics from '../modules/Graphics'
import { getUnitLayout } from '../modules/UnitLayoutRegistry'
import { getUnitMark, normalizeUnitMark } from '../modules/UnitMarkRegistry'
import Utils from '../utils/Utils'
import { Environment } from '../utils/Environment'
import { BrowserAPIs } from '../ssr/BrowserAPIs'
import { prefersReducedMotion } from '../modules/Animations'
import {
drawOuterLabel,
measureLabelWidth,
spaceOutLabels,
} from './common/OuterLabels'
/**
* 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)
}
/**
* Back-out ease factory: overshoots the target then springs back, so a dot
* visibly "settles" into its slot instead of just decelerating to a stop.
* `s` is the overshoot strength (1.70158 = the classic back ease ~10%).
* @param {number} s @returns {(t: number) => number}
*/
function easeOutBack(s) {
return (t) => 1 + (s + 1) * Math.pow(t - 1, 3) + s * Math.pow(t - 1, 2)
}
/**
* In-out ease: dots accelerate gently out of rest and decelerate into their
* slot, so a regroup reads as weighted motion rather than an instant launch.
* @param {number} t
*/
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
}
/**
* `chart.animations.speed` (ms) that the shared spring presets are tuned
* against - which is also the ApexCharts default, so an untouched config gets
* the preset verbatim.
*/
const SPRING_REFERENCE_SPEED = 800
/**
* Longest simulated step (s) fed to the springs in one frame. A backgrounded
* tab hands back a multi-second delta; the solver sub-steps internally so that
* is stable, but it would burn hundreds of sub-steps per dot to replay time the
* user never saw. Clamping lands them near their targets in one frame instead.
*/
const MAX_FRAME_STEP = 0.25
/**
* How a mark is positioned. Read once per dot per frame, so it is an int rather
* than a string compare. See `_place` / `Unit#_baseSpec`.
* PK_CIRCLE cx / cy at the centre
* PK_CORNER x / y at the top-left (square, image)
* PK_GLYPH transform="translate(...) scale(s)" (pictogram)
*/
const PK_CIRCLE = 0
const PK_CORNER = 1
const PK_GLYPH = 2
/**
* Resolve a spring preset to a `[stiffness, damping]` pair rescaled for the
* chart's animation speed.
*
* Stiffness scales with the square of the speed ratio and damping linearly,
* which is the substitution that stretches a spring in TIME while leaving its
* damping ratio (`c / 2*sqrt(k)`) untouched. A slower `speed` is therefore the
* same motion played slower, not a bouncier one - which is what makes `speed`
* keep meaning what it means for every other tween in the chart.
*
* @param {string|undefined} preset 'crisp' (default) | 'gentle' | 'snappy'
* @param {number} speed chart.animations.speed, in ms
* @returns {[number, number]}
*/
function springParams(preset, speed) {
const [stiffness, damping] = resolveSpring(
/** @type {import('apex-commons').SpringPreset|undefined} */ (preset),
)
const scale = SPRING_REFERENCE_SPEED / Math.max(1, speed)
return [stiffness * scale * scale, damping * scale]
}
/**
* One dot in flight during a gather.
*
* @typedef {object} UnitAnimDot
* @property {SVGElement} node
* @property {number} x target x (dot centre)
* @property {number} y target y (dot centre)
* @property {number} cx0 start x
* @property {number} cy0 start y
* @property {number} delay stagger delay (ms) before this dot starts moving
* @property {boolean} isEnter true when the dot has no previous slot
* @property {any} spec this mark's placement/draw spec (see Unit#_place). The
* frame loop switches on `spec.pk` rather than on a chart-wide shape, which
* is what lets one population mix circles and pictograms.
* @property {string} [key] identity key, used to carry live spring state
* across a gather that was interrupted by a re-render
* @property {number} [r0]
* @property {number} [r1]
* @property {string} [fill0]
* @property {string} [fill1]
* @property {number[]|null} [_c0]
* @property {number[]|null} [_c1]
* @property {import('apex-commons').Spring} [sx]
* @property {import('apex-commons').Spring} [sy]
* @property {boolean} [released] whether the springs have been retargeted yet
*/
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
/** @type {Map<string, any>} glyph draw specs, keyed (mark, quantised r) */
this._specCache = new Map()
/** @type {Set<string>|null} mark names already warned about */
this._markWarned = 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'
: opts.layout === 'arc'
? 'arc'
: opts.layout === 'custom'
? 'custom'
: '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,j?: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)
: layout === 'arc'
? this._layoutArc(counts, opts)
: layout === 'custom'
? this._layoutCustom(counts, 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 come out of the
// shape they replace instead of gathering from the plot centre. Each dot
// leaves from the PART of that shape which stood for it (getInitialSlotFor),
// so a bar comes apart along its own length rather than spraying from one
// point.
const morph = this.ctx && this.ctx.morphTypeChange
const morphActive =
animate &&
!!morph &&
typeof morph.isActive === 'function' &&
morph.isActive() &&
typeof morph.getInitialCenterFor === 'function'
const perRowBurst =
morphActive && typeof morph.getInitialSlotFor === 'function'
// Piece takeover: the morph engine will cut the outgoing marks into one
// cell per dot and fly the cells here itself, so the dots render at their
// final slots, hidden, and are revealed one by one as their piece lands.
// The engine sweeps the hidden attribute on cleanup, so a dot can never
// stay invisible past the transition.
const pieceTakeover =
morphActive &&
typeof morph.usesPieceTakeover === 'function' &&
morph.usesPieceTakeover()
// 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,spec?:any}>} */
const nextPrev = new Map()
/** @type {UnitAnimDot[]} */
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. Falls
// back to the captured shape's centre (every dot stacked at one point)
// only if the morph feature predates per-row slots.
const burst =
morphActive && !perRowBurst ? morph.getInitialCenterFor(cluster.i) : null
const burstCount = cluster.dots.length
const catData = unitData[cluster.i]
cluster.dots.forEach((d, jj) => {
// The mark's own dataPointIndex where the layout carried one (a custom
// provider may omit marks, which would otherwise shift every later
// datum by one), else its position in the cluster.
const j = d.j != null ? d.j : jj
// 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
// Which mark THIS unit draws, and how it is positioned. Sized from `rj`
// rather than the chart-wide radius, so a layout that hands back
// per-mark radii places every mark on its own slot.
const spec = this._markSpecFor(opts, datum, cluster.i, rj)
const el = this._drawDot(graphics, opts, rj, dotFill, cluster.i, j, spec)
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++
// The spec rides along so an exit ghost leaves as the mark it was.
nextPrev.set(key, { x: d.x, y: d.y, fill: dotFill, r: rj, spec })
if (pieceTakeover) {
// The piece layer does the flying; this dot waits at its final slot
// for its piece to land, and gets no tween of its own so the swap
// is geometrically exact.
this._place(el.node, spec, d.x, d.y)
el.node.setAttribute('opacity', '0')
el.node.setAttribute('data-piece-hidden', '1')
} else if (animate) {
const from = prev && prev.get(key)
// Priority: previous slot (keyed update) -> the part of the outgoing
// mark that stood for THIS row (morph) -> cluster centre (fresh
// mount). The first two keep dots opaque; a fresh mount fades them in.
const anchor =
from ||
(perRowBurst
? morph.getInitialSlotFor(cluster.i, j, burstCount)
: burst)
// A fresh mount fades in. Where an entering dot comes FROM is
// `gather.enter`: 'burst' (default) flies out from the cluster
// centre; 'fade' materialises in place; 'rise' fades in while
// drifting gently up into its slot. A small-multiple cell always
// fades IN PLACE (a waffle fills cell-by-cell, it does not fly
// from a centre).
const enter = (opts.gather && opts.gather.enter) || 'burst'
const inPlace = gridSplit || enter === 'fade' || enter === 'rise'
const cx0 = anchor ? anchor.x : inPlace ? d.x : cluster.cx
const cy0 = anchor
? anchor.y
: enter === 'rise' && !gridSplit
? d.y + 14
: inPlace
? d.y
: cluster.cy
el.node.style.opacity = anchor ? '1' : '0'
this._place(el.node, spec, cx0, cy0)
animDots.push({
node: el.node,
spec,
x: d.x,
y: d.y,
cx0,
cy0,
// Carries live spring state (position AND velocity) forward when
// this render interrupted one still in flight.
key,
// 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._place(el.node, spec, 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)
})
// Outer (name) labels: parked in the margins reserved by _layoutCustom and
// joined to their colour band by a leader line. Added after every series so
// they sit above the dots, and planned in one pass so crowded labels can be
// spaced apart before they reach the DOM.
// `!prev`: on an update the dots tween from where they already are, so there
// is nothing to wait for and the labels appear at once.
if (this._outerLabelsOn(opts)) {
this._drawOuterLabels(ret, clusters, counts, total, opts, animate && !prev)
}
// 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
// A render that placed its dots outright (resize, animations off, reduced
// motion) makes any live spring state stale: the springs describe where a
// dot WAS travelling on a layout that no longer exists, so carrying them
// into the next animated render would launch it from a phantom position.
// Likewise a cross-type morph, where the dots come from the outgoing
// chart's shape rather than from a previous unit render.
if (this.ctx && (!animate || morphActive)) this.ctx._unitSprings = null
if (animate && animDots.length) {
this._runGather(animDots)
} else {
// Nothing is in flight, so the chart is already at rest. Every other
// chart type raises this flag (Pie, Scatter, Graphics, Animations); the
// unit chart never did, which left it invisible to the two readiness
// signals the tooling waits on - the e2e runner and the Playwright
// fixture both poll `animationEnded`, and both were falling through to a
// timeout on every unit sample rather than to a real signal.
w.globals.animationEnded = true
}
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))
}
/**
* `layout: 'custom'`. Positions come from a caller-supplied provider rather
* than from a generator in this file.
*
* The provider is the whole extension point: `(objects, rect) => [{id, x, y,
* r?}]`. Everything downstream is unchanged, which is the point - the engine
* already tweens position, radius and colour, and already keeps a mark's
* identity across a relayout, so an arbitrary new arrangement needs no new
* transition code. A silhouette, a hex grid, a timeline, or a projection
* handed over by ApexMaps are all just this function.
*
* Marks the provider omits are dropped, so they animate out through the
* existing exit path. Ids matching no mark are ignored.
*
* @param {number[]} counts
* @param {any} opts
*/
_layoutCustom(counts, opts) {
const w = this.w
const gw = w.layout.gridWidth
const gh = w.layout.gridHeight
const total = counts.reduce((a, b) => a + b, 0)
const provider = this._resolveLayoutProvider(opts)
if (!provider) {
console.warn(
`[ApexCharts] unit chart: layout 'custom' needs plotOptions.unit.positions ` +
`(a function, or the name of a layout registered with ` +
`ApexCharts.registerUnitLayout). Falling back to 'grouped'.`,
)
return this._layoutGrouped(counts, opts)
}
// Outer labels live in a margin the shape has to give up, on BOTH sides so
// the silhouette stays centred (which is what it is for). Reserved before
// the dot size is chosen, so the shape is sized for the room it will
// actually get rather than being scaled down afterwards.
const gutter = this._outerLabelsOn(opts)
? this._outerLabelGutter(counts, opts)
: 0
this._lastOuterGutter = gutter
const rect = {
x: gutter,
y: 0,
width: Math.max(1, gw - gutter * 2),
height: gh,
}
// Auto dot size: treat the plot rect as a blob of the same area, so `total`
// marks fill it at the configured spacing. A fixed size (image, explicit
// size, bubble maxRadius) short-circuits inside _resolveStep exactly as it
// does for every other layout.
const availR = Math.sqrt((rect.width * rect.height) / Math.PI)
const step = this._resolveStep(opts, availR, total)
this._lastDotR = this._dotRadiusFromStep(step, opts)
const dotR = this._lastDotR
const objects = this._layoutObjects(counts, dotR)
let placed
try {
placed = provider(objects, rect)
} catch (e) {
console.warn(
'[ApexCharts] unit chart: the layout provider threw; falling back to ' +
"'grouped'.",
e,
)
return this._layoutGrouped(counts, opts)
}
if (!Array.isArray(placed)) {
console.warn(
'[ApexCharts] unit chart: the layout provider must return an array of ' +
"{id, x, y}; falling back to 'grouped'.",
)
return this._layoutGrouped(counts, opts)
}
/** @type {Map<string, {x:number,y:number,r?:number}>} */
const byId = new Map()
placed.forEach((p) => {
// Silently skipping a non-finite position would leave the mark at a stale
// spot with no clue why, so treat it as "omitted" and let it exit.
if (!p || !isFinite(p.x) || !isFinite(p.y)) return
byId.set(String(p.id), {
x: p.x,
y: p.y,
r: typeof p.r === 'number' && p.r > 0 ? p.r : undefined,
})
})
/** @type {{ i: number, cx: number, cy: number, outerR: number, dots: {x:number,y:number,r?:number,j?:number}[] }[]} */
const clusters = counts.map((_, i) => ({
i,
cx: gw / 2,
cy: gh / 2,
outerR: dotR,
dots: [],
}))
objects.forEach((o) => {
const hit = byId.get(o.id)
if (!hit) return
// `j` is the mark's own dataPointIndex, NOT its position in this array.
// A provider is allowed to omit marks, and the draw pass would otherwise
// fall back to the loop index - so omitting one day of a calendar made
// every later dot report the PREVIOUS day's datum in its tooltip.
clusters[o.seriesIndex].dots.push({
x: hit.x,
y: hit.y,
r: hit.r,
j: o.dataPointIndex,
})
})
// A cluster's centre and radius still drive the burst origin for entering
// marks and a cross-type morph, so derive them from where the marks
// actually landed rather than leaving them at the plot centre.
clusters.forEach((c) => {
if (!c.dots.length) return
let sx = 0
let sy = 0
c.dots.forEach((d) => {
sx += d.x
sy += d.y
})
c.cx = sx / c.dots.length
c.cy = sy / c.dots.length
let far = 0
c.dots.forEach((d) => {
far = Math.max(far, Math.hypot(d.x - c.cx, d.y - c.cy))
})
c.outerR = far + dotR
})
return clusters
}
/**
* One entry per mark, in global draw order, for a layout provider.
*
* `id` is the datum's own id/name where the per-unit object form supplies
* one, so a provider can address a specific unit ("Texas", "employee 41")
* rather than a positional slot. It falls back to `"<category>:<index>"`.
*
* @param {number[]} counts
* @param {number} dotR the radius the engine would use, so a provider that
* packs by size does not have to rediscover it
* @returns {{id:string,index:number,seriesIndex:number,dataPointIndex:number,label:string,value:number|undefined,datum:any,r:number}[]}
*/
_layoutObjects(counts, dotR) {
const w = this.w
const unitData = w.seriesData.unitData || []
const names = w.seriesData.seriesNames || []
/** @type {{id:string,index:number,seriesIndex:number,dataPointIndex:number,label:string,value:number|undefined,datum:any,r:number}[]} */
const objects = []
let index = 0
counts.forEach((n, i) => {
const catData = unitData[i]
for (let j = 0; j < n; j++) {
const datum = catData ? catData[j] : undefined
const id =
datum && typeof datum === 'object' && (datum.id != null || datum.name != null)
? String(datum.id != null ? datum.id : datum.name)
: `${i}:${j}`
objects.push({
id,
index,
seriesIndex: i,
dataPointIndex: j,
label: names[i],
// Normalised at the boundary: internally "no value" is null, but the
// public object shape uses an absent property.
value: this._unitValueOf(datum) ?? undefined,
datum,
r: dotR,
})
index++
}
})
return objects
}
/**
* Resolve `plotOptions.unit.positions` to a provider function: either the
* function itself, or the name of one registered through
* `ApexCharts.registerUnitLayout`.
* @param {any} opts
* @returns {Function|null}
*/
_resolveLayoutProvider(opts) {
const positions = opts.positions
if (typeof positions === 'function') return positions
if (typeof positions === 'string' && positions) {
const found = getUnitLayout(positions)
if (found) return found
console.warn(
`[ApexCharts] unit chart: no layout named "${positions}" is registered. ` +
`Register one with ApexCharts.registerUnitLayout("${positions}", fn).`,
)
}
return null
}
/**
* 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 all marks as a PARLIAMENT / hemicycle: seats in concentric arced
* rows across an annulus, filled in category (party) order so each category
* forms a contiguous angular wedge (the classic seating chart). `arc` controls
* the sweep (`startAngle`/`endAngle`, radialBar convention: 0 = top, clockwise;
* default a top semicircle), the donut hole (`innerRadiusRatio`) and the row
* count (`rows`, or 'auto'). Like `packed` this is ONE shared shape coloured by
* category, so seats key by physical slot: a seat-count change recolours the
* party boundary in place and only the rim adds / removes seats.
* @param {number[]} counts
* @param {any} opts
*/
_layoutArc(counts, opts) {
const w = this.w
const gw = w.layout.gridWidth
const gh = w.layout.gridHeight
const total = Math.max(1, counts.reduce((a, b) => a + b, 0))
const acfg = opts.arc || {}
const startDeg = typeof acfg.startAngle === 'number' ? acfg.startAngle : -90
const endDeg = typeof acfg.endAngle === 'number' ? acfg.endAngle : 90
const a0 = (startDeg * Math.PI) / 180
const a1 = (endDeg * Math.PI) / 180
const span = a1 - a0 || Math.PI
const innerRatio = Math.max(
0,
Math.min(0.95, typeof acfg.innerRadiusRatio === 'number' ? acfg.innerRadiusRatio : 0.4),
)
// radialBar angle convention -> screen coords (y down): 0deg = top, clockwise.
const ux = (/** @type {number} */ a) => Math.sin(a)
const uy = (/** @type {number} */ a) => -Math.cos(a)
// Fit the outer arc's bounding box (at radius 1) into the plot, then centre it.
const b = this._arcBounds(a0, a1)
const pad = Math.min(gw, gh) * 0.04
const boxW = Math.max(1e-6, b.maxX - b.minX)
const boxH = Math.max(1e-6, b.maxY - b.minY)
const r1 = Math.max(4, Math.min((gw - 2 * pad) / boxW, (gh - 2 * pad) / boxH))
const r0 = r1 * innerRatio
const cx = gw / 2 - ((b.minX + b.maxX) / 2) * r1
const cy = gh / 2 - ((b.minY + b.maxY) / 2) * r1
const alloc = this._arcAllocate(total, r0, r1, span, opts)
this._lastDotR = alloc.dotR
// Every seat position, then sorted by angle so category-ordered assignment
// paints contiguous wedges (the first category sits at the start angle).
/** @type {{a:number,x:number,y:number}[]} */
const seats = []
for (let r = 0; r < alloc.R; r++) {
const rho = alloc.radii[r]
const n = alloc.seatsPerRow[r]
for (let k = 0; k < n; k++) {
// (k+0.5)/n leaves even end margins and staggers successive rows.
const a = n === 1 ? (a0 + a1) / 2 : a0 + (span * (k + 0.5)) / n
seats.push({ a, x: cx + rho * ux(a), y: cy + rho * uy(a) })
}
}
seats.sort((s1, s2) => s1.a - s2.a)
const clusters = counts.map((_, i) => ({
i,
cx,
cy,
outerR: r1,
/** @type {{x:number,y:number,slot?:number}[]} */ dots: [],
}))
// Assign each category a contiguous run of seats equal to its count, so a
// party reads as an angular wedge; zero-count categories claim no seats.
// Slot = physical seat index (angle order) for the keyed transition.
let ci = 0
let used = 0
seats.forEach((s, slot) => {
while (ci < counts.length && used >= counts[ci]) {
ci++
used = 0
}
if (ci >= counts.length) return
clusters[ci].dots.push({ x: s.x, y: s.y, slot })
used++
})
return clusters
}
/**
* Bounding box of the outer arc (radius 1) over [a0, a1], including the centre
* and every cardinal angle (multiple of 90deg) inside the range, so a
* semicircle / full circle / arbitrary sweep is all bounded correctly.
* @param {number} a0 @param {number} a1
* @returns {{minX:number,maxX:number,minY:number,maxY:number}}
*/
_arcBounds(a0, a1) {
const ux = (/** @type {number} */ a) => Math.sin(a)
const uy = (/** @type {number} */ a) => -Math.cos(a)
const lo = Math.min(a0, a1)
const hi = Math.max(a0, a1)
const xs = [0, ux(a0), ux(a1)]
const ys = [0, uy(a0), uy(a1)]
const q = Math.PI / 2
for (let k = Math.ceil(lo / q); k * q <= hi; k++) {
xs.push(ux(k * q))
ys.push(uy(k * q))
}
return {
minX: Math.min(...xs),
maxX: Math.max(...xs),
minY: Math.min(...ys),
maxY: Math.max(...ys),
}
}
/**
* Allocate `total` seats across concentric rows of the annulus [r0, r1] sweeping
* `span` radians: seats per row are proportional to the row radius (a longer arc
* holds more), summed EXACTLY to total by largest remainder. Row count is
* `arc.rows` if given, else derived from a fixed dot size, else auto-searched to
* maximise the dot radius (the largest dots that still pack without overlap,
* mirroring `size:'auto'` elsewhere).
* @param {number} total @param {number} r0 @param {number} r1 @param {number} span @param {any} opts
* @returns {{R:number, radii:number[], seatsPerRow:number[], dotR:number}}
*/
_arcAllocate(total, r0, r1, span, opts) {
const spacing = opts.spacing > 0 ? opts.spacing : 1.05
const absSpan = Math.abs(span) || Math.PI
const fixed = this._fixedRadius(opts)
/** @param {number} R */
const evalR = (R) => {
R = Math.max(1, Math.round(R))
const radii = []
for (let r = 0; r < R; r++) {
radii.push(R === 1 ? (r0 + r1) / 2 : r0 + (r1 - r0) * (r / (R - 1)))
}
const weightSum = radii.reduce((a, x) => a + x, 0) || 1
const raw = radii.map((rho) => (total * rho) / weightSum)
const seatsPerRow = raw.map((x) => Math.floor(x))
let left = total - seatsPerRow.reduce((a, x) => a + x, 0)
raw
.map((x, idx) => ({ idx, frac: x - Math.floor(x) }))
.sort((p, qq) => qq.frac - p.frac)
.forEach((o) => {
if (left > 0) {
seatsPerRow[o.idx]++
left--
}
})
while (left > 0) {
seatsPerRow[R - 1]++
left--
}
const radialPitch = R === 1 ? r1 - r0 || r1 : (r1 - r0) / (R - 1)
let minArcPitch = Infinity
for (let r = 0; r < R; r++) {
const n = seatsPerRow[r]
if (n <= 0) continue
const arcPitch = (radii[r] * absSpan) / n
if (arcPitch < minArcPitch) minArcPitch = arcPitch
}
const pitch = Math.min(radialPitch, minArcPitch)
return { R, radii, seatsPerRow, dotR: Math.max(1, pitch / (2 * spacing)) }
}
const arcRows = opts.arc && opts.arc.rows
/** @type {{R:number, radii:number[], seatsPerRow:number[], dotR:number}} */
let res
if (typeof arcRows === 'number' && arcRows >= 1) {
res = evalR(arcRows)
} else if (fixed) {
const pitch = 2 * fixed * spacing
res = evalR((r1 - r0) / pitch + 1)
} else {
const maxR = Math.max(1, Math.min(40, Math.ceil(Math.sqrt(total)) + 6))
res = evalR(1)
for (let R = 2; R <= maxR; R++) {
const cand = evalR(R)
if (cand.dotR > res.dotR) res = cand
}
}
if (fixed) res.dotR = fixed
return res
}
/**
* 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)
// Vertical beeswarm: value on the Y axis, category lanes as X columns.
if (scfg.orientation === 'vertical') return this._layoutScatterVertical(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) : [],
)
/** @param {any} v @returns {v is number} */
const isNum = (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 domain = this._scatterValueDomain(scfg, vmin, vmax, tickAmount)
const xMin = domain.min
const xMax = domain.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 (domain-aligned; see _scatterValueDomain).
const ticks = domain.ticks
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
}
/**
* Vertical beeswarm: the transpose of _layoutScatter. The value runs UP the Y
* axis and each category is a column (lane) across X; the swarm pack spreads
* dots horizontally off each column's centre line. The value-axis config keys
* (`xMin`/`xMax`/`xTitle`/`xFormatter`/`tickAmount`) still describe the value
* axis (now Y), so flipping `orientation` keeps the same value settings.
* @param {any} opts
*/
_layoutScatterVertical(opts) {
const w = this.w
const scfg = opts.scatter || {}
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)
const sizeStats = this._scatterSizeStats(scfg, unitData)
const catVals = unitData.map((cat) =>
Array.isArray(cat) ? cat.map(valueOf) : [],
)
/** @param {any} v @returns {v is number} */
const isNum = (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 value 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 domain = this._scatterValueDomain(scfg, vmin, vmax, tickAmount)
const vMin = domain.min
const vMax = domain.max
const vSpan = vMax - vMin || 1
// Plot box: left gutter for the value (Y) axis, bottom gutter for the lane
// labels along X.
const leftGutter = 46 + (scfg.xTitle ? 18 : 0)
const bottomGutter = Kv > 1 ? 26 : 10
const plotL = leftGutter
const plotR = gw - 10
const plotT = 10
const plotB = gh - bottomGutter
const plotW = Math.max(4, plotR - plotL)
const plotH = Math.max(4, plotB - plotT)
// Value grows UPWARD (larger value = smaller y).
const plotY = (/** @type {number} */ v) =>
plotB - ((v - vMin) / vSpan) * plotH
const laneW = plotW / Kv
const laneCx = (/** @type {number} */ slot) => plotL + laneW * (slot + 0.5)
// Dot radius: fixed if set, else a small auto radius bounded by lane WIDTH
// and peak lane density (a swarm reads best with small dots).
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, laneW * 0.12, plotH / (2.5 * Math.sqrt(maxLane))),
)
}
this._lastDotR = r
const spacing = opts.spacing > 0 ? opts.spacing : 1
const step = Math.max(0.5, r * spacing)
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, cx:number, name:string }[]} */
const lanes = []
const maxR = sizeStats ? sizeStats.rMax : r
visible.forEach((ci, slot) => {
const cx = laneCx(slot)
lanes.push({ i: ci, cx, name: names[ci] || `series-${ci + 1}` })
const cat = unitData[ci] || []
const pts = cat.map((d, j) => {
const v = valueOf(d)
/** @type {{j:number,py:number,x:number,r?:number}} */
const p = { j, py: plotY(isNum(v) ? v : vMin), x: cx }
if (sizeStats) p.r = this._scatterRadius(d, sizeStats, r)
return p
})
if (jitter) {
const halfLane = Math.max(maxR, laneW / 2 - maxR)
pts.forEach((p, k) => {
const t = ((k * 9301 + 49297) % 233280) / 233280 // LCG in [0,1)
p.x = cx + (t * 2 - 1) * halfLane
})
} else {
this._beeswarm(pts, cx, r, step, maxR, true)
}
clusters.push({
i: ci,
cx,
cy: (plotT + plotB) / 2,
outerR: laneW / 2,
dots: pts.map((p) => ({ x: p.x, y: p.py, r: p.r })),
})
})
// Value-axis (Y) tick values (domain-aligned; see _scatterValueDomain).
const ticks = domain.ticks
this._scatterAxis = {
mode: '1d',
orientation: 'vertical',
plotL,
plotR,
plotT,
plotB,
vMin,
vMax,
plotY,
ticks,
lanes,
valueTitle: 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 value-window break. Deterministic
* (no physics, no randomness).
*
* Orientation-agnostic: the "fixed" axis is the value axis and the "spread"
* axis is the lane thickness. Horizontal (default): fixed = `px`, spread = `y`
* (mutates `.y`). Vertical: fixed = `py`, spread = `x` (mutates `.x`).
* @param {any[]} pts @param {number} center lane centre on the spread axis
* @param {number} rFallback @param {number} step @param {number} [maxR]
* @param {boolean} [vertical]
*/
_beeswarm(pts, center, rFallback, step, maxR, vertical = false) {
const fk = vertical ? 'py' : 'px' // fixed (value) coordinate
const sk = vertical ? 'x' : 'y' // spread (lane) coordinate to assign
const order = pts.slice().sort((a, b) => a[fk] - b[fk])
/** @type {{f:number,s:number,r:number}[]} */
const placed = []
const rCap = maxR != null ? maxR : rFallback
order.forEach((p) => {
const pr = p.r != null ? p.r : rFallback
let chosen = 0
for (let k = 0; k < 2000; k++) {
const off = k === 0 ? 0 : Math.ceil(k / 2) * step * (k % 2 ? 1 : -1)
const s = center + off
let ok = true
for (let m = placed.length - 1; m >= 0; m--) {
const q = placed[m]
const df = p[fk] - q.f
if (df > pr + rCap) break // value-ascending; nothing else in reach
const need = pr + q.r
const ds = s - q.s
if (df * df + ds * ds < need * need) {
ok = false
break
}
}
if (ok) {
chosen = off
break
}
}
p[sk] = center + chosen
placed.push({ f: p[fk], s: p[sk], r: pr })
})
}
/**
* Value-axis domain + ticks for a 1D beeswarm. The domain ALWAYS contains
* every datum: a swarm that clips a dot outside the plot box is a bug, so an
* explicit `xMin`/`xMax` only FRAMES the axis and is extended by whole
* tick-steps whenever the data would otherwise overflow. Orientation-agnostic:
* the same value axis is X for a horizontal swarm and Y for a vertical one.
* @param {any} scfg scatter config
* @param {number} vmin data minimum @param {number} vmax data maximum
* @param {number} tickAmount desired tick count
* @returns {{ min:number, max:number, ticks:number[] }}
*/
_scatterValueDomain(scfg, vmin, vmax, tickAmount) {
/** @param {number} min @param {number} max @param {number} spacing */
const buildTicks = (min, max, spacing) => {
/** @type {number[]} */
const ticks = []
for (let v = min; v <= max + spacing * 0.5; v += spacing) {
ticks.push(Math.abs(v) < spacing * 1e-9 ? 0 : v)
}
return ticks
}
if (scfg.xMin != null || scfg.xMax != null) {
let min = scfg.xMin != null ? scfg.xMin : vmin
let max = scfg.xMax != null ? scfg.xMax : vmax
if (!(max > min)) max = min + 1
const spacing = (max - min) / Math.max(1, tickAmount - 1)
// Extend the framed bounds by whole steps so no dot falls outside the
// plot (keeps the user's tick origin + spacing, just adds ticks).
if (vmin < min) min -= Math.ceil((min - vmin) / spacing) * spacing
if (vmax > max) max += Math.ceil((vmax - max) / spacing) * spacing
return { min, max, ticks: buildTicks(min, max, spacing) }
}
const nice = this._niceScale(vmin, vmax, tickAmount)
const spacing =
nice.spacing || (nice.max - nice.min) / Math.max(1, tickAmount - 1)
return { min: nice.min, max: nice.max, ticks: buildTicks(nice.min, nice.max, spacing) }
}
/**
* A "nice" numeric scale [min, max] + tick spacing covering [dataMin, dataMax]
* with about `ticks` ticks, using rounded 1/2/5 x 10^n steps. Homegrown (no
* dependency) - lean-core.
* @param {number} dataMin @param {number} dataMax @param {number} ticks
* @returns {{min:number,max:number,spacing:number}}
*/
_niceScale(dataMin, dataMax, ticks) {
const lo = dataMin
let hi = dataMax
if (!(hi > lo)) hi = lo + 1
const range = this._niceNum(hi - lo, false)
const spacing = this._niceNum(range / Math.max(1, ticks - 1), true)
return {
min: Math.floor(lo / spacing) * spacing,
max: Math.ceil(hi / spacing) * spacing,
spacing,
}
}
/**
* Round a range to a "nice" 1/2/5 x 10^n number (Heckbert's loose/round label
* algorithm).
* @param {number} range @param {boolean} round @returns {number}
*/
_niceNum(range, round) {
const rng = range > 0 ? range : 1
const exp = Math.floor(Math.log(rng) / Math.LN10)
const frac = rng / Math.pow(10, exp)
let nf
if (round) {
nf = frac < 1.5 ? 1 : frac < 3 ? 2 : frac < 7 ? 5 : 10
} else {
nf = frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 5 ? 5 : 10
}
return nf * Math.pow(10, exp)
}
/**
* Draw the scatter chrome behind the dots, from the geometry the layout
* stashed on `this._scatterAxis`. 1D (beeswarm): vertical X gridlines +
* baseline + tick labels (+ x title) + a per-lane category label in the
* category colour. 2D: both X + Y gridlines, both axes' tick labels, and
* rotated/placed axis titles (no lane labels - category is colour). Browser-
* only (SSR renders the dots without the chrome, as with cluster labels).
* @param {any} ret @param {Graphics} graphics
*/
_drawScatterAxes(ret, graphics) {
const w = this.w
if (!Environment.isBrowser()) return
const ax = this._scatterAxis
if (!ax) return
const NS = 'http://www.w3.org/2000/svg'
const g = graphics.group({ class: 'apexcharts-unit-axis' })
const gridColor =
(w.config.grid && w.config.grid.borderColor) || 'rgba(128,128,128,0.18)'
const axisColor = 'rgba(128,128,128,0.5)'
// xaxis.labels.style.colors is the one axis-text colour knob a scatter
// layout responds to (string, or ApexCharts' per-label array form, whose
// first entry wins here). When set it also recolours the lane labels, so
// a page can match this chrome to another chart type's axes; unset, lanes
// keep their category colour.
const cfgColors =
w.config.xaxis &&
w.config.xaxis.labels &&
w.config.xaxis.labels.style &&
w.config.xaxis.labels.style.colors
const configuredLabelColor = Array.isArray(cfgColors)
? cfgColors[0]
: cfgColors
const labelColor = configuredLabelColor || 'rgba(120,130,140,0.9)'
const line = (/** @type {number} */ x1, /** @type {number} */ y1, /** @type {number} */ x2, /** @type {number} */ y2, /** @type {string} */ stroke) => {
const l = BrowserAPIs.createElementNS(NS, 'line')
l.setAttribute('x1', String(x1))
l.setAttribute('y1', String(y1))
l.setAttribute('x2', String(x2))
l.setAttribute('y2', String(y2))
l.setAttribute('stroke', stroke)
l.setAttribute('shape-rendering', 'crispEdges')
g.node.appendChild(l)
}
const text = (
/** @type {string} */ str,
/** @type {number} */ x,
/** @type {number} */ y,
/** @type {string} */ anchor,
/** @type {string} */ fill,
/** @type {number} */ size,
/** @type {number} */ weight,
/** @type {string} */ cls,
) => {
const t = BrowserAPIs.createElementNS(NS, 'text')
t.setAttribute('class', cls)
t.setAttribute('x', String(x))
t.setAttribute('y', String(y))
t.setAttribute('text-anchor', anchor)
t.setAttribute('dominant-baseline', 'middle')
t.setAttribute('font-size', `${size}px`)
t.setAttribute('font-family', w.config.chart.fontFamily || 'inherit')
t.setAttribute('font-weight', String(weight))
t.setAttribute('fill', fill)
t.textContent = str
g.node.appendChild(t)
}
if (ax.mode === '2d') {
// Y gridlines + tick labels (value grows upward).
ax.yTicks.forEach((/** @type {number} */ v) => {
const y = ax.plotY(v)
if (ax.gridlines) line(ax.plotL, y, ax.plotR, y, gridColor)
const label = ax.yFormatter ? String(ax.yFormatter(v)) : this._formatTick(v)
text(label, ax.plotL - 8, y, 'end', labelColor, 11, 400, 'apexcharts-unit-tick')
})
// X gridlines + tick labels.
ax.xTicks.forEach((/** @type {number} */ v) => {
const x = ax.plotX(v)
if (ax.gridlines) line(x, ax.plotT, x, ax.plotB, gridColor)
const label = ax.xFormatter ? String(ax.xFormatter(v)) : this._formatTick(v)
text(label, x, ax.plotB + 14, 'middle', labelColor, 11, 400, 'apexcharts-unit-tick')
})
// Axis baselines (X along the bottom, Y up the left).
line(ax.plotL, ax.plotB, ax.plotR, ax.plotB, axisColor)
line(ax.plotL, ax.plotT, ax.plotL, ax.plotB, axisColor)
// X title under the ticks; Y title rotated up the left edge.
if (ax.xTitle) {
text(
String(ax.xTitle),
(ax.plotL + ax.plotR) / 2,
ax.plotB + 32,
'middle',
labelColor,
12,
600,
'apexcharts-unit-axis-title',
)
}
if (ax.yTitle) {
const yt = BrowserAPIs.createElementNS(NS, 'text')
yt.setAttribute('class', 'apexcharts-unit-axis-title')
const tx = 14
const ty = (ax.plotT + ax.plotB) / 2
yt.setAttribute('x', String(tx))
yt.setAttribute('y', String(ty))
yt.setAttribute('text-anchor', 'middle')
yt.setAttribute('font-size', '12px')
yt.setAttribute('font-family', w.config.chart.fontFamily || 'inherit')
yt.setAttribute('font-weight', '600')
yt.setAttribute('fill', labelColor)
yt.setAttribute('transform', `rotate(-90 ${tx} ${ty})`)
yt.textContent = String(ax.yTitle)
g.node.appendChild(yt)
}
ret.add(g)
return
}
// 1D vertical beeswarm: value on Y (horizontal gridlines + left labels),
// category lanes as columns labelled along the bottom.
if (ax.orientation === 'vertical') {
ax.ticks.forEach((/** @type {number} */ v) => {
const y = ax.plotY(v)
if (ax.gridlines) line(ax.plotL, y, ax.plotR, y, gridColor)
const label = ax.formatter ? String(ax.formatter(v)) : this._formatTick(v)
text(label, ax.plotL - 8, y, 'end', labelColor, 11, 400, 'apexcharts-unit-tick')
})
// Y axis baseline up the left.
line(ax.plotL, ax.plotT, ax.plotL, ax.plotB, axisColor)
// Value (Y) axis title, rotated up the left edge.
if (ax.valueTitle) {
const yt = BrowserAPIs.createElementNS(NS, 'text')
yt.setAttribute('class', 'apexcharts-unit-axis-title')
const tx = 14
const ty = (ax.plotT + ax.plotB) / 2
yt.setAttribute('x', String(tx))
yt.setAttribute('y', String(ty))
yt.setAttribute('text-anchor', 'middle')
yt.setAttribute('font-size', '12px')
yt.setAttribute('font-family', w.config.chart.fontFamily || 'inherit')
yt.setAttribute('font-weight', '600')
yt.setAttribute('fill', labelColor)
yt.setAttribute('transform', `rotate(-90 ${tx} ${ty})`)
yt.textContent = String(ax.valueTitle)
g.node.appendChild(yt)
}
// Lane (category) labels along the bottom: explicit axis label colour
// when configured, else the category colour.
ax.lanes.forEach((/** @type {{i:number,cx:number,name:string}} */ lane) => {
const color =
configuredLabelColor ||
w.globals.colors[lane.i] ||
w.globals.colors[0] ||
'#008FFB'
text(lane.name, lane.cx, ax.plotB + 16, 'middle', color, 12, 600, 'apexcharts-unit-lane-label')
})
ret.add(g)
return
}
// 1D beeswarm: vertical gridlines + tick labels. The first / last labels are
// edge-anchored (start / end) so a wide value at the plot edge stays inside.
ax.ticks.forEach((/** @type {number} */ v, /** @type {number} */ idx) => {
const x = ax.plotX(v)
if (ax.gridlines) line(x, ax.plotT, x, ax.plotB, gridColor)
const label = ax.formatter ? String(ax.formatter(v)) : this._formatTick(v)
const anchor =
idx === 0 ? 'start' : idx === ax.ticks.length - 1 ? 'end' : 'middle'
text(label, x, ax.plotB + 14, anchor, labelColor, 11, 400, 'apexcharts-unit-tick')
})
// X axis baseline.
line(ax.plotL, ax.plotB, ax.plotR, ax.plotB, axisColor)
// X axis title.
if (ax.xTitle) {
text(
String(ax.xTitle),
(ax.plotL + ax.plotR) / 2,
ax.plotB + 32,
'middle',
labelColor,
12,
600,
'apexcharts-unit-axis-title',
)
}
// Lane (category) labels in the left gutter: explicit axis label colour
// when configured, else the category colour.
if (ax.plotL > 12) {
ax.lanes.forEach((/** @type {{i:number,cy:number,name:string}} */ lane) => {
const color =
configuredLabelColor ||
w.globals.colors[lane.i] ||
w.globals.colors[0] ||
'#008FFB'
text(lane.name, ax.plotL - 8, lane.cy, 'end', color, 12, 600, 'apexcharts-unit-lane-label')
})
}
ret.add(g)
}
/**
* Compact tick-value formatting: integers as-is, otherwise trimmed to a short
* decimal; large magnitudes get a k/M suffix.
* @param {number} v @returns {string}
*/
_formatTick(v) {
if (!isFinite(v)) return ''
const a = Math.abs(v)
if (a >= 1e6) return `${+(v / 1e6).toFixed(1)}M`
if (a >= 1e4) return `${+(v / 1e3).toFixed(1)}k`
if (Number.isInteger(v)) return String(v)
return String(+v.toFixed(2))
}
/**
* Distribute `total` whole cells across `counts` in proportion to each value,
* using the largest-remainder method so the parts sum to exactly `total`
* (used by the grid/waffle percentage mode).
* @param {number[]} counts @param {number} total @returns {number[]}
*/
_largestRemainder(counts, total) {
const sum = counts.reduce((a, b) => a + b, 0)
if (sum <= 0 || total <= 0) return counts.map(() => 0)
const exact = counts.map((c) => (c / sum) * total)
const floors = exact.map((v) => Math.floor(v))
const used = floors.reduce((a, b) => a + b, 0)
const remaining = Math.max(0, total - used)
// Hand the leftover cells to the categories with the largest fractional part.
const byFrac = exact
.map((v, i) => ({ i, frac: v - Math.floor(v) }))
.sort((a, b) => b.frac - a.frac)
const out = floors.slice()
for (let n = 0; n < remaining && n < byFrac.length; n++) {
out[byFrac[n].i]++
}
return out
}
/**
* Phyllotaxis (sunflower) placement for `n` points around (cx, cy).
* @param {number} cx @param {number} cy @param {number} n
* @param {number} step @param {number} startIndex
* @returns {{x:number,y:number}[]}
*/
_spiral(cx, cy, n, step, startIndex) {
const pts = []
for (let k = 0; k < n; k++) {
const idx = startIndex + k
const r = step * Math.sqrt(idx + 0.5)
const theta = idx * GOLDEN_ANGLE
pts.push({ x: cx + r * Math.cos(theta), y: cy + r * Math.sin(theta) })
}
return pts
}
/**
* A fixed dot radius, if the shape/size implies one: an explicit numeric
* `size`, or an `image` shape (sized by its own width/height). Returns null
* when dots should auto-size to fit the plot.
* @param {any} opts @returns {number | null}
*/
_fixedRadius(opts) {
if (opts.shape === 'image' && opts.image) {
return Math.max(opts.image.width || 20, opts.image.height || 20) / 2
}
// Bubble sizing (opt-in, circle only): the lattice is spaced for the
// LARGEST bubble so per-value radii (see _radiusForValue) never overlap a
// neighbour. A numeric maxRadius fixes that spacing; 'auto' falls through to
// auto-sizing and the fitted radius becomes the effective max.
if (this._bubbleActive(opts) && typeof opts.sizeByValue.maxRadius === 'number') {
return opts.sizeByValue.maxRadius > 0 ? opts.sizeByValue.maxRadius : null
}
if (typeof opts.size === 'number' && opts.size > 0) return opts.size
return null
}
/**
* Whether opt-in bubble sizing applies: enabled, and the shape sizes per
* mark. Squares and images keep a uniform size; a pictogram does not, because
* its scale is derived per mark from the same radius a circle would use.
* @param {any} opts @returns {boolean}
*/
_bubbleActive(opts) {
const sbv = opts.sizeByValue
return !!(
sbv &&
sbv.enabled &&
opts.shape !== 'image' &&
opts.shape !== 'square'
)
}
/**
* This datum's numeric value for sizing / tooltip: the number itself, or an
* object's `value` / `y`. Null when there is no usable number.
* @param {any} d @returns {number | null}
*/
_unitValueOf(d) {
if (typeof d === 'number') return d
if (d && typeof d === 'object') {
const v = d.value != null ? d.value : d.y
return typeof v === 'number' ? v : null
}
return null
}
/**
* Radius for one bubble given the value stats. Default 'area' scaling makes
* a bubble's AREA proportional to its value (radius grows with sqrt); 'linear'
* scales the radius directly. Missing values collapse to the min radius.
* @param {number|null} v
* @param {{min:number,max:number,minR:number,maxR:number,scale:string}} stats
* @returns {number}
*/
_radiusForValue(v, stats) {
if (v == null || !isFinite(v)) return stats.minR
const t = stats.max > stats.min ? (v - stats.min) / (stats.max - stats.min) : 1
const tc = Math.max(0, Math.min(1, t))
if (stats.scale === 'linear') {
return stats.minR + tc * (stats.maxR - stats.minR)
}
const aMin = stats.minR * stats.minR
const aMax = stats.maxR * stats.maxR
return Math.sqrt(aMin + tc * (aMax - aMin))
}
/**
* Value stats + radius bounds for bubble sizing, or null when it does not
* apply (disabled, non-circle shape, or no per-unit values). `maxR` is the
* reference radius the layout already spaced the lattice for; `minR` defaults
* to ~35% of it.
* @param {any[][]} unitData @param {any} opts @param {number} refR
* @returns {{min:number,max:number,minR:number,maxR:number,scale:string}|null}
*/
_bubbleStats(unitData, opts, refR) {
if (!this._bubbleActive(opts)) return null
let vmin = Infinity
let vmax = -Infinity
unitData.forEach((cat) => {
if (!cat) return
cat.forEach((d) => {
const v = this._unitValueOf(d)
if (v != null && isFinite(v)) {
if (v < vmin) vmin = v
if (v > vmax) vmax = v
}
})
})
if (vmin === Infinity || vmax < vmin) return null
const sbv = opts.sizeByValue
const maxR = refR
const minR = Math.max(
1,
Math.min(
maxR,
typeof sbv.minRadius === 'number' ? sbv.minRadius : maxR * 0.35,
),
)
return {
min: vmin,
max: vmax,
minR,
maxR,
scale: sbv.scale === 'linear' ? 'linear' : 'area',
}
}
/**
* Radial step between successive spiral shells. A fixed radius derives the
* step directly; 'auto' derives it so a cluster of `count` dots fits `availR`.
* @param {any} opts @param {number} availR @param {number} count
* @returns {number}
*/
_resolveStep(opts, availR, count) {
const spacing = opts.spacing > 0 ? opts.spacing : 1
const fixed = this._fixedRadius(opts)
if (fixed) return 2 * fixed * spacing
// fit: step * (sqrt(count) + 0.5) <= availR (the +0.5 leaves room for the
// outermost dot's own radius).
return availR / (Math.sqrt(Math.max(1, count)) + 0.5)
}
/**
* @param {number} step @param {any} opts
* @returns {number}
*/
_dotRadiusFromStep(step, opts) {
const spacing = opts.spacing > 0 ? opts.spacing : 1
const fixed = this._fixedRadius(opts)
if (fixed) return fixed
return Math.max(1, step / (2 * spacing))
}
/**
* Corner-anchored shapes (square, image) position by their top-left x/y;
* circles position by their centre cx/cy.
* @param {any} opts @returns {boolean}
*/
_isCorner(opts) {
return opts.shape === 'square' || opts.shape === 'image'
}
/**
* Half-width/height used to convert a centre point to a corner shape's x/y.
* @param {any} opts @param {number} [r] this mark's own radius; defaults to
* the chart-wide one (an image is sized by its own width/height either way)
* @returns {{hx:number, hy:number}}
*/
_halfExtent(opts, r) {
if (opts.shape === 'image' && opts.image) {
return { hx: (opts.image.width || 20) / 2, hy: (opts.image.height || 20) / 2 }
}
const rr = r != null ? r : this._lastDotR
return { hx: rr, hy: rr }
}
/**
* The draw + placement rule for ONE mark.
*
* Positioning used to be a chart-GLOBAL decision - `_isCorner(opts)` and a
* single `_halfExtent(opts)`, hoisted out of the gather loop - which held only
* while every mark in a render was the same element. Two things broke that:
* a pictogram render where dot 3 is a <circle> and dot 4 a <path>, and the
* plainer bug that a `square` sized from a per-position radius (`_drawDot`
* uses the dot's own `rj`) was still being CENTRED with the chart-wide
* `_lastDotR`, so a layout returning per-mark radii drew every square off its
* own slot by `_lastDotR - r`.
*
* So the rule travels with the mark. A spec is one frozen object per distinct
* (kind, size) - shared by every dot that uses it, resolved once per render -
* carrying an int the frame loop switches on. `_place` is the only writer.
*
* @typedef {object} UnitMarkSpec
* @property {number} pk PK_CIRCLE | PK_CORNER | PK_GLYPH
* @property {number} [hx] corner: half-width
* @property {number} [hy] corner: half-height
* @property {any} [mark] glyph: the resolved mark definition
* @property {string} [d] glyph: path data, in the mark's own viewBox units
* @property {string} [fillRule] glyph: 'evenodd' when the mark declares it
* @property {number} [s] glyph: uniform scale from viewBox units to px
* @property {number} [ox] glyph: pre-scaled x of the viewBox centre
* @property {number} [oy] glyph: pre-scaled y of the viewBox centre
* @property {string} [tail] glyph: the pre-built `) scale(s)` transform tail
* @property {number} [r] the radius this spec was fitted to
*/
/**
* Position one mark at (x, y), whatever element it is.
*
* Circles and corner shapes write byte-identically to what they wrote before
* this seam existed, so the morph capture and every existing test read the
* same DOM. A glyph writes ONE attribute where they write two.
*
* @param {SVGElement} node @param {UnitMarkSpec} spec
* @param {number} x @param {number} y
*/
_place(node, spec, x, y) {
// One typedef covers all three kinds, so every per-kind field is optional
// on it. The `pk` switch is what makes them present.
const s = /** @type {any} */ (spec)
if (s.pk === PK_GLYPH) {
node.setAttribute(
'transform',
'translate(' + (x - s.ox) + ',' + (y - s.oy) + s.tail,
)
} else if (s.pk === PK_CORNER) {
node.setAttribute('x', String(x - s.hx))
node.setAttribute('y', String(y - s.hy))
} else {
node.setAttribute('cx', String(x))
node.setAttribute('cy', String(y))
}
}
/**
* The spec for the chart-wide shape (no pictogram, no per-mark radius).
* @param {any} opts @param {number} [r]
* @returns {UnitMarkSpec}
*/
_baseSpec(opts, r) {
const rr = r != null ? r : this._lastDotR
if (!this._isCorner(opts)) return { pk: PK_CIRCLE, r: rr }
const { hx, hy } = this._halfExtent(opts, rr)
return { pk: PK_CORNER, hx, hy, r: rr }
}
/**
* Resolve whatever `pictogram.mark` / `datum.mark` held into a mark
* definition, or null. A name goes through the registry; an object or a bare
* path string is taken as-is.
*
* An unresolvable mark warns ONCE per name and falls back rather than
* dropping the unit: a typo should cost you the glyph, not the data point.
*
* @param {any} ref @returns {any|null}
*/
_resolveMark(ref) {
if (ref == null) return null
if (typeof ref === 'object') return normalizeUnitMark(ref)
if (typeof ref !== 'string' || !ref) return null
const s = ref.trim()
// Sugar: an inline outline rather than a registered name.
if (s[0] === 'M' || s[0] === 'm') return normalizeUnitMark(s)
const found = getUnitMark(s)
if (found) return found
if (!this._markWarned) this._markWarned = new Set()
if (!this._markWarned.has(s)) {
this._markWarned.add(s)
console.warn(
`[ApexCharts] unit chart: no mark named "${s}" is registered. ` +
`Register one with ApexCharts.registerUnitMark("${s}", pathData), ` +
`or import a catalog from 'apexcharts/pictograms'.`,
)
}
return null
}
/**
* The draw spec for one glyph at the current lattice pitch, cached per
* (mark, radius) for the render so thousands of units of one glyph resolve
* once and then share both the spec and the `d` STRING.
*
* The scale lives in the transform rather than being baked into `d`, for two
* reasons: baking needs a full path parser at runtime (the unit-shapes one
* lives in a separate optional module, and arcs cannot be scaled by naive
* number substitution), and a constant `scale(s)` costs the same single
* attribute write per frame that a bare translate would.
*
* Sizing is derived from `dotR` - the radius the LAYOUT chose - so a glyph
* occupies the box the dot itself would have. Swapping `circle` for a
* pictogram therefore never re-flows the chart: same pitch, same slots.
*
* @param {any} mark @param {number} dotR @param {any} pcfg
* @returns {UnitMarkSpec}
*/
_glyphSpec(mark, dotR, pcfg) {
// Quantised to 0.1px so bubble sizing (a distinct radius per unit) cannot
// grow the cache to one entry per dot; sub-tenth-pixel scale differences
// are not observable anyway.
const qr = Math.round(dotR * 10) / 10
const key = mark.name + '|' + mark.path.length + '|' + qr
const hit = this._specCache.get(key)
if (hit) return hit
const vb = mark.viewBox || [0, 0, 100, 100]
const pad = Math.max(0, Math.min(0.9, pcfg.padding || 0))
const grow = typeof pcfg.scale === 'number' && pcfg.scale > 0 ? pcfg.scale : 1
const box = 2 * qr * (1 - pad) * grow
const s =
pcfg.fit === 'width'
? box / vb[2]
: pcfg.fit === 'height'
? box / vb[3]
: box / Math.max(vb[2], vb[3])
const spec = Object.freeze({
pk: PK_GLYPH,
mark,
d: mark.path,
fillRule: mark.fillRule,
s,
ox: (vb[0] + vb[2] / 2) * s,
oy: (vb[1] + vb[3] / 2) * s,
tail: ') scale(' + s + ')',
r: qr,
})
this._specCache.set(key, spec)
return spec
}
/**
* Which mark THIS unit draws.
*
* Precedence mirrors how `datum.fillColor` already overrides the category
* colour: the datum's own `mark` first (a per-unit override, so one crowd can
* mix glyphs), then the per-series entry of a `mark` array, then the one
* chart-wide mark.
*
* @param {any} opts @param {any} datum @param {number} i @param {number} r
* @returns {UnitMarkSpec}
*/
_markSpecFor(opts, datum, i, r) {
if (opts.shape !== 'pictogram') return this._baseSpec(opts, r)
const pcfg = opts.pictogram || {}
const own = datum && typeof datum === 'object' ? datum.mark : undefined
const cfg = Array.isArray(pcfg.mark)
? pcfg.mark[i % pcfg.mark.length]
: pcfg.mark
const mark = this._resolveMark(own != null ? own : cfg)
if (mark) return this._glyphSpec(mark, r, pcfg)
// Unresolvable: draw the fallback element rather than nothing.
return this._baseSpec(
{ ...opts, shape: pcfg.fallback === 'square' ? 'square' : 'circle' },
r,
)
}
/**
* Draw one dot (circle, square, or image icon) with the category fill +
* stroke, tagged so the shared non-axis tooltip and hover reuse work.
* @param {Graphics} graphics @param {any} opts @param {number} dotR
* @param {string} color @param {number} i @param {number} j
* @param {UnitMarkSpec} [spec] this mark's resolved spec; defaults to the
* chart-wide shape
* @returns {any}
*/
_drawDot(graphics, opts, dotR, color, i, j, spec) {
const w = this.w
const strokeW = w.config.stroke.show ? w.config.stroke.width : 0
const strokeColor = Array.isArray(w.globals.stroke.colors)
? w.globals.stroke.colors[i] || 'none'
: 'none'
// Fill translucency via the standard `fill.opacity` (unit defaults to 1, so
// dots stay solid unless a chart opts in - e.g. bubble scatters set < 1 so
// overlapping bubbles read through each other). Applied as the SVG
// `fill-opacity` attribute, independent of the element `style.opacity` the
// gather / exit animations tween, so the two compose cleanly.
const fillOpacity =
typeof w.config.fill.opacity === 'number' ? w.config.fill.opacity : 1
let el
if (spec && spec.pk === PK_GLYPH) {
// A monochrome vector glyph: ONE <path>, filled directly in the mark's
// colour. No fetch, no decode, and crucially no per-colour <filter> - an
// feFlood/feComposite pair (what `shape:'image'` needs to match the
// legend) forces an offscreen surface PER ELEMENT on every paint, which
// is what caps the tinted-image pictogram well below the dot chart.
el = w.dom.Paper.path(spec.d)
el.node.setAttribute('fill', color)
if (spec.fillRule === 'evenodd') {
el.node.setAttribute('fill-rule', 'evenodd')
}
if (fillOpacity < 1) el.node.setAttribute('fill-opacity', String(fillOpacity))
// Geometry the morph capture cannot read back off a transform. Written
// once at creation; never touched in a frame.
el.node.setAttribute('data:r', String(spec.r))
} else if (opts.shape === 'image' && opts.image && opts.image.src) {
const iw = opts.image.width || 20
const ih = opts.image.height || 20
el = w.dom.Paper.image(opts.image.src)
el.node.setAttribute('width', String(iw))
el.node.setAttribute('height', String(ih))
el.node.setAttribute('preserveAspectRatio', 'xMidYMid meet')
// Recolour a monochrome icon to the category / per-unit colour so the
// pictogram matches the legend. An feFlood floods the target colour and
// feComposite clips it to the icon's own alpha, leaving the silhouette in
// the new colour. Off by default so multi-colour icons keep their colours.
if (opts.image.tint) {
el.node.setAttribute('filter', `url(#${this._tintFilter(color)})`)
}
} else if (opts.shape === 'square') {
const side = dotR * 2
el = graphics.drawRect(0, 0, side, side, opts.borderRadius || 0, color, 1, strokeW, strokeColor)
el.node.setAttribute('fill', color)
if (fillOpacity < 1) el.node.setAttribute('fill-opacity', String(fillOpacity))
} else {
el = graphics.drawCircle(dotR, {
fill: color,
'stroke-width': strokeW,
stroke: strokeColor,
})
el.node.setAttribute('fill', color)
if (fillOpacity < 1) el.node.setAttribute('fill-opacity', String(fillOpacity))
}
el.node.classList.add('apexcharts-unit-area')
el.node.setAttribute('i', String(i))
el.node.setAttribute('j', String(j))
return el
}
/**
* Ensure (once per colour) an SVG recolour filter exists in the chart's defs
* and return its id. The filter floods `color` and clips it to the source
* graphic's alpha (feComposite operator="in"), so an `<image>` referencing a
* monochrome icon is repainted in `color` while keeping its silhouette. Reused
* across every dot of the same colour.
* @param {string} color @returns {string}
*/
_tintFilter(color) {
const w = this.w
const NS = 'http://www.w3.org/2000/svg'
const safe = String(color).replace(/[^a-zA-Z0-9]/g, '')
const id = `apexcharts-unit-tint-${w.globals.chartID}-${safe}`
const svg = w.dom.Paper.node
if (svg.querySelector(`#${id}`)) return id
let defs = svg.querySelector('defs')
if (!defs) {
defs = BrowserAPIs.createElementNS(NS, 'defs')
svg.insertBefore(defs, svg.firstChild)
}
const filter = BrowserAPIs.createElementNS(NS, 'filter')
filter.setAttribute('id', id)
// Keep the flood within the icon box; percentages avoid clipping the icon.
filter.setAttribute('x', '0%')
filter.setAttribute('y', '0%')
filter.setAttribute('width', '100%')
filter.setAttribute('height', '100%')
const flood = BrowserAPIs.createElementNS(NS, 'feFlood')
flood.setAttribute('flood-color', color)
flood.setAttribute('result', 'flood')
const comp = BrowserAPIs.createElementNS(NS, 'feComposite')
comp.setAttribute('in', 'flood')
comp.setAttribute('in2', 'SourceAlpha')
comp.setAttribute('operator', 'in')
filter.appendChild(flood)
filter.appendChild(comp)
defs.appendChild(filter)
return id
}
/**
* Position a non-animated dot at (x, y). Circles use cx/cy at the centre;
* corner shapes (square, image) use x/y at the top-left; a pictogram rides a
* transform. Callers that already hold the mark's spec pass it; the rest get
* the chart-wide one.
* @param {SVGElement} node @param {any} opts @param {number} x @param {number} y
* @param {UnitMarkSpec} [spec]
*/
_placeDot(node, opts, x, y, spec) {
this._place(node, spec || this._baseSpec(opts), x, y)
}
/**
* Parse a `#rgb` / `#rrggbb` / `rgb()` / `rgba()` colour to `[r, g, b]`, or
* null if it cannot be parsed (the colour tween is then skipped).
* @param {string} str @returns {number[] | null}
*/
_rgb(str) {
if (typeof str !== 'string') return null
let s = str.trim()
if (s[0] === '#') {
if (s.length === 4) s = '#' + s[1] + s[1] + s[2] + s[2] + s[3] + s[3]
const n = parseInt(s.slice(1, 7), 16)
if (isNaN(n)) return null
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
const m = s.match(/rgba?\(([^)]+)\)/)
if (m) {
const p = m[1].split(',').map((x) => parseFloat(x))
if (p.length >= 3 && p.every((v) => !isNaN(v))) return [p[0], p[1], p[2]]
}
return null
}
/**
* Whether to run the gather / transition animation. Runs on the initial mount
* and on data-driven updates (keyed old->new tween or cross-type burst).
* Skipped: in SSR, when animations are off, when the caller passed
* `animate:false` (shouldAnimate === false), on a PURE window resize (resized
* with no data change - re-gathering on every resize would be jarring), and
* when the user prefers reduced motion.
*
* Note: `w.globals.resized` is set true on every update (not just window
* resize), so it must be paired with `!dataChanged` to isolate a real resize.
* @returns {boolean}
*/
_shouldAnimate() {
const w = this.w
const anim = w.config.chart.animations
if (!Environment.isBrowser()) return false
if (!anim || anim.enabled === false) return false
if (w.globals.shouldAnimate === false) return false
if (w.globals.resized && !w.globals.dataChanged) return false
if (anim.respectReducedMotion && prefersReducedMotion()) return false
return true
}
/**
* Give every dot an x/y spring, reusing the live springs of a gather this
* render just cancelled.
*
* The reuse is the whole point of the spring path. A carried spring holds a
* dot's real on-screen position AND its velocity, so an interrupted gather
* resumes from there. Without it the dot restarts from `cx0`, which on an
* update is the slot it was still travelling towards - so every interruption
* teleports it forward and then re-animates from a standstill. A dragged
* slider or a scrubbed storyboard interrupts on almost every frame, which is
* where that reads worst.
*
* Springs left over from a completed gather are at rest on their targets, so
* carrying them is identical to making fresh ones. Only an interrupted flight
* carries anything.
*
* @param {UnitAnimDot[]} dots
* @param {any} gcfg plotOptions.unit.gather
* @param {number} speed chart.animations.speed, in ms
*/
_seedSprings(dots, gcfg, speed) {
const [stiffness, damping] = springParams(gcfg.spring, speed)
const live = this.ctx ? this.ctx._unitSprings : null
/** @type {Map<string, {x: import('apex-commons').Spring, y: import('apex-commons').Spring}>} */
const springs = new Map()
for (let k = 0; k < dots.length; k++) {
const d = dots[k]
// An entering dot has no previous slot, so a spring left under its key
// belongs to a different dot's flight, not to this one.
const carried = live && !d.isEnter && d.key != null ? live.get(d.key) : null
const sx = carried ? carried.x : makeSpring(d.cx0, stiffness, damping)
const sy = carried ? carried.y : makeSpring(d.cy0, stiffness, damping)
if (carried) {
// `speed` may have changed since these were made. Rescaling in place
// keeps the velocity and adopts the new timing.
sx.stiffness = stiffness
sy.stiffness = stiffness
sx.damping = damping
sy.damping = damping
// The draw pass placed this node at its previous SLOT. Put it back
// where the dot actually is, before the first frame paints.
d.cx0 = sx.value
d.cy0 = sy.value
this._place(d.node, d.spec, d.cx0, d.cy0)
// A dot that is still moving does not get re-staggered. The stagger
// exists to make a LAUNCH read as a wave, and a moving dot is not
// launching; holding it to its old target through the delay window
// would send it visibly onward to a slot that no longer exists. Under
// continuous scrubbing every dot is moving, so the cloud simply tracks
// the slider. A spring left at rest by a finished gather still has its
// velocity at zero, so a discrete update keeps the full stagger.
if (sx.velocity !== 0 || sy.velocity !== 0) d.delay = 0
}
d.sx = sx
d.sy = sy
if (d.key != null) springs.set(d.key, { x: sx, y: sy })
}
if (this.ctx) this.ctx._unitSprings = springs
}
/**
* One rAF loop that tweens every dot from its start (cx0/cy0 - either the
* cluster centre on first mount / for entering dots, or its previous slot on
* an update) to its target slot, staggered by index. Entering dots fade in;
* moving dots stay opaque. Dots whose group colour changed (a 'flow' regroup)
* cross-fade their fill from the old colour to the new one over the same ease;
* dots whose radius changed (bubble sizing) grow/shrink over it too (circles).
*
* Position travels on a spring by default (`gather.motion`), so a gather
* interrupted by the next render resumes from where the dots actually are,
* carrying their velocity, rather than restarting from a standstill. Colour,
* radius and opacity stay on a fixed-duration ease either way: those are 0..1
* quantities, and the shared solver's rest thresholds are absolute (0.05 in
* caller units), which is negligible for pixels but 5% of a unit interval.
* @param {UnitAnimDot[]} dots
*/
_runGather(dots) {
const w = this.w
const opts = w.config.plotOptions.unit
const speed = Math.max(1, w.config.chart.animations.speed || 800)
// The placement rule is on each dot's `spec` (see `_place`), not hoisted
// here: a pictogram render mixes <circle> and <path> in one population, so
// there is no single chart-wide rule to hoist. The per-frame write count
// does not go up - a circle still writes cx+cy, a glyph writes ONE
// transform.
// Stagger across the whole population, capped so large sets still settle
// quickly. Dots animate outward roughly centre-first.
const maxDelay = Math.min(speed * 0.6, 450)
const n = dots.length
for (let k = 0; k < n; k++) {
dots[k].delay = n > 1 ? (k / (n - 1)) * maxDelay : 0
}
const gcfg = opts.gather || {}
// Springs unless the caller asked for a tween, which a non-default
// `gather.easing` counts as: 'outBack' and 'inOutCubic' are curve choices a
// spring cannot express, so honouring them keeps an explicit look working
// without the caller having to set `motion` as well.
const motion = gcfg.motion || 'auto'
const useSpring =
motion === 'spring' ||
(motion === 'auto' && (!gcfg.easing || gcfg.easing === 'outCubic'))
if (useSpring) this._seedSprings(dots, gcfg, speed)
// Springs left from an earlier render are stale once the tween path owns
// the positions, so switching motion at runtime cannot resurrect them.
else if (this.ctx) this.ctx._unitSprings = null
// Seed the starting radius for size-changing circles so they grow from the
// previous size instead of flashing at the new one for a frame. (The old
// "seed initial corner" pass that stood here is gone: it rewrote exactly
// what draw()'s _placeDot had already written, and what _seedSprings
// rewrites again for a carried spring.)
for (let k = 0; k < n; k++) {
const d = dots[k]
if (d.spec.pk === PK_CIRCLE && d.r0 != null && d.r1 != null && d.r0 !== d.r1) {
d.node.setAttribute('r', String(d.r0))
}
}
// Pre-parse the endpoints for dots that recolour mid-flight (flow regroup);
// dots that keep their colour skip the tween entirely.
for (let k = 0; k < n; k++) {
const d = dots[k]
if (d.fill0 && d.fill1 && d.fill0 !== d.fill1) {
d._c0 = this._rgb(d.fill0)
d._c1 = this._rgb(d.fill1)
}
}
// Position easing for the tween path. Default decelerates to a stop;
// `gather.easing: 'inOutCubic'` accelerates out of rest first (weighted
// travel), and 'outBack' overshoots each dot past its slot and springs back
// (a per-dot settle), with `gather.overshoot` tuning the spring strength.
// Colour, radius and opacity always stay on the out-cubic: a back ease
// exceeds 1 mid-flight, which would push RGB channels out of range and
// wobble radii.
const easePos =
gcfg.easing === 'outBack'
? easeOutBack(typeof gcfg.overshoot === 'number' ? gcfg.overshoot : 1.70158)
: gcfg.easing === 'inOutCubic'
? easeInOutCubic
: easeOutCubic
// Cancel a gather loop still running from a previous render before starting
// a new one, so we don't keep animating the old (detached) dots on a rapid
// update.
if (this.w.globals.unitGatherRAF != null) {
BrowserAPIs.cancelAnimationFrame(this.w.globals.unitGatherRAF)
this.w.globals.unitGatherRAF = null
}
const start = performance.now()
let last = start
/** @param {number} now */
const stepFn = (now) => {
// Bail if the chart was destroyed mid-gather (route change / re-render):
// the dots are detached and setAttribute on them is wasted work. Matches
// Animations.animatePop.
if (this.w.globals.isDestroyed) {
this.w.globals.unitGatherRAF = null
this.w.globals.animationEnded = true
return
}
const dt = Math.min(MAX_FRAME_STEP, Math.max(0, (now - last) / 1000))
last = now
let done = true
for (let k = 0; k < n; k++) {
const d = dots[k]
const elapsed = now - start - d.delay
const t = Math.max(0, Math.min(1, elapsed / speed))
const ec = easeOutCubic(t)
let cx, cy
if (d.sx && d.sy) {
// Hold at the start value until the dot's stagger delay elapses,
// then aim it at its slot. A dot carried over mid-flight had its
// delay zeroed in _seedSprings, so it turns toward the new slot on
// this first frame, carrying the velocity it already had.
if (elapsed >= 0 && !d.released) {
retarget(d.sx, d.x)
retarget(d.sy, d.y)
d.released = true
}
const restX = stepSpring(d.sx, dt)
const restY = stepSpring(d.sy, dt)
if (!d.released || !restX || !restY) done = false
cx = d.sx.value
cy = d.sy.value
} else {
const e = easePos(t)
cx = d.cx0 + (d.x - d.cx0) * e
cy = d.cy0 + (d.y - d.cy0) * e
}
this._place(d.node, d.spec, cx, cy)
// Entering dots fade in over the first stretch; moving dots stay solid.
if (d.isEnter) d.node.style.opacity = String(Math.min(1, t * 2.5))
// Cross-fade the fill for dots that changed group colour.
if (d._c0 && d._c1) {
const cr = Math.round(d._c0[0] + (d._c1[0] - d._c0[0]) * ec)
const cg = Math.round(d._c0[1] + (d._c1[1] - d._c0[1]) * ec)
const cb = Math.round(d._c0[2] + (d._c1[2] - d._c0[2]) * ec)
d.node.setAttribute('fill', `rgb(${cr},${cg},${cb})`)
}
// Grow/shrink circles whose radius changed (bubble sizing).
if (
d.spec.pk === PK_CIRCLE &&
d.r0 != null &&
d.r1 != null &&
d.r0 !== d.r1
) {
d.node.setAttribute('r', String(d.r0 + (d.r1 - d.r0) * ec))
}
if (t < 1) done = false
}
if (done) {
for (let k = 0; k < n; k++) {
const d = dots[k]
d.node.style.opacity = ''
// Settle on the exact target colour + radius (undo rounding drift).
if (d._c1 && d.fill1) d.node.setAttribute('fill', d.fill1)
if (
d.spec.pk === PK_CIRCLE &&
d.r0 != null &&
d.r1 != null &&
d.r0 !== d.r1
) {
d.node.setAttribute('r', String(d.r1))
}
}
this.w.globals.unitGatherRAF = null
// The gather is the unit chart's animation, so its completion is what
// "the chart has settled" means here. Raised only when the loop reaches
// rest, so a snapshot taken on this signal catches the final layout.
this.w.globals.animationEnded = true
} else {
this.w.globals.unitGatherRAF = BrowserAPIs.requestAnimationFrame(stepFn)
}
}
this.w.globals.unitGatherRAF = BrowserAPIs.requestAnimationFrame(stepFn)
}
/**
* Keys present in the previous render but not the current one, resolved back
* to their old slot {x, y, fill}. These are the dots that must animate out.
* @param {Map<string, {x:number,y:number,fill:string,r?:number,spec?:any}>} prev
* @param {Map<string, {x:number,y:number,fill:string,r?:number,spec?:any}>} nextPrev
* @param {any} opts
* @returns {{x:number,y:number,fill:string,r?:number,spec?:any}[]}
*/
_collectExits(prev, nextPrev, opts) {
// Cap the ghost count so a huge dataset switch (e.g. hundreds removed) does
// not spawn an unbounded number of one-shot animated nodes.
const cap = Math.max(0, opts.maxUnits || 5000)
/** @type {{x:number,y:number,fill:string,r?:number,spec?:any}[]} */
const exits = []
for (const [key, slot] of prev) {
if (!nextPrev.has(key)) {
exits.push(slot)
if (exits.length >= cap) break
}
}
return exits
}
/**
* Animate the exit ghosts out, then remove them. Layouts whose positions carry
* data (a waffle / grid lattice, or a scatter / beeswarm on real axes) fade
* their ghosts OUT IN PLACE - drifting them toward the plot centre would drag
* cells across tiles or bubbles across the plane, which reads as wrong. The
* blob / bar layouts keep the gentle inward collapse so a removal reads as
* motion rather than a pop.
* @param {any} group @param {{x:number,y:number,fill:string,r?:number,spec?:any}[]} exits @param {any} opts
*/
_runExits(group, exits, opts) {
const w = this.w
const graphics = new Graphics(w, this.ctx)
const dotR = this._lastDotR
const cx = w.layout.gridWidth / 2
const cy = w.layout.gridHeight / 2
// Layouts whose positions carry data - a grid/waffle lattice, a scatter /
// beeswarm on real axes, or anything a caller positioned itself - fade
// their ghosts IN PLACE (no drift): a removed cell / point simply fades on
// its own spot. Only the blob / bar layouts keep the gentle inward collapse.
const drift =
opts.layout === 'grid' ||
opts.layout === 'scatter' ||
opts.layout === 'arc' ||
opts.layout === 'custom'
? 0
: 0.35
/** @type {{ node: SVGElement, x0:number, y0:number, spec: UnitMarkSpec }[]} */
const ghosts = []
exits.forEach((slot) => {
// A ghost must leave as the mark it WAS, so the spec travels with the
// slot; without it every pictogram would ghost out as a plain circle.
const r = slot.r != null ? slot.r : dotR
const spec = slot.spec || this._baseSpec(opts, r)
const el = this._drawDot(graphics, opts, r, slot.fill, 0, 0, spec)
el.node.classList.add('apexcharts-unit-exit')
this._place(el.node, spec, slot.x, slot.y)
group.add(el)
ghosts.push({ node: el.node, x0: slot.x, y0: slot.y, spec })
})
if (!this._shouldAnimate()) {
// No animation: just drop them (they belong to the old data).
ghosts.forEach((g) => g.node.remove())
return
}
const speed = Math.max(1, w.config.chart.animations.speed || 800)
// Cancel an exit loop still running from a previous render before starting a
// new one, so stale ghosts don't keep fading on a rapid update.
if (this.w.globals.unitExitRAF != null) {
BrowserAPIs.cancelAnimationFrame(this.w.globals.unitExitRAF)
this.w.globals.unitExitRAF = null
}
const start = performance.now()
/** @param {number} now */
const stepFn = (now) => {
// Bail if the chart was destroyed mid-exit (the ghost group is detached).
if (this.w.globals.isDestroyed) {
this.w.globals.unitExitRAF = null
return
}
const t = Math.max(0, Math.min(1, (now - start) / speed))
const e = easeOutCubic(t)
for (let k = 0; k < ghosts.length; k++) {
const g = ghosts[k]
// Drift a little toward the centre while fading (no drift for a grid:
// the ghost stays on its own cell and simply fades away).
if (drift) {
const x = g.x0 + (cx - g.x0) * e * drift
const y = g.y0 + (cy - g.y0) * e * drift
this._place(g.node, g.spec, x, y)
}
g.node.style.opacity = String(1 - e)
}
if (t < 1) {
this.w.globals.unitExitRAF = BrowserAPIs.requestAnimationFrame(stepFn)
} else {
this.w.globals.unitExitRAF = null
group.node && group.node.remove()
}
}
this.w.globals.unitExitRAF = BrowserAPIs.requestAnimationFrame(stepFn)
}
/**
* Are outer (name) labels on? Only for `layout: 'custom'`: they name a colour
* BAND, so they need categories that occupy their own part of the shape. The
* generated layouts (`packed`, `grid`) interleave categories, and the blob /
* bar / arc layouts already carry a label of their own.
* @param {any} opts
*/
_outerLabelsOn(opts) {
const cfg = opts.clusterLabels
return !!(
opts.layout === 'custom' &&
cfg &&
cfg.show !== false &&
cfg.external &&
cfg.external.show
)
}
/**
* One outer label's text, as lines. Two lines by default (name, then share),
* which is what makes the label readable at a distance from the band it names.
* A `clusterLabels.formatter` may return "\n"-separated text to control the
* split, or a single line.
* @param {number} i @param {number} value @param {number} total @param {any} opts
* @returns {string[]}
*/
_outerLabelLines(i, value, total, opts) {
const w = this.w
const name = w.seriesData.seriesNames[i] || `series-${i + 1}`
const percent = total > 0 ? (value / total) * 100 : 0
const cfg = opts.clusterLabels
const text =
typeof cfg.formatter === 'function'
? cfg.formatter(name, { seriesIndex: i, value, percent, w })
: `${name}\n${percent.toFixed(1)}%`
return String(text).split('\n')
}
/**
* Room one side has to give up: the widest label, plus the leader line, plus a
* little air. Capped at a quarter of the plot so one long category name shrinks
* its own label into the gutter instead of starving the shape.
* @param {number[]} counts @param {any} opts
* @returns {number}
*/
_outerLabelGutter(counts, opts) {
const w = this.w
const cfg = opts.clusterLabels
const conn = cfg.external.connector || {}
const total = counts.reduce((a, b) => a + b, 0)
/** @type {string[]} */
const lines = []
counts.forEach((c, i) => {
if (c > 0) lines.push(...this._outerLabelLines(i, c, total, opts))
})
if (!lines.length) return 0
const width = measureLabelWidth(w, lines, {
fontSize: cfg.fontSize,
fontFamily: cfg.fontFamily || w.config.chart.fontFamily,
})
const gap = conn.gap != null ? conn.gap : 8
const length = conn.length != null ? conn.length : 22
const room = width + gap + length + 8 + Math.abs(parseFloat(cfg.external.offsetX) || 0)
return Math.min(room, w.layout.gridWidth * 0.25)
}
/**
* Plan and draw the outer labels. A band's anchor is one of its own dots - the
* outermost on the label's side, preferring dots near the band's middle - so
* the leader line lands on the crowd rather than on a bounding box the viewer
* cannot see.
*
* Sides: a silhouette ordered by rows stacks its categories vertically, so
* their centroids share an x and the labels have to alternate left/right down
* the shape. One ordered by columns spreads them horizontally, so each label
* goes to the side its band is already on.
*
* @param {any} ret @param {{ i:number, cx:number, cy:number, dots:{x:number,y:number,r?:number}[] }[]} clusters
* @param {number[]} counts @param {number} total @param {any} opts
* @param {boolean} gathering true only when the dots are flying in from the
* centre (first render / cross-type morph). On an update the crowd is already
* on screen, so the labels must not wait for anything.
*/
_drawOuterLabels(ret, clusters, counts, total, opts, gathering) {
const w = this.w
if (!Environment.isBrowser()) return
const cfg = opts.clusterLabels
const ext = cfg.external
const conn = ext.connector || {}
const gap = conn.gap != null ? conn.gap : 8
const length = conn.length != null ? conn.length : 22
const offsetX = parseFloat(ext.offsetX) || 0
const offsetY = parseFloat(ext.offsetY) || 0
const gw = w.layout.gridWidth
const gh = w.layout.gridHeight
const dotR = this._lastDotR
const live = clusters.filter((c) => c.dots.length > 0)
if (!live.length) return
let spreadX = 0
let spreadY = 0
if (live.length > 1) {
const xs = live.map((c) => c.cx)
const ys = live.map((c) => c.cy)
spreadX = Math.max(...xs) - Math.min(...xs)
spreadY = Math.max(...ys) - Math.min(...ys)
}
const bandedByX = spreadX > spreadY
const fontSize = parseFloat(cfg.fontSize) || 13
const lineHeight = Math.round(fontSize * 1.35)
/** @type {{ i:number, lines:string[], anchor:{x:number,y:number}, elbow:{x:number,y:number}, labelX:number, idealY:number, labelY:number, side:'left'|'right' }[]} */
const items = []
live
.slice()
.sort((a, b) => a.cy - b.cy)
.forEach((c, k) => {
const lines = this._outerLabelLines(c.i, counts[c.i], total, opts)
if (!lines.some((l) => l !== '')) return
/** @type {'left'|'right'} */
const side = bandedByX
? c.cx >= gw / 2
? 'right'
: 'left'
: k % 2 === 0
? 'right'
: 'left'
const dir = side === 'right' ? 1 : -1
// Outermost dot on this side, pulled toward the band's own middle so a
// single stray dot at the shape's tip cannot claim the label.
let best = c.dots[0]
let bestScore = -Infinity
c.dots.forEach((d) => {
const score = dir * d.x - 0.75 * Math.abs(d.y - c.cy)
if (score > bestScore) {
bestScore = score
best = d
}
})
const anchor = { x: best.x + dir * (best.r || dotR), y: best.y }
const elbow = { x: anchor.x + dir * gap, y: anchor.y }
items.push({
i: c.i,
lines,
anchor,
elbow,
labelX: elbow.x + dir * length + offsetX,
idealY: anchor.y + offsetY,
labelY: anchor.y + offsetY,
side,
})
})
if (!items.length) return
// Space each gutter column apart, then keep it inside the plot: a label
// block is centred on labelY, so half of it has to fit above and below.
const maxLines = items.reduce((m, it) => Math.max(m, it.lines.length), 1)
const block = maxLines * lineHeight
const half = block / 2
;['left', 'right'].forEach((side) => {
spaceOutLabels(
items.filter((it) => it.side === side),
block + 2,
gh - half,
half,
)
})
const group = new Graphics(w, this.ctx).group({
class: 'apexcharts-unit-outer-labels',
})
// On a gather the dots fly in from the plot centre, so a leader line drawn at
// full opacity would point at an empty gutter. Fade the labels in behind it.
//
// Deliberately NOT timed to the last dot landing (speed + the stagger): the
// crowd is easing out, so the shape is recognisable long before it settles,
// and waiting for the final dot reads as the labels being broken. They come
// in partway through instead, and the fade is slow enough to feel like they
// arrive WITH the crowd rather than popping in after it. Capped so a slow
// configured speed cannot hold them back for seconds.
if (gathering) {
const speed = Math.max(1, w.config.chart.animations.speed || 800)
group.node.classList.add('apexcharts-unit-label-delay')
group.node.style.animationDelay = `${Math.min(speed * 0.45, 600) / 1000}s`
}
items.forEach((it) => {
const color = w.globals.colors[it.i] || w.globals.colors[0] || '#008FFB'
group.add(
drawOuterLabel(w, {
lines: it.lines,
lineHeight,
anchor: it.anchor,
elbow: it.elbow,
labelX: it.labelX,
labelY: it.labelY,
side: it.side,
connector: {
show: conn.show !== false,
width: conn.width != null ? conn.width : 1.5,
color: conn.color || color,
},
style: {
fontSize: cfg.fontSize,
fontFamily: cfg.fontFamily || w.config.chart.fontFamily,
fontWeight: cfg.fontWeight,
},
foreColor: cfg.color || w.config.chart.foreColor,
groupClass: 'apexcharts-unit-outer-label-group',
textClass: 'apexcharts-unit-outer-label',
connectorClass: 'apexcharts-unit-label-connector',
}),
)
})
ret.add(group)
}
/**
* A cluster label placed above (default) or below the cluster/bar. A TOP label
* over a wide grouped/packed blob rides a curved arc (invisible arc path +
* <textPath>, centred at 50% offset); a bottom label, a 'columns' bar, or a
* cluster too small for the arc gets a straight centred label instead.
* `clusterLabels.position` = 'top' | 'bottom'; `offsetY` pushes it further from
* the blob in either direction.
* @param {any} elSeries @param {{ i:number, cx:number, cy:number, outerR:number, flat?:boolean }} cluster
* @param {number} value @param {number} total @param {any} opts @param {string} color
*/
_drawClusterLabel(elSeries, cluster, value, total, opts, color) {
const w = this.w
if (!Environment.isBrowser()) return
const NS = 'http://www.w3.org/2000/svg'
const name = w.seriesData.seriesNames[cluster.i] || `series-${cluster.i + 1}`
const percent = total > 0 ? (value / total) * 100 : 0
const cfg = opts.clusterLabels
const fontSize = parseFloat(cfg.fontSize) || 13
let text
if (typeof cfg.formatter === 'function') {
text = cfg.formatter(name, {
seriesIndex: cluster.i,
value,
percent,
w,
})
} else {
text = `${name} (${percent.toFixed(1)}%)`
}
const str = typeof text === 'string' ? text : String(text)
const textEl = BrowserAPIs.createElementNS(NS, 'text')
textEl.setAttribute('class', 'apexcharts-unit-label')
textEl.setAttribute('text-anchor', 'middle')
textEl.setAttribute('font-size', `${fontSize}px`)
textEl.setAttribute('font-family', cfg.fontFamily || w.config.chart.fontFamily || 'inherit')
textEl.setAttribute('font-weight', String(cfg.fontWeight || 600))
textEl.setAttribute('fill', cfg.color || color)
const bottom = cfg.position === 'bottom'
// Arc radius sits just outside the blob (top placement only).
const R = cluster.outerR + fontSize * 0.6 + 3 + (cfg.offsetY || 0)
// Rough text width (no measuring API in SSR). Only curve the label when it
// fits the upper-semicircle arc length (pi * R); otherwise a small cluster
// would wrap its label around a few dots. Fall back to a straight label.
const estWidth = str.length * fontSize * 0.55
// Curve only a TOP label over a grouped/packed blob wide enough to carry it.
// A bottom label, a 'columns' bar (cluster.flat), or a tiny cluster takes a
// straight label instead.
const curved =
!bottom &&
!cluster.flat &&
cfg.curved !== false &&
estWidth <= Math.PI * R * 0.95
if (curved) {
const yMid = cluster.cy
const x1 = cluster.cx - R
const x2 = cluster.cx + R
// Upper semicircle (left -> right over the TOP). In SVG y grows downward,
// so sweep-flag 1 bows the arc upward; the label rides the crown.
const d = `M ${x1} ${yMid} A ${R} ${R} 0 0 1 ${x2} ${yMid}`
const arcId = `apexcharts-unit-label-${w.globals.chartID}-${cluster.i}`
const pathEl = BrowserAPIs.createElementNS(NS, 'path')
pathEl.setAttribute('id', arcId)
pathEl.setAttribute('d', d)
pathEl.setAttribute('fill', 'none')
pathEl.setAttribute('stroke', 'none')
const tp = BrowserAPIs.createElementNS(NS, 'textPath')
tp.setAttribute('href', `#${arcId}`)
tp.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', `#${arcId}`)
tp.setAttribute('startOffset', '50%')
tp.textContent = str
textEl.appendChild(tp)
elSeries.node.appendChild(pathEl)
} else {
// Straight label centred above (default) or below the blob/bar. offsetY
// pushes it further away from the blob in either direction.
textEl.setAttribute('x', String(cluster.cx))
const y = bottom
? cluster.cy + cluster.outerR + fontSize + 6 + (cfg.offsetY || 0)
: cluster.cy - cluster.outerR - 6 - (cfg.offsetY || 0)
textEl.setAttribute('y', String(y))
textEl.textContent = str
}
elSeries.node.appendChild(textEl)
}
}