UNPKG

apexcharts

Version:

A JavaScript Chart Library

1,006 lines (917 loc) 34.4 kB
// @ts-check /** * ApexCharts Sunburst / nested pie-donut (hierarchical radial) chart. * * Rings go from the centre hole outward, one per hierarchy level; each child * arc is nested inside its parent's angular wedge. * * P1: static rings + parent tinting + per-node tooltip. * P2: bloom intro animation, click-to-zoom (focus a branch, animate, breadcrumb * back), curved <textPath> labels, strict-partition warning. * * Data: a native `children` hierarchy, OR an existing `drilldown` config * (`series` + `drilldown.series`) read as plain data by the adapter below (NO * dependency on the drilldown runtime feature). * * Self-contained by design (colour tint + tooltip + breadcrumb, and the shared * standalone arc-path builder in common/arc which has no Pie dependency) so a * `apexcharts/sunburst` bundle does not drag in the Pie or Drilldown modules. * See plans/19-sunburst.md. * * @module SunburstChart */ import Graphics from '../modules/Graphics' import { BrowserAPIs } from '../ssr/BrowserAPIs.js' import { Environment } from '../utils/Environment.js' import { roundedDonutSegmentPath, sharpDonutSegmentPath, } from './common/arc/ArcPath' import Animations from '../modules/Animations' import { buildHierarchy, fillValues, morphKey } from './common/Hierarchy' import { avoidChromeOverlap } from './common/Breadcrumb' const D2R = Math.PI / 180 const R2D = 180 / Math.PI const SVGNS = 'http://www.w3.org/2000/svg' const XHTML = 'http://www.w3.org/1999/xhtml' /** * @param {number} a * @param {number} b * @param {number} t */ const lerp = (a, b, t) => a + (b - a) * t export default class SunburstChart { /** * @param {import('../types/internal').ChartStateW} w * @param {import('../types/internal').ChartContext} ctx */ constructor(w, ctx) { this.ctx = ctx this.w = w const cnf = w.config this.cfg = cnf.plotOptions.sunburst this.strokeWidth = cnf.stroke.show ? cnf.stroke.width : 0 this.strokeColor = Array.isArray(cnf.stroke.colors) ? cnf.stroke.colors[0] : cnf.stroke.colors || '#fff' this.startAngle = this.cfg.startAngle this.endAngle = this.cfg.endAngle this.maxDepth = 0 this.centerX = 0 this.centerY = 0 this.maxRadius = 0 this.total = 1 this._focusMaxDepth = 0 /** @type {any} focused node (null = root/whole tree) */ this._focus = null // Bumped on every layout pass. A rapid re-click (zoom before the previous // zoom settles) starts a new pass; older animation frames check this and // stop writing, so two rAF loops never fight over the same arc's geometry. this._zoomGen = 0 /** @type {any[]} */ this._roots = [] /** @type {any[]} flat list of every node */ this._nodesAll = [] /** @type {(vd: number) => number} */ this._innerR = () => 0 /** @type {(vd: number) => number} */ this._outerR = () => 0 /** @type {any} */ this._tooltipEl = null this._lblSeq = 0 // How many leaves have taken a captured mark this layout pass (cross-type // morph: leaf k unrolls from the outgoing chart's k-th mark). this._morphLeafIndex = 0 /** @type {any} */ this._graphics = null /** @type {any} */ this._ringsG = null /** @type {any} */ this._labelsG = null } /** * @param {any[]} series flattened top-level values (geometry comes from the * config hierarchy; kept for the standard draw(series) signature + noData) * @returns {any} SVG group */ draw(series) { const w = this.w const graphics = new Graphics(this.w) this._graphics = graphics const g = graphics.group({ class: 'apexcharts-sunburst' }) if (w.globals.noData || !series || !series.length) return g const gw = w.layout.gridWidth const gh = w.layout.gridHeight this.centerX = gw / 2 + (this.cfg.offsetX || 0) this.centerY = gh / 2 + (this.cfg.offsetY || 0) this.maxRadius = Math.min(gw, gh) / 2.05 - this.strokeWidth - (!w.config.chart.sparkline.enabled ? w.config.chart.dropShadow.blur : 0) if (this.maxRadius < 5) return g // Build + colour the hierarchy once (angles/radii are recomputed per zoom). this._roots = this._buildHierarchy() if (!this._roots.length) return g this._roots.forEach((r) => this._fillValues(r)) this._validateStrict() this._nodesAll = [] const colors = w.globals.colors || [] this._roots.forEach((r, i) => { this._colorPass(r, r.color || colors[i % colors.length] || '#008FFB') }) this.total = this._roots.reduce((s, r) => s + Math.max(0, r.value), 0) || 1 this._ringsG = graphics.group({ class: 'apexcharts-sunburst-rings' }) this._labelsG = graphics.group({ class: 'apexcharts-sunburst-labels' }) g.add(this._ringsG) g.add(this._labelsG) this._focus = null this._relayout(this._focus) // Same gating as Pie: angular clock-sweep intro on first render, a morph // from the previous on-screen geometry on data updates (never an instant // re-render while animations are on), nothing on resize. const anims = w.config.chart.animations /** @type {'intro'|'zoom'|'update'|'none'} */ let mode = 'none' if (anims.enabled) { if (w.globals.dataChanged) { if (anims.dynamicAnimation.enabled) mode = 'update' } else if (!w.globals.resized) { mode = 'intro' } } this._applyLayout(mode) this._renderBreadcrumb() return g } // ------------------------------------------------------------------ data /** * Resolve the config into root nodes `{ name, value, color?, children? }`. * Each datum may carry `children` (native) or `drilldown: '<id>'` (adapter). * Shared with the treemap - see charts/common/Hierarchy. * @returns {any[]} */ _buildHierarchy() { return buildHierarchy(this.w) } /** * Fill a parent's value from its children when missing. * @param {any} node */ _fillValues(node) { fillValues(node) } /** * With `partition: 'strict'`, warn (once) when a parent's value does not * match the sum of its children. The angles are still normalized to fill the * wedge (strict rendering is a P3 refinement); this just surfaces the data * mismatch. */ _validateStrict() { if (this.cfg.partition !== 'strict') return let warned = false /** @param {any} node */ const walk = (node) => { if (node.children && node.children.length) { const sum = node.children.reduce( (/** @type {number} */ s, /** @type {any} */ c) => s + Math.max(0, c.value || 0), 0, ) if (!warned && node.value != null && Math.abs(sum - node.value) > 0.5) { console.warn( `ApexCharts sunburst: partition 'strict' but "${node.name}" (${node.value}) != sum of its children (${sum}). Angles are normalized to fill the wedge.`, ) warned = true } node.children.forEach(walk) } } this._roots.forEach(walk) } /** * Assign a colour to every node (explicit `color` wins, else the parent's * colour tinted lighter). Done once so zoom preserves colours. * @param {any} node * @param {string} color */ _colorPass(node, color) { node._color = node.color || color this._nodesAll.push(node) if (node.children) { node.children.forEach((/** @type {any} */ c) => this._colorPass(c, c.color || this._lighten(node._color, this.cfg.tint)), ) } } // ---------------------------------------------------------------- layout /** * Recompute visibility + angles + radii for a focus node (null = whole tree). * @param {any} focus */ _relayout(focus) { this._nodesAll.forEach((n) => { n._show = false }) this._focusMaxDepth = 0 if (!focus) { const total = this._roots.reduce((s, r) => s + Math.max(0, r.value), 0) || 1 let a = this.startAngle this._roots.forEach((r) => { const span = (this.endAngle - this.startAngle) * Math.max(0, r.value) / total this._placeVis(r, 0, a, a + span, null) a += span }) } else { this._placeVis(focus, 0, this.startAngle, this.endAngle, focus._parent) } // Radial bands for the current focus depth. const hole = this._parseSize(this.cfg.innerSize, this.maxRadius) const ringCount = this._focusMaxDepth + 1 const band = (this.maxRadius - hole) / ringCount const radialGap = ringCount > 1 ? 1 : 0 this._innerR = (/** @type {number} */ vd) => hole + vd * band + (vd > 0 ? radialGap / 2 : 0) this._outerR = (/** @type {number} */ vd) => hole + (vd + 1) * band - radialGap / 2 // Final radii per node (honouring leaf-extend). this._nodesAll.forEach((n) => { if (!n._show) return n._iR = this._innerR(n._vDepth) n._oR = n._leaf && this.cfg.leaf === 'extend' && n._vDepth < this._focusMaxDepth ? this.maxRadius : this._outerR(n._vDepth) }) } /** * @param {any} node * @param {number} vDepth * @param {number} a0 * @param {number} a1 * @param {any} parent */ _placeVis(node, vDepth, a0, a1, parent) { node._show = true node._vDepth = vDepth node._a0 = a0 node._a1 = a1 node._parent = parent node._leaf = !(node.children && node.children.length) if (vDepth > this._focusMaxDepth) this._focusMaxDepth = vDepth if (!node._leaf) { const total = node.children.reduce( (/** @type {number} */ s, /** @type {any} */ c) => s + Math.max(0, c.value), 0, ) || 1 let a = a0 node.children.forEach((/** @type {any} */ c) => { const span = ((a1 - a0) * Math.max(0, c.value)) / total this._placeVis(c, vDepth + 1, a, a + span, node) a += span }) } } // --------------------------------------------------------------- render /** * Create / update / remove arc elements to match the current layout, with an * animation appropriate to the transition: * intro — pie/donut-style angular clock sweep from startAngle to endAngle * (all rings reveal together as the sweep line passes them) * update — morph every arc from its previous on-screen geometry (matched * by node key across the re-render); new arcs unfurl in place * zoom — tween angles + radii between focus layouts (same instance) * @param {'intro'|'zoom'|'update'|'none'} mode */ _applyLayout(mode) { const w = this.w const anims = w.config.chart.animations const dur = !anims.enabled ? 0 : mode === 'none' ? 0 : mode === 'update' ? anims.dynamicAnimation.speed || 350 : anims.speed || 500 // Supersede any in-flight zoom: stale frames from the previous pass check // this and stop, so a fast re-click can't leave two animations fighting. const gen = ++this._zoomGen // Cross-type morph source (the optional `morph` feature). Leaves are handed // out in draw order, which is the order the outgoing chart's marks were // captured in, so tile k becomes leaf k. // // Not gated on `mode`: a type change arrives with new data, so it resolves // to 'update', and a snapshot only exists during one anyway. It brings its // own speed, the same one every other morphing renderer uses. const morph = /** @type {any} */ (this.ctx)?.morphTypeChange const morphActive = !!morph && typeof morph.isActive === 'function' && morph.isActive() && typeof morph.getInitialPathAt === 'function' const morphDur = morphActive ? morph.getSpeed() : 0 this._morphLeafIndex = 0 // Geometry of the PREVIOUS render (stashed on the persistent chart ctx — // this module is re-instantiated on every data update). const prev = mode === 'update' ? /** @type {any} */ (this.ctx)._sunburstPrevGeoms : null this._nodesAll.forEach((node) => { if (node._show) { const target = { a0: node._a0, a1: node._a1, iR: node._iR, oR: node._oR, } if (!node._el) node._el = this._createArcEl(node) // Cross-type morph (treemap -> sunburst): a leaf starts as the tile // that stood for the same row and unrolls into its arc. Interior rings // have no counterpart in a flat partition, so they sweep in as the // leaves arrive. const morphFrom = morphActive ? this._morphSourceFor(node) : null if (morphFrom && morphDur > 0) { this._morphArcFrom(node, morphFrom, target, morphDur, gen) } else if ((mode === 'intro' || morphActive) && dur > 0) { this._sweepArc(node, target, morphActive ? morphDur : dur, gen) } else { let from let isNew = false if (node._cur) { // `_cur` is the arc's LIVE geometry (updated each frame), so an // interrupted zoom continues smoothly from where it actually is. from = node._cur } else if (prev && prev.get(node._key)) { from = prev.get(node._key) // survived a data update: morph in place } else { const mid = (target.a0 + target.a1) / 2 // new arc: unfurl from its slot from = { a0: mid, a1: mid, iR: target.iR, oR: target.iR } isNew = true } // Only genuinely new arcs fade in; arcs morphing from a previous // geometry stay fully opaque while their shape tweens. this._animateArc(node, from, target, dur, false, isNew, gen) } } else if (node._el && node._cur) { const mid = (node._cur.a0 + node._cur.a1) / 2 const target = { a0: mid, a1: mid, iR: node._cur.iR, oR: node._cur.iR } this._animateArc(node, node._cur, target, dur, true, false, gen) } }) // Stash what is now on screen so the NEXT data update can morph from it. const geoms = new Map() this._nodesAll.forEach((n) => { if (n._show) { geoms.set(n._key, { a0: n._a0, a1: n._a1, iR: n._iR, oR: n._oR }) } }) ;/** @type {any} */ (this.ctx)._sunburstPrevGeoms = geoms this._renderLabels(dur) } /** * The captured mark this leaf should unroll from, or null when the node is * the outgoing chart had nothing to give it. * * When both charts carry branch keys the pairing is by identity, so EVERY * ring finds the tile that stood for the same branch and the inner rings * unroll instead of appearing from nothing. * * Without keys (a flat treemap, or an older config) only leaves pair, and * they consume the captured paths in draw order - the order the outgoing * renderer laid its own marks out in, so tile k pairs with leaf k. * * @param {any} node * @returns {string | null} */ _morphSourceFor(node) { const ctx = /** @type {any} */ (this.ctx) const morph = ctx && ctx.morphTypeChange if (!morph) return null if ( typeof morph.hasKeyedMarks === 'function' && morph.hasKeyedMarks() && typeof morph.getInitialPathForKey === 'function' ) { return morph.getInitialPathForKey(morphKey(node._key)) } if (node.children && node.children.length) return null return morph.getInitialPathAt(this._morphLeafIndex++) } /** * Unroll an arc from an arbitrary captured shape. * * `_animateArc` interpolates arc PARAMETERS (angles and radii), which cannot * express a rectangle, so this one tweens the path data itself through * Animations.morphSVG - the same call every other morphing renderer makes, * which already selects the polygon-resample algorithm while a cross-type * morph is active. * * @param {any} node * @param {string} fromD * @param {{a0:number,a1:number,iR:number,oR:number}} target * @param {number} dur * @param {number} gen */ _morphArcFrom(node, fromD, target, dur, gen) { const el = node._el const toD = this._arcPath( target.iR, target.oR, target.a0, target.a1, this.cfg.borderRadius, ) el.node.style.display = '' el.attr({ d: fromD, opacity: 1 }) // `_cur` is what an interrupting zoom tweens FROM. During a cross-type // morph the arc has no meaningful intermediate geometry (it is a rectangle // part-way to an arc), so the target is the honest answer: a zoom landing // mid-morph continues from the arc this one is becoming. node._cur = target if (this._zoomGen !== gen) return new Animations(this.w, /** @type {any} */ (this.ctx)).morphSVG( el, 0, // Not a (series, point) index: an arc has no j, and passing a number here // would make morphSVG treat it as the last point of a series and fire the // chart's animation-completed hook. /** @type {any} */ (null), node._color, fromD, toD, dur, 0, ) } /** * Pie/donut-style intro: a clock sweep from startAngle to endAngle. Each * arc's end angle is clamped to the sweep line, so arcs appear in angular * order and grow until complete — the whole hierarchy unwipes together. * @param {any} node * @param {{a0:number,a1:number,iR:number,oR:number}} target * @param {number} dur * @param {number} gen layout generation; frames stop once superseded */ _sweepArc(node, target, dur, gen) { const el = node._el const br = this.cfg.borderRadius const s0 = this.startAngle const s1 = this.endAngle el.node.style.display = '' el.attr({ d: '', opacity: 1 }) el.animate(dur) .during((/** @type {number} */ pos) => { if (this._zoomGen !== gen) return const sweep = s0 + (s1 - s0) * pos if (sweep <= target.a0 + 0.01) { el.attr({ d: '' }) return } const a1 = Math.min(target.a1, sweep) el.attr({ d: this._arcPath(target.iR, target.oR, target.a0, a1, br) }) node._cur = { a0: target.a0, a1, iR: target.iR, oR: target.oR } }) .after(() => { if (this._zoomGen !== gen) return node._cur = target }) } /** * @param {any} node * @returns {any} svg.js path element */ _createArcEl(node) { const path = this._graphics.drawPath({ d: '', fill: node._color, stroke: this.strokeColor, strokeWidth: this.strokeWidth, fillOpacity: 1, classes: 'apexcharts-sunburst-arc', }) const el = path.node el.setAttribute('data:name', node.name) el.setAttribute('data:value', String(node.value)) // The branch's identity, normalized so a treemap computes the same string // for the same branch. A cross-type morph pairs on it, at every level. el.setAttribute('data:key', morphKey(node._key)) // Leaves are the level that corresponds to a flat partition (a treemap's // tiles), so the cross-type morph can pair the two up without handing a // tile its own ancestors. el.setAttribute( 'data:leaf', String(!(node.children && node.children.length)), ) this._attachTooltip(el, node) if (Environment.isBrowser()) { el.addEventListener('click', () => this._zoomTo(node)) el.style.cursor = 'pointer' } this._ringsG.add(path) return path } /** * @param {any} node * @param {{a0:number,a1:number,iR:number,oR:number}} from * @param {{a0:number,a1:number,iR:number,oR:number}} to * @param {number} dur * @param {boolean} hide shrink + fade out, then hide * @param {boolean} fadeIn fade 0 -> 1 (new arcs only; morphs stay opaque) * @param {number} gen layout generation; frames stop once superseded */ _animateArc(node, from, to, dur, hide, fadeIn, gen) { const el = node._el const br = this.cfg.borderRadius el.attr({ fill: node._color }) if (dur === 0) { el.attr({ d: this._arcPath(to.iR, to.oR, to.a0, to.a1, br), opacity: hide ? 0 : 1 }) el.node.style.display = hide ? 'none' : '' node._cur = hide ? null : to return } el.node.style.display = '' const startOp = hide ? Number(el.attr('opacity')) || 1 : fadeIn ? 0 : 1 const endOp = hide ? 0 : 1 el.attr({ opacity: startOp }) el.animate(dur) .during((/** @type {number} */ pos) => { if (this._zoomGen !== gen) return const a0 = lerp(from.a0, to.a0, pos) const a1 = lerp(from.a1, to.a1, pos) const iR = lerp(from.iR, to.iR, pos) const oR = lerp(from.oR, to.oR, pos) el.attr({ d: this._arcPath(iR, oR, a0, a1, br), opacity: lerp(startOp, endOp, pos), }) // Live geometry, so an interrupting zoom picks up exactly here. Even a // collapsing arc keeps it, so re-showing mid-collapse reverses smoothly. node._cur = { a0, a1, iR, oR } }) .after(() => { if (this._zoomGen !== gen) return if (hide) { el.node.style.display = 'none' node._cur = null } else { node._cur = to } }) } // ---------------------------------------------------------------- labels /** * Labels are overlays on animated paths, so they reveal gradually AFTER the * arcs settle (repo convention: overlays never pop in over a moving path). * @param {number} dur arc animation duration (0 = instant labels) */ _renderLabels(dur) { const labelsG = this._labelsG while (labelsG.node.firstChild) labelsG.node.removeChild(labelsG.node.firstChild) if (!this.cfg.dataLabels.show) return this._nodesAll.forEach((node) => { if (!node._show) return if (node._a1 - node._a0 < this.cfg.dataLabels.minAngleToShow) return this._renderCurvedLabel(node) }) if (dur > 0) { labelsG.attr({ opacity: 0 }) labelsG.animate(250, dur).attr({ opacity: 1 }) } else { labelsG.attr({ opacity: 1 }) } } /** * Curved label along the arc's mid-radius (flipped on the bottom half so it * stays upright). Raw SVG <textPath> — svg.js has no first-class textPath. * @param {any} node */ _renderCurvedLabel(node) { if (!Environment.isBrowser()) return const style = this.cfg.dataLabels.style const w = this.w const r = (node._iR + node._oR) / 2 const mid = (node._a0 + node._a1) / 2 const flip = mid > 90 && mid < 270 // Small angular padding so text does not touch the radial edges. const padDeg = Math.min((r > 0 ? (4 / r) * R2D : 0), (node._a1 - node._a0) / 2) const from = flip ? node._a1 - padDeg : node._a0 + padDeg const to = flip ? node._a0 + padDeg : node._a1 - padDeg const p1 = this._ptAt(r, from) const p2 = this._ptAt(r, to) const largeArc = Math.abs(to - from) > 180 ? 1 : 0 const sweep = flip ? 0 : 1 const id = `apx-sb-lbl-${w.globals.cuid}-${this._lblSeq++}` const guide = BrowserAPIs.createElementNS(SVGNS, 'path') guide.setAttribute('id', id) guide.setAttribute( 'd', `M ${p1.x} ${p1.y} A ${r} ${r} 0 ${largeArc} ${sweep} ${p2.x} ${p2.y}`, ) guide.setAttribute('fill', 'none') guide.setAttribute('stroke', 'none') const colors = style.colors const fill = (Array.isArray(colors) ? colors[0] : colors) || '#fff' const text = BrowserAPIs.createElementNS(SVGNS, 'text') text.setAttribute('font-size', style.fontSize || '12px') if (style.fontFamily) text.setAttribute('font-family', style.fontFamily) text.setAttribute('font-weight', String(style.fontWeight || 400)) text.setAttribute('fill', fill) text.setAttribute('dominant-baseline', 'central') text.style.pointerEvents = 'none' const tp = BrowserAPIs.createElementNS(SVGNS, 'textPath') tp.setAttribute('href', '#' + id) tp.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#' + id) tp.setAttribute('startOffset', '50%') tp.setAttribute('text-anchor', 'middle') tp.textContent = this._truncate(node.name, r, node._a1 - node._a0, style.fontSize) text.appendChild(tp) this._labelsG.node.appendChild(guide) this._labelsG.node.appendChild(text) } /** * Trim a label to the arc length available at its radius. * @param {string} name * @param {number} r * @param {number} spanDeg * @param {string} fontSize * @returns {string} */ _truncate(name, r, spanDeg, fontSize) { const arcLen = r * spanDeg * D2R - 8 const charW = (parseFloat(fontSize) || 12) * 0.58 const maxChars = Math.floor(arcLen / charW) if (maxChars >= name.length) return name if (maxChars <= 1) return '' return name.slice(0, Math.max(1, maxChars - 1)) + '…' } // ----------------------------------------------------------------- zoom /** * Focus a node (zoom in), or zoom out one level when the current focus (the * innermost ring) is clicked. * @param {any} node */ _zoomTo(node) { if (this.cfg.zoomOnClick === false) return const nextFocus = node === this._focus ? node._parent || null : node if (nextFocus === this._focus) return // A leaf with no children is not a meaningful focus target; zooming to it // would show a single full ring. Focus its parent branch instead. this._focus = nextFocus && !(nextFocus.children && nextFocus.children.length) ? nextFocus._parent || null : nextFocus this._relayout(this._focus) this._applyLayout('zoom') this._renderBreadcrumb() } /** Root -> focus chain of nodes. */ _focusChain() { const chain = [] let n = this._focus while (n) { chain.unshift(n) n = n._parent } return chain } /** * Minimal self-contained breadcrumb (reuses the shared `.apexcharts-breadcrumb` * CSS classes, but does NOT depend on the drilldown feature). */ _renderBreadcrumb() { if (!Environment.isBrowser()) return const w = this.w const elWrap = w.dom.elWrap if (!elWrap) return const existing = elWrap.querySelector('.apexcharts-breadcrumb') if (existing && existing.parentNode) existing.parentNode.removeChild(existing) if (!this._focus) return // only shown once zoomed in const nav = BrowserAPIs.createElementNS(XHTML, 'nav') nav.setAttribute('class', 'apexcharts-breadcrumb') nav.setAttribute('aria-label', 'Sunburst breadcrumb') nav.style.position = 'absolute' nav.style.top = '0px' nav.style.left = '0px' const crumbs = [{ name: 'All', node: null }].concat( this._focusChain().map((n) => ({ name: n.name, node: n })), ) crumbs.forEach((crumb, i) => { if (i > 0) { const sep = BrowserAPIs.createElementNS(XHTML, 'span') sep.setAttribute('class', 'apexcharts-breadcrumb-separator') sep.textContent = ' / ' nav.appendChild(sep) } const isCurrent = i === crumbs.length - 1 if (isCurrent) { const cur = BrowserAPIs.createElementNS(XHTML, 'span') cur.setAttribute( 'class', 'apexcharts-breadcrumb-item apexcharts-breadcrumb-current', ) cur.textContent = crumb.name nav.appendChild(cur) } else { const btn = BrowserAPIs.createElementNS(XHTML, 'button') btn.setAttribute('type', 'button') btn.setAttribute('class', 'apexcharts-breadcrumb-item') if (i === 0) { const arrow = BrowserAPIs.createElementNS(XHTML, 'span') arrow.setAttribute('class', 'apexcharts-breadcrumb-arrow') arrow.textContent = '←' btn.appendChild(arrow) } const text = BrowserAPIs.createElementNS(XHTML, 'span') text.setAttribute('class', 'apexcharts-breadcrumb-label') text.textContent = crumb.name btn.appendChild(text) btn.addEventListener('click', () => { this._focus = crumb.node this._relayout(this._focus) this._applyLayout('zoom') this._renderBreadcrumb() }) nav.appendChild(btn) } }) elWrap.appendChild(nav) this._avoidChromeOverlap(nav) } /** * The breadcrumb is an absolute overlay at top-left, so it can sit on top of * a left-aligned title (or subtitle). After mounting, push it below any chart * chrome it intersects. (Duplicated from drilldown's Breadcrumb on purpose — * sunburst must not import the drilldown feature.) * @param {any} nav */ /** * Shared with the treemap - see charts/common/Breadcrumb. * @param {any} nav */ _avoidChromeOverlap(nav) { avoidChromeOverlap(this.w, nav) } // ------------------------------------------------------------ geometry /** * @param {number} r * @param {number} deg 0 = top, clockwise * @returns {{x: number, y: number}} */ _ptAt(r, deg) { return { x: this.centerX + r * Math.cos((deg - 90) * D2R), y: this.centerY + r * Math.sin((deg - 90) * D2R), } } /** * Rounded donut-segment path (inner radius always > 0). Applies `spacing` * (angular gap) and `borderRadius` (corner rounding), both clamped so a thin * arc never inverts. * @param {number} iR * @param {number} oR * @param {number} a0 * @param {number} a1 * @param {number} borderRadius * @returns {string} */ _arcPath(iR, oR, a0, a1, borderRadius) { if (oR <= iR + 0.01) return '' let spanDeg = a1 - a0 const spacing = this.cfg.spacing if (spacing > 0 && spanDeg > 0 && oR > 0) { const gapDeg = (spacing / oR) * R2D const inset = Math.min(gapDeg / 2, Math.max(0, spanDeg / 2 - 0.25)) a0 += inset a1 -= inset spanDeg = a1 - a0 } if (spanDeg <= 0) return '' const spanRad = spanDeg * D2R const cx = this.centerX const cy = this.centerY let r = borderRadius r = Math.min(r, (spanRad * iR) / 2, (spanRad * oR) / 2, (oR - iR) / 2) if (!(r > 0.5)) { return sharpDonutSegmentPath({ cx, cy, rIn: iR, rOut: oR, a0, a1, spanDeg }) } return roundedDonutSegmentPath({ cx, cy, rIn: iR, rOut: oR, a0, a1, r, spanDeg }) } // ------------------------------------------------------------- tooltip /** @returns {any} */ _tip() { if (!this._tooltipEl) { this._tooltipEl = this.w.dom.baseEl.querySelector('.apexcharts-tooltip') } return this._tooltipEl } /** * @param {any} el * @param {any} node */ _attachTooltip(el, node) { if (!this.w.config.tooltip.enabled || !Environment.isBrowser()) return el.addEventListener('mouseenter', (/** @type {MouseEvent} */ e) => this._showTooltip(e, node), ) el.addEventListener('mousemove', (/** @type {MouseEvent} */ e) => this._positionTooltip(e), ) el.addEventListener('mouseleave', () => this._hideTooltip()) } /** * @param {MouseEvent} e * @param {any} node */ _showTooltip(e, node) { const t = this._tip() if (!t) return const w = this.w const pctTotal = ((node.value / this.total) * 100).toFixed(1) const parentVal = node._parent ? node._parent.value : this.total const pctParent = parentVal > 0 ? ((node.value / parentVal) * 100).toFixed(1) : pctTotal // With tooltip.fillSeriesColor the container is transparent by design and // the series-group is expected to carry the slice colour as its inline // background (like pie). Without it, the themed container provides the bg. const groupBg = w.config.tooltip.fillSeriesColor ? `background-color:${node._color};` : '' t.innerHTML = `<div class="apexcharts-tooltip-series-group apexcharts-active" style="display:flex;${groupBg}">` + `<span class="apexcharts-tooltip-marker" style="background-color:${node._color}"></span>` + `<div class="apexcharts-tooltip-text">` + `<div class="apexcharts-tooltip-y-group">` + `<span class="apexcharts-tooltip-text-y-label">${node.name}: </span>` + `<span class="apexcharts-tooltip-text-y-value">${node.value} (${pctParent}% of parent, ${pctTotal}% of total)</span>` + `</div></div></div>` t.classList.add('apexcharts-active') t.style.opacity = '1' this._positionTooltip(e) } /** * Position beside the cursor, flipping to the opposite side when the box * would overflow the chart wrap, and clamping inside it either way. * @param {MouseEvent} e */ _positionTooltip(e) { const t = this._tip() if (!t) return const rect = this.w.dom.elWrap.getBoundingClientRect() const tw = t.offsetWidth const th = t.offsetHeight const pad = 12 let x = e.clientX - rect.left + pad if (x + tw > rect.width) x = e.clientX - rect.left - tw - pad x = Math.max(0, Math.min(x, rect.width - tw)) let y = e.clientY - rect.top + pad if (y + th > rect.height) y = e.clientY - rect.top - th - pad y = Math.max(0, Math.min(y, rect.height - th)) t.style.left = x + 'px' t.style.top = y + 'px' } _hideTooltip() { const t = this._tip() if (!t) return t.classList.remove('apexcharts-active') t.style.opacity = '0' } // --------------------------------------------------------------- utils /** * @param {string|number} size * @param {number} max * @returns {number} */ _parseSize(size, max) { if (typeof size === 'number') return size const s = String(size).trim() if (s.endsWith('%')) return (parseFloat(s) / 100) * max const n = parseFloat(s) return isNaN(n) ? 0.15 * max : n } /** * Blend a hex colour toward white by `amount` (0..1). Non-hex returned as-is. * @param {string} color * @param {number} amount * @returns {string} */ _lighten(color, amount) { if (typeof color !== 'string' || color[0] !== '#') return color let hex = color.slice(1) if (hex.length === 3) { hex = hex.split('').map((c) => c + c).join('') } if (hex.length !== 6) return color const num = parseInt(hex, 16) if (isNaN(num)) return color let rC = (num >> 16) & 255 let gC = (num >> 8) & 255 let bC = num & 255 rC = Math.round(rC + (255 - rC) * amount) gC = Math.round(gC + (255 - gC) * amount) bC = Math.round(bC + (255 - bC) * amount) return '#' + ((1 << 24) + (rC << 16) + (gC << 8) + bC).toString(16).slice(1) } }