UNPKG

apexcharts

Version:

A JavaScript Chart Library

727 lines (673 loc) 22.9 kB
// @ts-check /** * Crossfilter engine (Linked Views #4, Phase 2 / P2.1). * * The pure aggregation core behind `ApexCharts.crossfilter(...)`. It holds one * shared record set and, per participating chart, a DIMENSION (row -> key), a * REDUCTION (rows -> scalar), and a FILTER (a Set of selected categorical keys, * or a numeric `[min,max]` range). * * Crossfilter semantics: a chart's aggregation is computed over the records that * pass EVERY OTHER chart's filter (a chart never filters itself), so each chart * always shows "what else is available". A chart's own filter only marks which * of its own buckets are selected (used by the glue layer to self-dim). * * This module is intentionally free of DOM and browser globals (only the * `globalThis` id-registry, mirroring `__apexcharts_registry__`), so the * aggregation math is unit-testable in isolation. Chart wiring (reading * `chart.link`, injecting series, redrawing siblings) lives in LinkedViews and * is layered on top in later phases. * * @module modules/link/Crossfilter */ const REGISTRY_KEY = '__apexcharts_crossfilters__' /** * Strip floating-point noise (0.30000000000000004 -> 0.3) without wrecking * large magnitudes such as timestamps. * @param {number} x * @returns {number} */ function cleanFloat(x) { if (!Number.isFinite(x)) return x const n = Number(x.toPrecision(12)) return Object.is(n, -0) ? 0 : n } /** * Is v a usable finite numeric value for range bucketing/filtering. * @param {*} v * @returns {v is number} */ function isNum(v) { return typeof v === 'number' && Number.isFinite(v) } /** * Normalize a `reduce` spec into a `(rows) => number` reducer. * @param {'count'|{sum?:string,avg?:string,min?:string,max?:string}|((rows:any[])=>number)|undefined} reduce * @returns {(rows:any[])=>number} */ function makeReducer(reduce) { if (typeof reduce === 'function') return reduce if (reduce && typeof reduce === 'object') { if (typeof reduce.sum === 'string') { const f = reduce.sum return (rows) => rows.reduce((a, r) => a + (Number(r[f]) || 0), 0) } if (typeof reduce.avg === 'string') { const f = reduce.avg return (rows) => rows.length ? rows.reduce((a, r) => a + (Number(r[f]) || 0), 0) / rows.length : 0 } if (typeof reduce.min === 'string') { const f = reduce.min return (rows) => rows.length ? Math.min(...rows.map((r) => Number(r[f]) || 0)) : 0 } if (typeof reduce.max === 'string') { const f = reduce.max return (rows) => rows.length ? Math.max(...rows.map((r) => Number(r[f]) || 0)) : 0 } } // 'count' (default) return (rows) => rows.length } /** * Order a discovered key list: 'first-seen' (default), 'asc', 'desc', or a * comparator fn. * @param {any[]} keys * @param {'first-seen'|'asc'|'desc'|((a:any,b:any)=>number)|undefined} order * @returns {any[]} */ function applyOrder(keys, order) { if (typeof order === 'function') return keys.slice().sort(order) if (order === 'asc') return keys.slice().sort((a, b) => (a > b ? 1 : a < b ? -1 : 0)) if (order === 'desc') return keys.slice().sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)) return keys // first-seen (data order) } /** * Discover the ordered, distinct categorical keys over the full record set. * @param {any[]} records * @param {(row:any)=>any} accessor * @param {'first-seen'|'asc'|'desc'|((a:any,b:any)=>number)|undefined} order * @returns {any[]} */ function categoryDomain(records, accessor, order) { const seen = new Set() const keys = [] for (let i = 0; i < records.length; i++) { const k = accessor(records[i]) if (k == null) continue if (!seen.has(k)) { seen.add(k) keys.push(k) } } return applyOrder(keys, order) } /** * Discover the ordered, distinct x and y keys for a 2D (matrix) dimension whose * accessor returns `[xKey, yKey]`. * @param {any[]} records * @param {(row:any)=>any} accessor * @param {any} order * @returns {{xLabels:any[], yLabels:any[]}} */ function matrixDomain(records, accessor, order) { const xSeen = new Set() const ySeen = new Set() const xLabels = [] const yLabels = [] for (let i = 0; i < records.length; i++) { const pair = accessor(records[i]) if (!pair) continue const x = pair[0] const y = pair[1] if (x != null && !xSeen.has(x)) { xSeen.add(x) xLabels.push(x) } if (y != null && !ySeen.has(y)) { ySeen.add(y) yLabels.push(y) } } return { xLabels: applyOrder(xLabels, order), yLabels: applyOrder(yLabels, order) } } /** * Compute bin edges for a numeric/range dimension over the full record set. * Supports `{ thresholds }`, `{ width }`, or `{ count }` (default count: 30). * @param {any[]} records * @param {(row:any)=>any} accessor * @param {{width?:number,count?:number,thresholds?:number[]}|undefined} bins * @returns {number[]} edges, length = nBins + 1 (ascending) */ function rangeEdges(records, accessor, bins) { if (bins && Array.isArray(bins.thresholds) && bins.thresholds.length >= 2) { const t = Array.from(new Set(bins.thresholds.filter(isNum))).sort( (a, b) => a - b, ) return t.length >= 2 ? t.map(cleanFloat) : [0, 1] } let min = Infinity let max = -Infinity for (let i = 0; i < records.length; i++) { const v = accessor(records[i]) if (!isNum(v)) continue if (v < min) min = v if (v > max) max = v } if (min === Infinity) return [0, 1] // no numeric values if (min === max) { // Degenerate extent: a single bin around the lone value. const pad = Math.abs(min) > 0 ? Math.abs(min) : 1 return [cleanFloat(min), cleanFloat(min + pad)] } if (bins && isNum(bins.width) && bins.width > 0) { const w = bins.width const start = Math.floor(min / w) * w let end = Math.ceil(max / w) * w if (end <= start) end = start + w const count = Math.max(1, Math.round((end - start) / w)) const edges = new Array(count + 1) for (let i = 0; i <= count; i++) edges[i] = cleanFloat(start + i * w) return edges } const count = bins && isNum(bins.count) && bins.count >= 1 ? Math.floor(bins.count) : 30 const w = (max - min) / count const edges = new Array(count + 1) for (let i = 0; i <= count; i++) edges[i] = cleanFloat(min + i * w) edges[count] = cleanFloat(max) // pin the top edge exactly return edges } /** * Bucket index of v within ascending `edges` (last bin inclusive of the top * edge). Returns -1 when v is outside the edge range or not numeric. * @param {*} v @param {number[]} edges */ function binIndexOf(v, edges) { if (!isNum(v)) return -1 const last = edges.length - 1 if (v < edges[0] || v > edges[last]) return -1 if (v === edges[last]) return last - 1 for (let i = 0; i < last; i++) { if (v >= edges[i] && v < edges[i + 1]) return i } return -1 } /** * Bin CENTER x-values from ascending `edges` (length = nBins). A histogram bar * on a numeric axis is centered on its x-value, so plotting the center (not the * lower edge) makes the bar visually span its bin `[edges[i], edges[i + 1]]`. * That keeps the bars aligned with the brush rectangle and with the range-dim * test, which compares the same true bin range against the selected `[min,max]`. * @param {number[]} edges * @returns {number[]} */ function binCenters(edges) { const centers = [] for (let i = 0; i < edges.length - 1; i++) { centers.push(cleanFloat((edges[i] + edges[i + 1]) / 2)) } return centers } export default class Crossfilter { /** * @param {string} id * @param {any[]} [records] */ constructor(id, records) { this.id = id /** @type {any[]} */ this.records = Array.isArray(records) ? records : [] /** * chartId -> dimension descriptor * `{ accessor, reducer, type, bins, order, filter, labels, edges }`. * Typed loosely (the slots are mutated in place, like the rest of w-state). * @type {Map<string, any>} */ this.dims = new Map() /** @type {Map<string, Set<Function>>} */ this._listeners = new Map() } // ----- registry --------------------------------------------------------- /** @returns {Map<string, Crossfilter>} */ static _store() { const g = /** @type {any} */ (globalThis) if (!g[REGISTRY_KEY]) g[REGISTRY_KEY] = new Map() return g[REGISTRY_KEY] } /** * Get-or-create a coordinator by id. Passing `records` on an existing * coordinator swaps its dataset (re-aggregates). * @param {{id:string, records?:any[]}} opts * @returns {Crossfilter} */ static getOrCreate(opts) { if (!opts || typeof opts.id !== 'string') { throw new Error('ApexCharts.crossfilter requires an { id } string.') } const store = Crossfilter._store() let cf = store.get(opts.id) if (cf) { if (opts.records) cf.setRecords(opts.records) return cf } cf = new Crossfilter(opts.id, opts.records) store.set(opts.id, cf) return cf } /** @param {string} id @returns {Crossfilter|null} */ static get(id) { return Crossfilter._store().get(id) || null } // ----- data + dimensions ------------------------------------------------ /** * Swap the shared dataset and recompute every dimension's domain. Existing * filters are kept where still valid (categorical keys no longer present are * pruned); the change is broadcast. * @param {any[]} records */ setRecords(records) { this.records = Array.isArray(records) ? records : [] this.dims.forEach((dim) => this._recomputeDomain(dim)) this._emit('records', this.state()) this._emit('change', this.state()) return this } /** * Register (or replace) a chart's dimension + reduction. * @param {string} chartId * @param {{ * dimension:(row:any)=>any, reduce?:any, type?:'category'|'range', * bins?:{width?:number,count?:number,thresholds?:number[]}, * order?:'first-seen'|'asc'|'desc'|((a:any,b:any)=>number), * filter?:any }} spec */ registerDimension(chartId, spec) { if (!spec || typeof spec.dimension !== 'function') { throw new Error( `crossfilter.registerDimension("${chartId}") needs a dimension function.`, ) } const type = spec.type || (spec.bins ? 'range' : 'category') const dim = { accessor: spec.dimension, reducer: makeReducer(spec.reduce), type, bins: spec.bins, order: spec.order, /** @type {Set<any>|[number,number]|null} */ filter: null, /** @type {any[]} */ labels: [], /** @type {number[]|null} */ edges: null, } this.dims.set(chartId, dim) this._recomputeDomain(dim) if (spec.filter != null) this._setFilterOn(dim, spec.filter) return this } /** @param {string} chartId @returns {boolean} */ hasDimension(chartId) { return this.dims.has(chartId) } /** @param {string} chartId */ removeDimension(chartId) { this.dims.delete(chartId) return this } /** @param {any} dim */ _recomputeDomain(dim) { if (dim.type === 'matrix') { const dom = matrixDomain(this.records, dim.accessor, dim.order) dim.xLabels = dom.xLabels dim.yLabels = dom.yLabels dim.edges = null return } if (dim.type === 'range') { dim.edges = rangeEdges(this.records, dim.accessor, dim.bins) dim.labels = binCenters(dim.edges) // bin centers (bar x-position) } else { dim.labels = categoryDomain(this.records, dim.accessor, dim.order) dim.edges = null // Prune categorical filter keys that no longer exist in the domain. if (dim.filter instanceof Set) { const domain = new Set(dim.labels) Array.from(dim.filter).forEach((k) => { if (!domain.has(k)) dim.filter.delete(k) }) } } } // ----- filters ---------------------------------------------------------- /** * Set (replace) a chart's filter. Categorical: an array/Set of keys (or null * to clear). Range: a `[min,max]` tuple (or null to clear). * @param {string} chartId * @param {any[]|Set<any>|[number,number]|null} filter */ filter(chartId, filter) { const dim = this.dims.get(chartId) if (!dim) return this this._setFilterOn(dim, filter) this._emit('change', this.state()) return this } /** * Toggle one categorical key in a chart's filter Set (multi-select, OR). * @param {string} chartId @param {any} key */ toggleKey(chartId, key) { const dim = this.dims.get(chartId) if (!dim || dim.type !== 'category') return this if (!(dim.filter instanceof Set)) dim.filter = new Set() const set = /** @type {Set<any>} */ (dim.filter) if (set.has(key)) set.delete(key) else set.add(key) if (set.size === 0) dim.filter = null this._emit('change', this.state()) return this } /** @param {any} dim @param {any} filter */ _setFilterOn(dim, filter) { if (filter == null) { dim.filter = null return } if (dim.type === 'range') { if (Array.isArray(filter) && filter.length === 2 && filter.every(isNum)) { dim.filter = [Math.min(filter[0], filter[1]), Math.max(filter[0], filter[1])] } else { dim.filter = null } return } // categorical const set = filter instanceof Set ? new Set(filter) : new Set(filter) dim.filter = set.size ? set : null } /** * Clear one chart's filter. * @param {string} chartId */ clear(chartId) { const dim = this.dims.get(chartId) if (dim) dim.filter = null this._emit('change', this.state()) return this } /** Clear all filters across every dimension. */ reset() { this.dims.forEach((dim) => { dim.filter = null }) this._emit('change', this.state()) return this } /** @param {any} dim @returns {boolean} does this dimension have an active filter */ _hasFilter(dim) { if (dim.filter == null) return false if (dim.filter instanceof Set) return dim.filter.size > 0 return true // range tuple } /** @param {any} dim @param {any} row @returns {boolean} does row pass this dim's filter */ _passes(dim, row) { if (!this._hasFilter(dim)) return true const v = dim.accessor(row) if (dim.filter instanceof Set) return dim.filter.has(v) // range if (!isNum(v)) return false return v >= dim.filter[0] && v <= dim.filter[1] } // ----- aggregation ------------------------------------------------------ /** * Records passing every ACTIVE filter except the one on `exceptChartId` * (pass null/undefined to apply all filters). * @param {string|null} [exceptChartId] * @returns {any[]} */ filteredRecords(exceptChartId) { /** @type {any[]} */ const active = [] this.dims.forEach((dim, id) => { if (id === exceptChartId) return if (this._hasFilter(dim)) active.push(dim) }) if (active.length === 0) return this.records return this.records.filter((row) => active.every((dim) => this._passes(dim, row))) } /** Rows passing ALL active filters (the fully filtered set). @returns {any[]} */ filteredRows() { return this.filteredRecords(null) } /** * The crossfilter aggregation for one chart: reduce over records passing all * OTHER charts' filters, bucketed by this chart's dimension. Category/range * dims return `{type, labels, values, keys, edges?}`; a matrix (2D) dim * returns `{type:'matrix', xLabels, yLabels, matrix}`. * @param {string} chartId * @returns {any} */ aggregateFor(chartId) { const dim = this.dims.get(chartId) if (!dim) return { type: 'category', labels: [], values: [], keys: [] } const rows = this.filteredRecords(chartId) if (dim.type === 'matrix') { const xIndex = new Map(dim.xLabels.map((/** @type {any} */ k, /** @type {number} */ i) => [k, i])) const yIndex = new Map(dim.yLabels.map((/** @type {any} */ k, /** @type {number} */ i) => [k, i])) /** @type {any[][][]} */ const buckets = dim.yLabels.map(() => dim.xLabels.map(() => [])) for (let i = 0; i < rows.length; i++) { const pair = dim.accessor(rows[i]) if (!pair) continue const xi = xIndex.get(pair[0]) const yi = yIndex.get(pair[1]) if (xi != null && yi != null) buckets[yi][xi].push(rows[i]) } return { type: 'matrix', xLabels: dim.xLabels.slice(), yLabels: dim.yLabels.slice(), matrix: buckets.map((brow) => brow.map((cell) => dim.reducer(cell))), } } if (dim.type === 'range') { const edges = dim.edges || [0, 1] const nBins = edges.length - 1 /** @type {any[][]} */ const buckets = Array.from({ length: nBins }, () => []) for (let i = 0; i < rows.length; i++) { const idx = binIndexOf(dim.accessor(rows[i]), edges) if (idx >= 0) buckets[idx].push(rows[i]) } return { type: 'range', labels: binCenters(edges), // plot bars at bin centers, not lower edges values: buckets.map((b) => dim.reducer(b)), keys: buckets.map((_, i) => [edges[i], edges[i + 1]]), edges, } } // categorical: preserve the full domain order (empty buckets included). /** @type {Map<any, any[]>} */ const index = new Map() dim.labels.forEach((/** @type {any} */ k) => index.set(k, [])) for (let i = 0; i < rows.length; i++) { const k = dim.accessor(rows[i]) const bucket = index.get(k) if (bucket) bucket.push(rows[i]) } return { type: 'category', labels: dim.labels.slice(), values: dim.labels.map((/** @type {any} */ k) => dim.reducer(index.get(k) || []), ), keys: dim.labels.slice(), } } /** * Aggregate every registered chart. * @returns {Record<string, ReturnType<Crossfilter['aggregateFor']>>} */ aggregateAll() { /** @type {any} */ const out = {} this.dims.forEach((_dim, id) => { out[id] = this.aggregateFor(id) }) return out } // ----- state + events --------------------------------------------------- /** * A serializable snapshot: active filters, filtered/total record counts. * @returns {{filters:Record<string, any[]|[number,number]>, filteredCount:number, total:number}} */ state() { /** @type {any} */ const filters = {} this.dims.forEach((dim, id) => { if (!this._hasFilter(dim)) return filters[id] = dim.filter instanceof Set ? Array.from(dim.filter) : dim.filter.slice() }) return { filters, filteredCount: this.filteredRows().length, total: this.records.length, } } /** @param {string} chartId @returns {any} the current filter (Set copy / range copy / null) */ filterOf(chartId) { const dim = this.dims.get(chartId) if (!dim || !this._hasFilter(dim)) return null return dim.filter instanceof Set ? new Set(dim.filter) : dim.filter.slice() } /** * Subscribe to an event ('change' | 'records'). Returns an unsubscribe fn. * @param {string} event @param {Function} cb */ on(event, cb) { let set = this._listeners.get(event) if (!set) { set = new Set() this._listeners.set(event, set) } set.add(cb) return () => this.off(event, cb) } /** @param {string} event @param {Function} cb */ off(event, cb) { this._listeners.get(event)?.delete(cb) return this } /** @param {string} event @param {any} payload */ _emit(event, payload) { this._listeners.get(event)?.forEach((cb) => { try { cb(payload) } catch (e) { // A listener throwing must not corrupt the coordinator. console.error(e) } }) } // ----- data table (presentation helper) --------------------------------- // Renders the filtered rows into a user-provided element and keeps it in sync // on every filter change. Only the passed `el` is touched (no window/document // globals), so the engine stays SSR-safe and unit-testable. /** @param {string} s @returns {string} HTML-escaped */ _esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') } /** * @param {Array<string|{field:string,label?:string,format?:(v:any,row:any)=>any}>|undefined} columns * @returns {Array<{field:string,label:string,format?:Function}>} */ _resolveColumns(columns) { if (Array.isArray(columns) && columns.length) { return columns.map((c) => typeof c === 'string' ? { field: c, label: c } : { field: c.field, label: c.label || c.field, format: c.format }, ) } const first = this.records[0] const fields = first ? Object.keys(first) : [] return fields.map((f) => ({ field: f, label: f })) } /** * @param {Array<{field:string,label:string,format?:Function}>} columns * @param {any[]} rows @param {number} total * @returns {string} */ _tableHTML(columns, rows, total) { const head = '<thead><tr>' + columns.map((c) => `<th>${this._esc(c.label)}</th>`).join('') + '</tr></thead>' const body = '<tbody>' + rows .map( (row) => '<tr>' + columns .map((c) => { const raw = row[c.field] const val = c.format ? c.format(raw, row) : raw return `<td>${this._esc(val)}</td>` }) .join('') + '</tr>', ) .join('') + '</tbody>' const caption = `<caption>${rows.length} of ${total} rows</caption>` return `<table class="apexcharts-cf-table">${caption}${head}${body}</table>` } /** * Bind an HTML table of the filtered rows to `el`; it re-renders on every * filter change. Returns a handle with refresh()/destroy(). * @param {HTMLElement} el * @param {{columns?:any[], page?:number, pageSize?:number}} [opts] */ dataTable(el, opts) { if (!el) return { refresh() {}, destroy() {} } const o = opts || {} const columns = this._resolveColumns(o.columns) const pageSize = o.pageSize || 0 // 0 = show all const page = o.page || 0 const render = () => { const rows = this.filteredRows() const shown = pageSize ? rows.slice(page * pageSize, page * pageSize + pageSize) : rows el.innerHTML = this._tableHTML(columns, shown, rows.length) } render() const off = this.on('change', render) return { refresh: render, destroy: () => { off() el.innerHTML = '' }, } } /** Remove this coordinator from the registry and drop all state. */ destroy() { Crossfilter._store().delete(this.id) this.dims.clear() this._listeners.clear() this.records = [] } }