UNPKG

apexcharts

Version:

A JavaScript Chart Library

499 lines (460 loc) 18.6 kB
// @ts-check import Crossfilter from './Crossfilter' /** * Linked Views (#4): the per-chart glue for crossfilter, in two modes. * * HIGHLIGHT mode (P1): `chart.link.enabled` with NO `dimension`. Charts sharing * a `chart.group` form a set; brushing a rectangle range on a member that has * `chart.selection.enabled` dims every member's marks whose x falls outside the * range, in place (axes preserved, nothing re-renders, a CSS opacity class * cross-fades). x is the join, so no key config is needed. Best on discrete-mark * charts; pie/donut have no x-domain and are skipped. * * FILTER mode (P2): `chart.link.dimension` is a function (its presence selects * this path). Real crossfilter (crossfilter.js / dc.js style): each chart * declares a dimension + reduction over a shared record set registered with * `ApexCharts.crossfilter({ id, records })`. Clicking a pie slice / bar toggles * that bucket's key; the source self-dims the non-selected buckets, and every * other participating chart re-aggregates over the filtered subset and animates * to its new values via `updateSeries`. A chart never filters itself. * * Eager module (like history/perspectives): `ctx.linkedViews`, self-inert unless * a `chart.link` mode is active. It survives update() (Destroy keeps eager * modules on isUpdating), so it registers its dimension, injects the initial * aggregated series before the first paint, and subscribes to the coordinator * exactly once. Cross-chart fan-out is driven by the coordinator's `change` * event: each chart re-applies its own aggregation. * * @module modules/link/LinkedViews */ // Discrete data marks that carry `index` (seriesIndex) + `j` (dataPointIndex). const MARK_SELECTOR = [ '.apexcharts-bar-area', '.apexcharts-candlestick-area', '.apexcharts-boxPlot-area', '.apexcharts-rangebar-area', '.apexcharts-marker', ].join(', ') // Categorical filter-mode marks (pie/donut slices + bars) carry `j` = the // bucket's dataPointIndex, which maps to the aggregation's key order. const FILTER_MARK_SELECTOR = [ '.apexcharts-pie-area', '.apexcharts-bar-area', ].join(', ') const DIMMED_CLASS = 'apexcharts-crossfilter-dimmed' const PIE_TYPES = ['pie', 'donut', 'polarArea', 'radialBar'] export default class LinkedViews { /** * @param {import('../../types/internal').ChartStateW} w * @param {import('../../types/internal').ChartContext} ctx */ constructor(w, ctx) { this.w = w this.ctx = ctx this._dimmed = false // FILTER-mode state (engine path). this._wired = false this._pending = false /** @type {string|null} */ this._lastValues = null this._onPointSelect = this._onPointSelect.bind(this) this._afterRender = this._afterRender.bind(this) this._onChange = this._onChange.bind(this) // Set up the engine path before the first render. Constructed in the // ApexCharts constructor, so mutating w.config.series here reaches create(). if (this._mode() === 'filter') this._initEngine() } /** @returns {'highlight'|'filter'|'off'} */ _mode() { const link = this.w.config.chart.link if (link && typeof link.dimension === 'function') return 'filter' if (link && link.enabled) return 'highlight' return 'off' } _enabled() { const link = this.w.config.chart.link return !!(link && link.enabled) } /** * The source chart's rectangle brush produced a data-x range. In FILTER mode * this becomes a `[min,max]` range filter on the chart's dimension (the other * charts re-aggregate). In HIGHLIGHT mode (P1) it dims out-of-range marks * across the group. Called (null-safe) from ZoomPanSelection selectionDrawn / * selectionDragging. * @param {{min:number, max:number}} xaxis */ onSourceSelection(xaxis) { const mode = this._mode() if (mode === 'off') return if (!xaxis || xaxis.min == null || xaxis.max == null) return let min = Math.min(xaxis.min, xaxis.max) let max = Math.max(xaxis.min, xaxis.max) // Snap to the plot's data bounds when the brush reaches an edge. The brush // clamps to the axis extent, but the pixel->data round-trip can leave min a // few units above globals.minX (or max below maxX), which would wrongly dim // the very first / last mark the brush visibly covers ("can't select past // the first column"). The tolerance is a tiny fraction of the range, far // smaller than the gap between marks, so a genuine near-edge selection (one // that should exclude the boundary mark) is left untouched. const gMinX = this.w.globals.minX const gMaxX = this.w.globals.maxX if (isFinite(gMinX) && isFinite(gMaxX) && gMaxX > gMinX) { const tol = (gMaxX - gMinX) * 1e-6 if (min - gMinX <= tol) min = gMinX if (gMaxX - max <= tol) max = gMaxX } if (mode === 'filter') { const cf = this._cf() if (!cf) return cf.filter(this._chartId(), [min, max]) // emits 'change' -> fan-out this._fireFilterChange(cf, [min, max]) return } // highlight mode (P1) this._group().forEach((ch) => { ch?.linkedViews?.applyDim(min, max) }) const args = { xaxis: { min, max }, sourceChartID: this.w.globals.chartID } if (typeof this.w.config.chart.events.crossFilter === 'function') { this.w.config.chart.events.crossFilter(this.ctx, args) } // fireEvent applies an args ARRAY to addEventListener handlers, so pass // [chart, opts] to match the (chart, opts) signature used elsewhere. this.ctx.events?.fireEvent('crossFilter', [this.ctx, args]) } /** self + grouped siblings (dedup-safe; getGroupedCharts excludes self). */ _group() { const siblings = typeof this.ctx.getGroupedCharts === 'function' ? this.ctx.getGroupedCharts() : [] return [this.ctx, ...siblings] } /** * Dim this chart's marks whose x is outside [min,max]; un-dim those inside. * No re-render, so mark identities are preserved. * @param {number} min @param {number} max */ applyDim(min, max) { if (!this._enabled()) return const w = this.w const baseEl = w.dom.baseEl if (!baseEl) return const dimOpacity = w.config.chart.link.dimOpacity if (w.dom.elWrap && typeof dimOpacity === 'number') { w.dom.elWrap.style.setProperty('--apx-cf-dim', String(dimOpacity)) } const seriesX = w.globals.seriesX || [] const marks = baseEl.querySelectorAll(MARK_SELECTOR) marks.forEach((node) => { const jAttr = node.getAttribute('j') if (jAttr === null) return const j = parseInt(jAttr, 10) const iAttr = node.getAttribute('index') const i = iAttr === null ? 0 : parseInt(iAttr, 10) const row = seriesX[i] || seriesX[0] if (!row) return const x = row[j] if (x == null) return node.classList.toggle(DIMMED_CLASS, x < min || x > max) }) this._dimmed = true } /** Remove dimming from this chart only. */ clear() { const baseEl = this.w.dom.baseEl if (!baseEl) return baseEl .querySelectorAll('.' + DIMMED_CLASS) .forEach((n) => n.classList.remove(DIMMED_CLASS)) this._dimmed = false } /** Clear dimming across the whole group (backs chart.clearCrossfilter). */ clearGroup() { if (this._mode() === 'filter') { const cf = this._cf() if (cf) cf.reset() // emits 'change' -> every subscribed chart re-applies return } this._group().forEach((ch) => ch?.linkedViews?.clear()) } // ─── FILTER mode (crossfilter engine glue) ─────────────────────────────── /** * The chart's stable internal id (keys its dimension in the coordinator). * Always set by the ApexCharts constructor (falls back to a cuid). * @returns {string} */ _chartId() { return /** @type {string} */ (this.w.globals.chartID) } /** @returns {import('./Crossfilter').default|null} the coordinator, or null */ _cf() { const link = this.w.config.chart.link const id = link && (link.id || this.w.config.chart.group) return id ? Crossfilter.get(id) : null } _isPie() { return PIE_TYPES.indexOf(this.w.config.chart.type) !== -1 } _isHeatmap() { return this.w.config.chart.type === 'heatmap' } /** * Before the first render: resolve the coordinator, register this chart's * dimension, inject the initial aggregated series into w.config (so the first * paint is already aggregated, no empty flash), and wire the listeners. */ _initEngine() { const cf = this._cf() const link = this.w.config.chart.link if (!cf) { const id = (link && link.id) || this.w.config.chart.group console.warn( `[apexcharts] chart.link.dimension is set but no crossfilter coordinator "${id}" exists. Call ApexCharts.crossfilter({ id, records }) before creating the chart.`, ) return } const chartId = this._chartId() if (!cf.hasDimension(chartId)) { cf.registerDimension(chartId, { dimension: link.dimension, reduce: link.reduce, // heatmap => 2D matrix dimension (accessor returns [xKey, yKey]). type: link.type || (this._isHeatmap() ? 'matrix' : undefined), bins: link.bins, order: link.order, }) } this._injectSeries(cf.aggregateFor(chartId)) this._wire(cf) } /** * Build the chart's series value from an aggregation, shaped by chart type: * matrix (heatmap) -> [{ name:yKey, data:[{x:xKey, y:value}] }] * pie/donut -> number[] * axis + category -> [{ name, data:number[] }] (categories set separately) * axis + range -> [{ name, data:[x,value][] }] on a numeric/time x-axis * @param {any} agg */ _seriesFromAgg(agg) { if (agg.type === 'matrix') { return agg.yLabels.map((/** @type {any} */ yl, /** @type {number} */ yi) => ({ name: String(yl), data: agg.xLabels.map((/** @type {any} */ xl, /** @type {number} */ xi) => ({ x: String(xl), y: agg.matrix[yi][xi], })), })) } if (this._isPie()) return agg.values.slice() const name = this.w.config.chart.link.seriesName || 'Count' if (agg.type === 'range') { return [{ name, data: agg.labels.map((/** @type {any} */ x, /** @type {number} */ i) => [x, agg.values[i]]) }] } return [{ name, data: agg.values.slice() }] } /** * Value signature used to skip a reflow when only dimming changed. * @param {any} agg */ _sigOf(agg) { return JSON.stringify(agg.matrix || agg.values) } /** * Write the aggregation into w.config as the chart's series/labels. Runs once * before the first paint; later updates go through updateSeries. * @param {any} agg */ _injectSeries(agg) { const w = this.w this._lastValues = this._sigOf(agg) w.config.series = this._seriesFromAgg(agg) if (agg.type === 'matrix') return // heatmap x/y come from the series data if (this._isPie()) { w.config.labels = agg.labels.map(String) } else if (agg.type === 'category') { if (!w.config.xaxis) w.config.xaxis = {} w.config.xaxis.categories = agg.labels.map(String) } else if (agg.type === 'range') { // Range dims plot bars at bin CENTERS on a numeric/datetime x-axis, so the // series' own x-extent is [firstCenter, lastCenter] and the outer half-bin // on each side would be off-axis: the first/last bins could never be // brushed in full and their records could never be included/excluded // correctly. Pin the axis domain to the TRUE histogram extent (the outer // bin edges) so the full plot width maps to the full data range. An // explicit user xaxis.min/max always wins. this._pinRangeDomain(agg.edges) } } /** * Pin the numeric/datetime x-axis to the outer bin edges of a range-binned * dimension (unless the user set xaxis.min/max explicitly). See _injectSeries. * @param {number[]|null|undefined} edges */ _pinRangeDomain(edges) { if (!Array.isArray(edges) || edges.length < 2) return const w = this.w if (!w.config.xaxis) w.config.xaxis = /** @type {any} */ ({}) if (w.config.xaxis.min == null) w.config.xaxis.min = edges[0] if (w.config.xaxis.max == null) w.config.xaxis.max = edges[edges.length - 1] } /** @param {import('./Crossfilter').default} cf */ _wire(cf) { if (this._wired) return this._wired = true // Click-to-filter on this chart's own marks (coexists with any user // dataPointSelection handler; both fire via Graphics.pathMouseDown). this.ctx.addEventListener('dataPointSelection', this._onPointSelect) // Re-apply self-dim after each (re)render (new DOM nodes lose the class). this.ctx.addEventListener('mounted', this._afterRender) this.ctx.addEventListener('updated', this._afterRender) // Any filter change on the shared coordinator re-aggregates this chart. cf.on('change', this._onChange) } /** * A pie slice / bar was clicked: toggle its bucket key on the coordinator. * @param {any} _e @param {any} _ctx @param {{dataPointIndex?:number}} opts */ _onPointSelect(_e, _ctx, opts) { if (this._mode() !== 'filter' || !opts || opts.dataPointIndex == null) return const cf = this._cf() if (!cf) return const chartId = this._chartId() const agg = cf.aggregateFor(chartId) // Phase A: heatmap (matrix) is a re-aggregating target only; cell // click-to-filter is Phase B. Ignore clicks safely. if (agg.type === 'matrix') return const key = agg.keys[opts.dataPointIndex] if (key == null) return cf.toggleKey(chartId, key) // emits 'change' -> fan-out (deferred per chart) this._fireFilterChange(cf, key) } /** Coordinator filter changed: re-aggregate this chart on a microtask so the * triggering click handler unwinds before we destroy/redraw the DOM. */ _onChange() { if (this._mode() !== 'filter' || this._pending) return this._pending = true Promise.resolve().then(() => { this._pending = false if (this.w.globals.isDestroyed) return this._applyAggregation() }) } /** * Pull this chart's crossfilter aggregation and push it through updateSeries * (animated). When the values are unchanged (e.g. only this chart's own * filter moved, which it ignores for itself), skip the reflow and just * refresh the self-dim. */ _applyAggregation() { if (this._mode() !== 'filter') return const cf = this._cf() if (!cf) return const agg = cf.aggregateFor(this._chartId()) const sig = this._sigOf(agg) if (sig === this._lastValues) { this._applySelfDim() // data unchanged: only the dimming may differ return } this._lastValues = sig this.ctx.updateSeries(this._seriesFromAgg(agg), true) // updateSeries re-renders -> 'updated' -> _afterRender applies self-dim. } _afterRender() { if (this._mode() !== 'filter') return // A wrapper that owns the `series` prop (react-apexcharts pushes it via // updateSeries right after mount, vue-apexcharts on any prop sync) can // clobber the injected aggregation with the empty placeholder series. // Filter-mode series are derived from the crossfilter, never user data, // so re-assert the aggregation instead of painting an empty chart. const series = this.w.config.series if (!series || series.length === 0) { this._reassertSeries() return } this._applySelfDim() } /** Restore the aggregated series after an external updateSeries emptied it. * Deferred a microtask so the triggering update fully unwinds first. */ _reassertSeries() { if (this._pending) return this._pending = true Promise.resolve().then(() => { this._pending = false if (this.w.globals.isDestroyed) return const cf = this._cf() if (!cf) return const agg = cf.aggregateFor(this._chartId()) const series = this._seriesFromAgg(agg) if (!series.length) return // empty record set: nothing to restore this._lastValues = this._sigOf(agg) this.ctx.updateSeries(series, true) }) } /** * Dim this chart's own buckets that are not in its own filter (no filter -> * none dimmed). Categorical: dim buckets whose key is not in the selected Set. * Range: dim bins lying fully outside the selected `[min,max]`. Keyed by each * mark's `j` (dataPointIndex) -> the aggregation key. */ _applySelfDim() { const cf = this._cf() if (!cf) return const w = this.w const baseEl = w.dom.baseEl if (!baseEl) return const chartId = this._chartId() const filter = cf.filterOf(chartId) // Set (category) | [min,max] (range) | null const dimOpacity = w.config.chart.link.dimOpacity if (w.dom.elWrap && typeof dimOpacity === 'number') { w.dom.elWrap.style.setProperty('--apx-cf-dim', String(dimOpacity)) } const isCategory = filter instanceof Set const isRange = Array.isArray(filter) const keys = cf.aggregateFor(chartId).keys baseEl.querySelectorAll(FILTER_MARK_SELECTOR).forEach((node) => { const jAttr = node.getAttribute('j') if (jAttr === null) return const key = keys[parseInt(jAttr, 10)] let dim = false if (isCategory) { dim = !(/** @type {Set<any>} */ (filter).has(key)) } else if (isRange && Array.isArray(key)) { // key = [lo, hi] bin range; dim when the bin is fully outside [min,max]. dim = key[1] <= filter[0] || key[0] >= filter[1] } node.classList.toggle(DIMMED_CLASS, dim) }) this._dimmed = !!filter } /** * Fire the `filterChange` event on this (source) chart. * @param {import('./Crossfilter').default} cf @param {any} key */ _fireFilterChange(cf, key) { const args = { ...cf.state(), sourceChartID: this._chartId(), key, } const events = this.w.config.chart.events if (typeof events.filterChange === 'function') { events.filterChange(this.ctx, args) } this.ctx.events?.fireEvent('filterChange', [this.ctx, args]) } teardown() { this.clear() if (this._wired) { this.ctx.removeEventListener?.('dataPointSelection', this._onPointSelect) this.ctx.removeEventListener?.('mounted', this._afterRender) this.ctx.removeEventListener?.('updated', this._afterRender) const cf = this._cf() if (cf) { cf.off('change', this._onChange) cf.removeDimension(this._chartId()) } this._wired = false } } }