UNPKG

apexcharts

Version:

A JavaScript Chart Library

254 lines (244 loc) 9.41 kB
// @ts-check /** * Shared hierarchy resolver for the partition charts (sunburst, treemap). * * Both charts answer the same question before they can draw anything: given a * config, what is the tree? The two accepted shapes are * * 1. native nesting - a datum carries `children: [...]`, to any depth * 2. the drilldown adapter - a datum carries `drilldown: '<id>'` naming an * entry in `config.drilldown.series`, read here as plain data (NO * dependency on the drilldown runtime feature, so a chart that only wants * a nested view does not pull the feature in) * * A node is `{ name, value, color?, _key, children? }`. `_key` is the node's * identity across data updates: the path of names, indexed so same-named * siblings stay distinct, which is what update animations match on. * * `value` is left as authored here; call `fillValues` to roll a missing parent * value up from its children. * * This module is deliberately NOT registered in `sharedModules` (vite.config.mjs). * It is a handful of pure functions, so each split bundle carrying a partition * chart is welcome to its own copy; registering it would be a second name in a * shared module for no gain. * * @module charts/common/Hierarchy */ /** * Look up a drilldown series by id. * @param {any} w * @param {string|number} id * @returns {any} */ export function drilldownById(w, id) { const dd = w.config.drilldown const list = dd && Array.isArray(dd.series) ? dd.series : [] return list.find((/** @type {any} */ s) => s && s.id === id) } /** * Resolve one datum (and everything under it) into a node. * * @param {any} w * @param {any} d the datum: an object (`{x|name, y|value, children?, drilldown?}`) * or a bare number * @param {number} i index among its siblings * @param {string[]|null} paletteFromParent per-level colours from a drilldown entry * @param {string} parentKey hierarchical identity of the parent * @param {Set<any>|null} [seenIds] drilldown ids already expanded on this path * @param {{ keepDatum?: boolean, expandDrilldown?: boolean }} [opts] * `keepDatum` attaches the source datum as `_datum` (the treemap needs it to * read a per-datum colour metric). Off by default so the node shape stays * exactly what the sunburst has always seen. * `expandDrilldown` (default true) reads `drilldown: '<id>'` as another level. * The treemap turns this off unless asked: there, a `drilldown` id has always * meant "descend on click" via the drilldown feature, and silently rendering * those levels nested instead would change what existing charts draw. * @returns {any} */ export function toNode( w, d, i, paletteFromParent, parentKey, seenIds = null, opts = {}, ) { const isObj = d && typeof d === 'object' const name = isObj ? (d.x ?? d.name ?? '') : '' const value = isObj ? Number(d.y ?? d.value) : Number(d) /** @type {any} */ const node = { name: String(name), value: isNaN(value) ? null : value, color: isObj && d.color ? d.color : undefined, // Identity across data updates: the path of names (indexed so same-named // siblings stay distinct). Update animations morph matched keys in place. _key: `${parentKey}/${i}:${name}`, } if (paletteFromParent && !node.color) { node.color = paletteFromParent[i % paletteFromParent.length] } if (opts.keepDatum) node._datum = d if (isObj && Array.isArray(d.children) && d.children.length) { node.children = d.children.map( (/** @type {any} */ c, /** @type {number} */ j) => toNode(w, c, j, null, node._key, seenIds, opts), ) } else if (isObj && d.drilldown != null && opts.expandDrilldown !== false) { // Drilldown ids resolve indirectly, so a self- or mutually-referential id // (a malformed config) would recurse forever and overflow the stack. Track // the ids expanded on this path and stop when one repeats. const visited = seenIds || new Set() if (!visited.has(d.drilldown)) { const dd = drilldownById(w, d.drilldown) if (dd && Array.isArray(dd.data) && dd.data.length) { const nextSeen = new Set(visited) nextSeen.add(d.drilldown) const palette = Array.isArray(dd.colors) ? dd.colors : null node.children = dd.data.map( (/** @type {any} */ c, /** @type {number} */ j) => toNode(w, c, j, palette, node._key, nextSeen, opts), ) } } } return node } /** * Sunburst root set: the whole chart is one tree, so the roots are the data of * the first series (or the bare array when the config skips the series wrapper). * * @param {any} w * @param {{ keepDatum?: boolean, expandDrilldown?: boolean }} [opts] * @returns {any[]} */ export function buildHierarchy(w, opts = {}) { const cfgSeries = /** @type {any} */ (w.config.series) const first = cfgSeries && cfgSeries[0] const data = first && Array.isArray(first.data) ? first.data : cfgSeries if (!Array.isArray(data)) return [] return data.map((/** @type {any} */ d, /** @type {number} */ i) => toNode(w, d, i, null, '', null, opts), ) } /** * Treemap root set: every series is a level-0 group, so each series becomes a * node whose children are its data. This is the shape the flat two-level * treemap has always drawn, expressed as a tree, which is what lets one * recursive layout serve both the flat and the nested case. * * @param {any} w * @param {any[]} [series] defaults to `w.config.series` * @param {{ keepDatum?: boolean, expandDrilldown?: boolean }} [opts] * @returns {any[]} */ export function buildSeriesRoots(w, series, opts = {}) { const cfgSeries = /** @type {any} */ (series || w.config.series) if (!Array.isArray(cfgSeries)) return [] return cfgSeries.map((/** @type {any} */ s, /** @type {number} */ i) => { const data = s && Array.isArray(s.data) ? s.data : [] const key = `${i}:${s?.name ?? ''}` /** @type {any} */ const root = { name: String(s?.name ?? ''), value: null, color: s?.color || undefined, _key: key, _seriesIndex: i, children: data.map((/** @type {any} */ d, /** @type {number} */ j) => toNode(w, d, j, null, key, null, opts), ), } return root }) } /** * Fill a parent's value from its children when missing. * @param {any} node */ export function fillValues(node) { if (node.children && node.children.length) { node.children.forEach((/** @type {any} */ c) => fillValues(c)) if (node.value == null || isNaN(node.value)) { node.value = node.children.reduce( (/** @type {number} */ s, /** @type {any} */ c) => s + Math.max(0, c.value || 0), 0, ) } } if (node.value == null || isNaN(node.value)) node.value = 0 } /** * True when any node in the config carries a nested shape, i.e. the chart has * more than the two levels a flat series array describes. Cheap enough to run * on every parse and the only thing that decides whether a treemap takes the * nested path. * * @param {any[]} series * @param {{ drilldown?: boolean }} [opts] `drilldown` (default true) counts a * `drilldown` id as nesting. See `toNode`'s `expandDrilldown`. * @returns {boolean} */ export function hasNesting(series, opts = {}) { if (!Array.isArray(series)) return false const countDrilldown = opts.drilldown !== false for (let i = 0; i < series.length; i++) { const data = series[i] && series[i].data if (!Array.isArray(data)) continue for (let j = 0; j < data.length; j++) { const d = data[j] if (!d || typeof d !== 'object') continue if (Array.isArray(d.children) && d.children.length) return true if (countDrilldown && d.drilldown != null) return true } } return false } /** * A node's identity for CROSS-CHART pairing, i.e. the same branch seen by a * treemap and by a sunburst. * * The two build their trees from the same config but not from the same root: * `buildHierarchy` (sunburst) starts at `series[0].data`, so its keys read * `/0:Tech/0:Software`; `buildSeriesRoots` (treemap) starts one level higher, * at the series itself, so the same branch reads `0:Market/0:Tech/0:Software`. * The paths agree from the first separator onward, which is exactly the part * that describes the data rather than the wrapper. * * Dropping the leading segment therefore gives one key both charts compute * independently, which is what lets a morph pair marks at every level instead * of only at the leaves. A series root itself normalizes to '' - correctly, as * it is the level a sunburst has no counterpart for. * * @param {string} key * @returns {string} */ export function morphKey(key) { if (typeof key !== 'string') return '' const i = key.indexOf('/') return i === -1 ? '' : key.slice(i) } /** * Depth-first walk over a root set. `fn` receives `(node, depth, parent)`. * @param {any[]} roots * @param {(node: any, depth: number, parent: any) => void} fn */ export function eachNode(roots, fn) { /** * @param {any} node * @param {number} depth * @param {any} parent */ const walk = (node, depth, parent) => { fn(node, depth, parent) if (node.children) { for (let i = 0; i < node.children.length; i++) { walk(node.children[i], depth + 1, node) } } } for (let i = 0; i < roots.length; i++) walk(roots[i], 0, null) }