UNPKG

@mindfiredigital/pivothead

Version:

PivotHead is a powerful and flexible library for creating interactive pivot tables in JavaScript applications. It provides a core engine for data manipulation and, in the future, will be compatible with wrappers for React, Vue, Svelte, and Angular, making

1,478 lines (1,475 loc) 122 kB
import * as x from "xlsx"; import { jsPDF as pe } from "jspdf"; import { autoTable as we } from "jspdf-autotable"; function re(c, e, t) { if (!c || c.length === 0) return 0; const r = c.map((s) => Number(s[e]) || 0); switch (t) { case "sum": return r.reduce((s, a) => s + a, 0); case "avg": return r.reduce((s, a) => s + a, 0) / r.length; case "min": return Math.min(...r); case "max": return Math.max(...r); case "count": return r.length; default: return 0; } } function ye(c, e, t) { return !e || e.length === 0 ? c : [...c].sort((r, s) => { for (const a of e) { const { field: i, direction: n, type: o, aggregation: u } = a; if (o === "measure") { const d = se(r, i), h = se(s, i); if (d !== h) return n === "asc" ? d - h : h - d; } else { const d = r[i], h = s[i]; if (d !== h) return n === "asc" ? d < h ? -1 : 1 : d > h ? -1 : 1; } } return 0; }); } function se(c, e, t) { return Number(c[e]) || 0; } function be(c, e = null, t = null) { let r = [...c.data || []]; e && (r = ye(r, [e])); let s = []; if (t) { const { rowFields: a, columnFields: i, grouper: n } = t, o = [...a, ...i]; s = ce(r, o, n); } return { rawData: r, groups: s }; } function ce(c, e, t) { if (!e || e.length === 0) return [{ key: "All", items: c, subgroups: [], aggregates: {} }]; const r = {}; return c.forEach((s) => { const a = t(s, e); r[a] || (r[a] = { key: a, items: [], subgroups: [], aggregates: {} }), r[a].items.push(s); }), e.length > 1 && Object.values(r).forEach((s) => { s.subgroups = ce( s.items, e.slice(1), t ); }), Object.values(r); } const Se = "@mindfiredigital/pivothead", ue = 1 * 1024 * 1024, j = 5 * 1024 * 1024, q = 8 * 1024 * 1024, B = 10 * 1024 * 1024, Ce = 256 * 1024, ae = 1 * 1024 * 1024, De = 2 * 1024 * 1024, ve = 5 * 1024 * 1024, ke = 10 * 1024 * 1024, Fe = 100 * 1024 * 1024, Ne = 100, Ee = [ "region", "country", "state", "city", "category", "department" ], Ae = [ "product", "item", "month", "quarter", "year", "type" ], Me = 40, _e = 10, Le = 50, Re = 10, xe = "#f8f9fa", de = Se, K = { error: 0, warn: 1, info: 2, debug: 3 }; function Pe() { const c = typeof process < "u" ? process.env.LOG_LEVEL ?? "info" : "info"; return K[c] ?? K.info; } function $e() { const c = Pe(), e = (t, r, s, ...a) => { (K[r] ?? 99) <= c && t(`[${de}] ${r.toUpperCase()}: ${s}`, ...a); }; return { error: (t, ...r) => e(console.error.bind(console), "error", t, ...r), warn: (t, ...r) => e(console.warn.bind(console), "warn", t, ...r), info: (t, ...r) => e(console.info.bind(console), "info", t, ...r), debug: (t, ...r) => e(console.debug.bind(console), "debug", t, ...r) }; } function Te() { if (typeof window < "u") return null; try { const c = require("winston"), { combine: e, label: t, colorize: r, printf: s, timestamp: a } = c.format, i = s( ({ level: n, message: o, label: u, timestamp: d, ...h }) => { const g = Object.keys(h).length ? ` ${JSON.stringify(h)}` : ""; return `${String(d)} [${String(u)}] ${String(n)}: ${String(o)}${g}`; } ); return c.createLogger({ level: (typeof process < "u" ? process.env.LOG_LEVEL : void 0) ?? "info", format: e( t({ label: de }), a({ format: "YYYY-MM-DD HH:mm:ss" }), r({ all: !1 }), i ), transports: [new c.transports.Console()] }); } catch { return null; } } const l = Te() ?? $e(); function P(c) { return (c == null ? "" : String(c)).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;"); } class E { /** * Converts the pivot table data to HTML * @param {PivotTableState<T>} state - The current state of the pivot table * @returns {string} HTML string representation of the pivot table */ static convertToHtml(e) { const { rows: t, columns: r, selectedMeasures: s, formatting: a, rawData: i } = e; if (i.length === 0 || !t.length || !r.length) return "<div>No data to display</div>"; const n = [ ...new Set(i.map((g) => g[r[0].uniqueName])) ], o = [ ...new Set(i.map((g) => g[t[0].uniqueName])) ], u = (g, m) => g === 0 ? "$0.00" : !g && g !== 0 ? "" : m && m.type === "currency" ? new Intl.NumberFormat(m.locale, { style: "currency", currency: m.currency, minimumFractionDigits: m.decimals, maximumFractionDigits: m.decimals }).format(g) : m && m.type === "number" ? new Intl.NumberFormat(m.locale, { minimumFractionDigits: m.decimals, maximumFractionDigits: m.decimals }).format(g) : String(g), d = (g, m) => ""; let h = ` <div class="pivot-export"> <style> .pivot-table { border-collapse: collapse; width: 100%; font-family: Arial, sans-serif; } .pivot-table th, .pivot-table td { border: 1px solid #ddd; padding: 8px; text-align: right; } .pivot-table th { background-color: #f2f2f2; font-weight: bold; text-align: center; position: relative; } .pivot-table .sort-icon::after { content: "↕"; position: absolute; right: 4px; opacity: 0.5; } .pivot-table th.region-header { border-bottom: none; } .pivot-table th.measure-header { border-top: none; } .pivot-table .row-header { text-align: left; font-weight: bold; background-color: #f9f9f9; } .pivot-table .corner-header { background-color: #f2f2f2; border-bottom: 1px solid #ddd; } .pagination { margin-top: 15px; font-family: Arial, sans-serif; } .export-info { margin-top: 15px; font-size: 0.8em; color: #666; font-family: Arial, sans-serif; } </style> <table class="pivot-table"> <thead> <tr> <th rowspan="2" class="corner-header">${P( t[0]?.caption || t[0]?.uniqueName || "" )} /<br>Region</th>`; return n.forEach((g) => { h += `<th colspan="${s.length}" class="region-header">${P(g)}</th>`; }), h += ` </tr> <tr>`, n.forEach(() => { s.forEach((g) => { h += `<th class="measure-header sort-icon">${P( g.caption || g.uniqueName )}</th>`; }); }), h += ` </tr> </thead> <tbody>`, o.forEach((g) => { h += `<tr> <td class="row-header">${P(g)}</td>`, n.forEach((m) => { s.forEach((C) => { const S = i.filter( (w) => w[t[0].uniqueName] === g && w[r[0].uniqueName] === m ); let b = 0; if (S.length > 0) switch (C.aggregation) { case "sum": b = S.reduce( (w, y) => w + (Number(y[C.uniqueName]) || 0), 0 ); break; case "avg": C?.formula && typeof C.formula == "function" ? b = S.reduce( (w, y) => w + (C.formula?.(y) || 0), 0 ) / S.length : b = S.reduce( (w, y) => w + (Number(y[C.uniqueName]) || 0), 0 ) / S.length; break; case "max": b = Math.max( ...S.map( (w) => Number(w[C.uniqueName]) || 0 ) ); break; case "min": b = Math.min( ...S.map( (w) => Number(w[C.uniqueName]) || 0 ) ); break; case "count": b = S.length; break; default: b = 0; } const f = d(b, C.uniqueName), p = u( b, a[C.uniqueName] ); h += `<td${f}>${P(p)}</td>`; }); }), h += "</tr>"; }), h += ` </tbody> </table> <div class="pagination"> Page ${e.paginationConfig?.currentPage || 1} of ${e.paginationConfig?.totalPages || 1} </div> <div class="export-info"> <p>Generated: ${(/* @__PURE__ */ new Date()).toLocaleString()}</p> </div> </div>`, h; } /** * Exports the pivot table data to HTML and downloads the file * @param {PivotTableState<T>} state - The current state of the pivot table * @param {string} fileName - The name of the downloaded file (without extension) */ static exportToHTML(e, t = "pivot-table") { l.info( "PivotExportService.exportToHTML called with fileName:", t ), l.info("State rawData length:", e.rawData?.length || 0); const r = E.convertToHtml(e); l.info("HTML content length:", r.length); const s = `<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>${P(t)}</title> <style> body { font-family: Arial, sans-serif; margin: 20px; } .pivot-export { max-width: 100%; overflow-x: auto; } </style> </head> <body> ${r} </body> </html>`; l.info("Full HTML length:", s.length); const a = "data:text/html;charset=utf-8," + encodeURIComponent(s); l.info("Data URL created, length:", a.length); const i = document.createElement("a"); i.href = a, i.download = `${t}.html`, document.body.appendChild(i), l.info("Clicking download link..."), i.click(), document.body.removeChild(i), l.info("HTML export completed"); } /** * Exports the pivot table data to PDF and downloads the file * @param {PivotTableState<T>} state - The current state of the pivot table * @param {string} fileName - The name of the downloaded file (without extension) */ static exportToPDF(e, t = "pivot-table") { l.info( "PivotExportService.exportToPDF called with fileName:", t ), l.info("State rawData length:", e.rawData?.length || 0); const r = E.convertToHtml(e); l.info("HTML content length for PDF:", r.length); const s = document.createElement("div"); s.style.position = "absolute", s.style.left = "-9999px", s.innerHTML = r, document.body.appendChild(s); const a = s.querySelector("table"); if (!a) { l.error("No table found in the generated HTML"), document.body.removeChild(s); return; } l.info("Table element found, proceeding with PDF generation"); try { l.info("Creating jsPDF instance..."); const i = new pe(); i.setFontSize(16), i.text(t, i.internal.pageSize.getWidth() / 2, 15, { align: "center" }), we(i, { html: a, startY: 25, styles: { fontSize: 10, cellPadding: 3, overflow: "linebreak" }, headStyles: { fillColor: [66, 139, 202], textColor: 255, fontStyle: "bold" }, columnStyles: {}, margin: { top: 25, right: 15, bottom: 25, left: 15 }, didDrawPage: (n) => { i.setFontSize(10), i.text( `Page ${n.pageNumber}`, i.internal.pageSize.getWidth() - 20, i.internal.pageSize.getHeight() - 10 ); } }), i.save(`${t}.pdf`), document.body.removeChild(s); } catch (i) { l.error("Error exporting to PDF:", i), document.body.removeChild(s); } } /** * Exports the pivot table data to Excel and downloads the file * @param {PivotTableState<T>} state - The current state of the pivot table * @param {string} fileName - The name of the downloaded file (without extension) */ static exportToExcel(e, t = "pivot-table") { l.info( "PivotExportService.exportToExcel called with fileName:", t ), l.info("State rawData length:", e.rawData?.length || 0); try { E.generateExcel(e, t); } catch (r) { l.error("Error exporting to Excel:", r); } } /** * Generates an Excel file from the pivot table data * @param {PivotTableState<T>} state - The current state of the pivot table * @param {string} fileName - The name of the downloaded file (without extension) */ static generateExcel(e, t) { if (!e.data || e.data.length === 0) { l.info("No data to export!"); return; } const r = e.rows || [], s = e.columns || [], a = e.measures || [], i = r[0]?.uniqueName, n = s[0]?.uniqueName; if (!i || !n) { l.info("Missing row or column dimension"); return; } const o = [ ...new Set(e.data.map((f) => f[i])) ], u = [ ...new Set(e.data.map((f) => f[n])) ], d = [ r[0]?.caption || "Dimension", ...u.flatMap( (f) => a.map( (p) => `${f} - ${p.caption || p.uniqueName}` ) ) ], h = o.map((f) => { const p = [f]; return u.forEach((w) => { a.forEach((y) => { const D = e.data.filter( (v) => v[i] === f && v[n] === w ); let k = 0; if (D.length > 0) switch (y.aggregation) { case "sum": k = D.reduce( (v, F) => v + (Number(F[y.uniqueName]) || 0), 0 ); break; case "avg": y?.formula && typeof y.formula == "function" && D.length > 0 ? k = D.reduce( (v, F) => v + (y.formula?.(F) || 0), 0 ) / D.length : k = D.reduce( (v, F) => v + (Number(F[y.uniqueName]) || 0), 0 ) / D.length; break; case "max": k = Math.max( ...D.map( (v) => Number(v[y.uniqueName]) || 0 ) ); break; case "min": k = Math.min( ...D.map( (v) => Number(v[y.uniqueName]) || 0 ) ); break; case "count": k = D.length; break; default: k = 0; } p.push(k); }); }), p; }); if (e.processedData && e.processedData.totals) { const f = ["Total"]; u.forEach(() => { a.forEach((p) => { const w = e.processedData.totals[p.uniqueName] || 0; f.push(w); }); }), h.push(f); } const g = [d, ...h], m = x.utils.aoa_to_sheet(g), C = x.utils.decode_range(m["!ref"] ?? "A1:A1"), S = []; for (let f = 0; f <= C.e.c; f++) S[f] = { wch: f === 0 ? 15 : 12 }; m["!cols"] = S; for (let f = 1; f <= h.length; f++) for (let p = 1; p <= u.length * a.length; p++) { const w = x.utils.encode_cell({ r: f, c: p }), y = (p - 1) % a.length, D = a[y]; if (D && D.format && m[w]) if (D.format.type === "currency") m[w].z = D.format.currency === "USD" ? '"$"#,##0.00' : `"${D.format.currency}"#,##0.00`; else if (D.format.type === "number" && D.format.decimals !== void 0) { const k = "#,##0" + (D.format.decimals > 0 ? "." + "0".repeat(D.format.decimals) : ""); m[w].z = k; } else D.format.type === "percentage" && (m[w].z = "0.00%", typeof m[w].v == "number" && (m[w].v = m[w].v / 100)); } const b = x.utils.book_new(); x.utils.book_append_sheet(b, m, "Pivot Table"), x.writeFile(b, `${t}.xlsx`); } /** * Opens a print dialog with formatted pivot table content * @param {PivotTableState<T>} state - The current state of the pivot table * @param {string} title - Optional title for the printed page */ static openPrintDialog(e) { const t = E.convertToHtml(e), r = window.open("", "_blank"); if (!r) { l.error("Failed to open print dialog"); return; } r.document.write(t), r.document.close(), r.print(); } } class et { constructor(e) { if (this.filterConfig = [], this.paginationConfig = { currentPage: 1, pageSize: 10, totalPages: 1 }, this.autoAllColumnEnabled = !1, this.cache = /* @__PURE__ */ new Map(), this.listeners = /* @__PURE__ */ new Set(), !this.validateConfig(e)) throw new Error("Invalid pivot table configuration"); this.config = { ...e, defaultAggregation: e.defaultAggregation || "sum", isResponsive: e.isResponsive ?? !0 }, this.state = this.initializeState(e), this.loadData(); } setColumns(e) { throw new Error("Method not implemented."); } setRows(e) { throw new Error("Method not implemented."); } setData(e) { throw new Error("Method not implemented."); } validateConfig(e) { if (!e) return !1; if (e.dataSource) { const { type: t, url: r, file: s } = e.dataSource; if (t === "remote" && !r || t === "file" && !s) return !1; } return !0; } initializeState(e) { return { data: e.data || [], dataHandlingMode: "processed", rawData: e.data || [], processedData: { headers: [], rows: [], totals: {} }, rows: e.rows || [], columns: e.columns || [], measures: this.normalizeMeasures(e.measures || []), sortConfig: [], rowSizes: this.initializeRowSizes(e.data || []), expandedRows: {}, groupConfig: e.groupConfig || null, groups: [], selectedMeasures: this.normalizeMeasures(e.measures || []), selectedDimensions: e.dimensions || [], selectedAggregation: e.defaultAggregation || "sum", formatting: e.formatting || {}, columnWidths: {}, isResponsive: e.isResponsive ?? !0, rowGroups: [], columnGroups: [], filterConfig: [], paginationConfig: { currentPage: 1, pageSize: e.pageSize || 10, totalPages: 1 } }; } /** * Loads data from a file or URL. **/ async loadData() { if (this.config.dataSource) { const { type: e, url: t, file: r } = this.config.dataSource; e === "remote" && t ? this.state.rawData = await this.fetchRemoteData(t) : e === "file" && r ? this.state.rawData = await this.readFileData(r) : l.error("Invalid data source configuration"); } else this.config.data && (this.state.rawData = this.config.data); this.state.rowSizes = this.initializeRowSizes(this.state.rawData), this.ensureSyntheticAllColumn(), this.state.processedData = this.generateProcessedDataForDisplay(), this.state.groupConfig && this.applyGrouping(); } // Ensure we always have a non-empty column axis and data augmented for '__all__' when needed ensureSyntheticAllColumn() { if (!this.autoAllColumnEnabled) return; const e = !this.state.columns || this.state.columns.length === 0, t = !!this.state.columns?.some( (r) => r.uniqueName === "__all__" ); if (e && (this.state.columns = [ { uniqueName: "__all__", caption: "All" } ]), e || t) { const r = (s) => { const a = Array.isArray(s) ? s : []; let i = !1; const n = a.map((o) => o && o.__all__ === void 0 ? (i = !0, { ...o, __all__: "All" }) : o); return i ? n : a; }; this.config.data = r(this.config.data), this.state.rawData = r(this.state.rawData), this.state.data = r(this.state.data); } } setDataHandlingMode(e) { this.state.dataHandlingMode = e, this.refreshData(), this._emit(); } getDataHandlingMode() { return this.state.dataHandlingMode; } // Allow external callers (e.g., ConnectService) to enable/disable synthetic column mode setAutoAllColumn(e) { this.autoAllColumnEnabled = e, e && (this.ensureSyntheticAllColumn(), this.state.processedData = this.generateProcessedDataForDisplay(), this._emit()); } /** * Updates the engine's data source and clears filters * This method allows external components to update the data with fresh filtering state * @param {T[]} newData - The new data to use as the source * @param {boolean} clearFilters - Whether to clear existing filters (default: true) * @public */ updateDataSource(e, t = !0) { this.config.data = [...e], this.state.data = [...e], this.state.rawData = [...e], t && (this.filterConfig = [], this.state.filterConfig = []), this.ensureSyntheticAllColumn(), this.refreshData(), this._emit(); } /** * Loads data from a file or URL. * @param {File | string} source - The file or URL to load data from. * @public * @returns {Promise<void>} A promise that resolves when the data is loaded. **/ async fetchRemoteData(e) { try { const t = await fetch(e); if (!t.ok) throw new Error(`Failed to fetch data from ${e}`); return await t.json(); } catch (t) { return l.error("Error fetching remote data:", t), []; } } /** * Process the data to be displayed in the table. * @param {T[]} data - The data to process. * @returns {ProcessedData} The processed data including headers, rows, and totals. * @private **/ async readFileData(e) { return new Promise((t, r) => { const s = new FileReader(); s.onload = (a) => { try { const i = JSON.parse(a.target?.result); t(i); } catch (i) { r(i); } }, s.onerror = (a) => r(a), s.readAsText(e); }); } /** * Initializes row sizes for the pivot table. * @param {T[]} data - The data to initialize row sizes for. * @returns {RowSize[]} An array of row sizes. * @private */ initializeRowSizes(e) { return e.map((t, r) => ({ index: r, height: 40 })); } /** * Processes the data for the pivot table. * @param {T[]} data - The data to process. * @returns {ProcessedData} The processed data including headers, rows, and totals. * @private */ generateProcessedDataForDisplay() { let e = this.state.rawData; if (this.state.dataHandlingMode === "processed" && this.state.sortConfig.length > 0) { const t = this.state.sortConfig[0]; this.state.groups.length > 0 ? e = this.state.groups.flatMap((r) => r.items) : e = this.sortData(this.state.rawData, t); } return { headers: this.generateHeaders(), rows: this.generateRows(e), totals: this.calculateTotals(e) }; } /** * Generates headers for the pivot table. * @returns {string[]} An array of header strings. * @private */ generateHeaders() { const e = this.state.dataHandlingMode === "raw", t = this.state.rows ? this.state.rows.map( (s) => e ? s.uniqueName : s.caption || s.uniqueName ) : [], r = this.state.columns ? this.state.columns.map( (s) => e ? s.uniqueName : s.caption || s.uniqueName ) : []; return [...t, ...r]; } /** * Generates rows for the pivot table. * @param {T[]} data - The data to generate rows from. * @returns {CellValue[][]} A 2D array representing the rows. * @private */ /** * Generates rows for the pivot table with enhanced formatting. * @param {T[]} data - The data to generate rows from. * @returns {CellValue[][]} A 2D array representing the rows. * @private */ generateRows(e) { return !e || !this.state.rows || !this.state.columns ? [] : e.map((t) => [ ...this.state.rows.map((r) => t[r.uniqueName]), ...this.state.columns.map((r) => t[r.uniqueName]), ...this.state.measures.map((r) => { const s = this.calculateMeasureValue(t, r); return this.formatValue(s, r.uniqueName); }) ]); } /** * Calculates the value for a specific measure. * @param {T} item - The data item. * @param {MeasureConfig} measure - The measure configuration. * @returns {number} The calculated measure value. * @private */ calculateMeasureValue(e, t) { return t.formula && typeof t.formula == "function" ? t.formula(e) : e[t.uniqueName] || 0; } /** * Calculates totals for each measure in the pivot table. * @param {T[]} data - The data to calculate totals from. * @returns {Record<string, number>} An object with measure names as keys and their totals as values. * @private */ calculateTotals(e) { const t = {}; return this.state.measures.forEach((r) => { const { uniqueName: s, aggregation: a } = r; let i = 0; a === "sum" ? i = e.reduce( (n, o) => n + (o[s] || 0), 0 ) : a === "avg" ? i = e.reduce( (n, o) => n + (o[s] || 0), 0 ) / e.length : a === "max" ? i = Math.max( ...e.map((n) => n[s] || 0) ) : a === "min" ? i = Math.min( ...e.map((n) => n[s] || 0) ) : a === "count" && (i = e.length), t[s] = i; }), t; } /** * Subscribe to state changes. Returns an unsubscribe function. */ subscribe(e) { return this.listeners.add(e), e(this.getState()), () => this.listeners.delete(e); } /** * Emit state changes to all subscribers. * CRITICAL FIX: Wrap each listener in try-catch to prevent one subscriber error from crashing others */ _emit() { const e = this.getState(); this.listeners.forEach((t) => { try { t(e); } catch (r) { l.error("Error in pivot engine subscriber:", r); } }); } /** * Sets the measures for the pivot table. * @param {MeasureConfig[]} measureFields - The measure configurations to set. * @public */ setMeasures(e) { this.state.selectedMeasures = this.normalizeMeasures(e), this.state.processedData = this.generateProcessedDataForDisplay(), this.updateAggregates(), this._emit(); } /** * Sets the dimensions for the pivot table. * @param {Dimension[]} dimensionFields - The dimension configurations to set. * @public */ setDimensions(e) { this.state.selectedDimensions = e, this.state.processedData = this.generateProcessedDataForDisplay(), this.updateAggregates(), this.refreshData(), this._emit(); } /** * Sets the aggregation type for the pivot table. * @param {AggregationType} type - The aggregation type to set. * @public */ setAggregation(e) { this.state.selectedAggregation = e, this.state.processedData = this.generateProcessedDataForDisplay(), this.updateAggregates(), this.refreshData(), this._emit(); } /** * Sets the row groups for the pivot table. * @param {Group[]} rowGroups - The row groups to set. * @public */ setRowGroups(e) { this.state.rowGroups = e, this.state.processedData = this.generateProcessedDataForDisplay(), this.updateAggregates(), this._emit(); } /** * Sets the column groups for the pivot table. * @param {Group[]} columnGroups - The column groups to set. * @public */ setColumnGroups(e) { this.state.columnGroups = e, this.state.processedData = this.generateProcessedDataForDisplay(), this.updateAggregates(), this._emit(); } /** * Enhanced formatValue method with comprehensive formatting support * @param {CellValue} value - The value to format. * @param {string} field - The field name to use for formatting. * @returns {string} The formatted value as a string. * @public */ formatValue(e, t) { if (e == null || typeof e == "number" && isNaN(e)) { const a = this.getFieldFormat(t); return a && a.nullValue !== void 0 ? a.nullValue === null ? "" : String(a.nullValue) : ""; } const r = this.getFieldFormat(t), s = this.state.measures.find((a) => a.uniqueName === t); if (!r) { if (s?.aggregation === "avg") { const a = Number(e); return Number.isFinite(a) ? String(Math.round(a)) : String(e); } return String(e); } try { const a = s?.aggregation === "avg" && typeof r.decimals != "number" ? { ...r, decimals: 0 } : r; return this.applyFormatting(e, a); } catch (a) { return l.error(`Error formatting value for field ${t}:`, a), String(e); } } /** * Get formatting configuration for a field * @param {string} field - The field name * @returns {FormatOptions | null} The format configuration * @private */ getFieldFormat(e) { const t = this.state.measures.find((s) => s.uniqueName === e); if (t && t.format) return t.format; const r = this.state.formatting[e]; return r || null; } /** * Apply comprehensive formatting to a value * @param {CellValue} value - The value to format * @param {FormatOptions} format - The format configuration * @returns {string} The formatted value * @private */ applyFormatting(e, t) { let r = parseFloat(String(e)); if (isNaN(r)) return String(e); t.percent && (r = r * 100); const s = typeof t.decimals == "number" ? t.decimals : 2; let a; switch (t.type) { case "currency": a = this.formatCurrency(r, t, s); break; case "percentage": a = this.formatPercentage(r, t, s); break; case "date": a = this.formatDate(e, t); break; case "number": default: a = this.formatNumber(r, t, s); break; } return a = this.applyCustomSeparators(a, t), a; } /** * Format as currency * @param {number} num - The number to format * @param {FormatOptions} format - The format configuration * @param {number} decimals - Number of decimal places * @returns {string} The formatted currency value * @private */ formatCurrency(e, t, r) { const s = t.currency || "USD", a = t.locale || "en-US"; let i = new Intl.NumberFormat(a, { style: "currency", currency: s, minimumFractionDigits: r, maximumFractionDigits: r }).format(e); if (t.align === "right" || t.currencyAlign === "right") { const n = i.replace(/[\d.,\s]/g, ""); i = i.replace(n, "").trim() + " " + n; } else t.align === "left" || t.currencyAlign; return i; } /** * Format as percentage * @param {number} num - The number to format * @param {FormatOptions} format - The format configuration * @param {number} decimals - Number of decimal places * @returns {string} The formatted percentage value * @private */ formatPercentage(e, t, r) { const s = t.locale || "en-US"; return new Intl.NumberFormat(s, { style: "percent", minimumFractionDigits: r, maximumFractionDigits: r }).format(e / 100); } /** * Format as number * @param {number} num - The number to format * @param {FormatOptions} format - The format configuration * @param {number} decimals - Number of decimal places * @returns {string} The formatted number value * @private */ formatNumber(e, t, r) { const s = t.locale || "en-US"; return new Intl.NumberFormat(s, { minimumFractionDigits: r, maximumFractionDigits: r }).format(e); } /** * Format as date * @param {CellValue} value - The date value to format * @param {FormatOptions} format - The format configuration * @returns {string} The formatted date value * @private */ formatDate(e, t) { const r = t.locale || "en-US"; try { return new Date(e).toLocaleDateString(r, { dateStyle: "medium" }); } catch { return String(e); } } /** * Apply custom thousand and decimal separators * @param {string} formattedValue - The pre-formatted value * @param {FormatOptions} format - The format configuration * @returns {string} The value with custom separators applied * @private */ applyCustomSeparators(e, t) { let r = e; if (t.decimalSeparator && t.decimalSeparator !== ".") { const s = r.lastIndexOf("."); s !== -1 && (r = r.substring(0, s) + t.decimalSeparator + r.substring(s + 1)); } return t.thousandSeparator !== void 0 && (t.thousandSeparator === "" ? r = r.replace(/,/g, "") : t.thousandSeparator !== "," && (r = r.replace(/,/g, t.thousandSeparator))), r; } /** * Calculate aggregated value for a cell intersection * @param {string} rowValue - The row value * @param {string} columnValue - The column value * @param {MeasureConfig} measure - The measure configuration * @param {string} rowFieldName - The row field name * @param {string} columnFieldName - The column field name * @returns {number} The calculated aggregated value * @public */ calculateCellValue(e, t, r, s, a) { const n = (this.config.data || this.state.rawData).filter( (u) => u[s] === e && u[a] === t ); if (n.length === 0) return 0; let o = 0; switch (r.aggregation) { case "sum": o = n.reduce( (u, d) => u + (d[r.uniqueName] || 0), 0 ); break; case "avg": o = n.reduce( (u, d) => u + (d[r.uniqueName] || 0), 0 ) / n.length; break; case "max": o = Math.max( ...n.map((u) => u[r.uniqueName] || 0) ); break; case "min": o = Math.min( ...n.map((u) => u[r.uniqueName] || 0) ); break; case "count": o = n.length; break; default: o = 0; } return o; } /** * Get text alignment for a field * @param {string} field - The field name * @returns {string} The text alignment ('left', 'right', 'center') * @public */ getFieldAlignment(e) { const t = `alignment:${e}`; if (this.cache.has(t)) return this.cache.get(t); const r = this.getFieldFormat(e); let s; return r && r.align ? s = r.align : r && r.type === "currency" && r.currencyAlign ? s = r.currencyAlign : s = this.state.measures.find((i) => i.uniqueName === e) ? "right" : "left", this.cache.set(t, s), s; } /** * Update formatting configuration for a specific field * @param {string} field - The field name * @param {FormatOptions} format - The format configuration * @public */ updateFieldFormatting(e, t) { const r = this.state.measures.find((a) => a.uniqueName === e); r && (r.format = t), this.state.formatting[e] = t; const s = `alignment:${e}`; this.cache.delete(s), this.state.processedData = this.generateProcessedDataForDisplay(), this._emit(); } /** * Sorts the pivot table data based on the specified field and direction. * @param {string} field - The field to sort by. * @param {'asc' | 'desc'} direction - The sort direction. * @public */ sort(e, t) { const r = this.state.measures.find((a) => a.uniqueName === e), s = { field: e, direction: t, type: r ? "measure" : "dimension", aggregation: r?.aggregation }; this.state.sortConfig = [s], this.applySort(); } applySort() { if (this.state.dataHandlingMode === "raw") { const e = this.sortData( this.state.rawData, this.state.sortConfig[0] ); this.state.data = e, this.state.rawData = e, this.state.processedData = this.generateProcessedDataForDisplay(), this.updateAggregates(); } else if (this.state.groups.length > 0) this.state.groups = this.sortGroups( this.state.groups, this.state.sortConfig[0] ), this.state.processedData = this.generateProcessedDataForDisplay(), this.updateAggregates(); else { const e = this.sortData( this.state.rawData, this.state.sortConfig[0] ); this.state.data = e, this.state.rawData = e, this.state.processedData = this.generateProcessedDataForDisplay(), this.updateAggregates(); } this._emit(); } sortData(e, t) { return [...e].sort((r, s) => { let a = this.getFieldValue(r, t), i = this.getFieldValue(s, t); return typeof a == "string" && (a = a.toLowerCase()), typeof i == "string" && (i = i.toLowerCase()), a < i ? t.direction === "asc" ? -1 : 1 : a > i ? t.direction === "asc" ? 1 : -1 : 0; }); } getFieldValue(e, t) { if (t.type === "measure") { const r = this.state.measures.find( (s) => s.uniqueName === t.field ); if (r && r.formula) return r.formula(e); } return e[t.field]; } sortGroups(e, t) { return [...e].sort((r, s) => { let a, i; if (t.type === "measure") a = r.aggregates[`${t.aggregation}_${t.field}`] || 0, i = s.aggregates[`${t.aggregation}_${t.field}`] || 0; else { const n = r.key ? r.key.split("|") : [], o = s.key ? s.key.split("|") : [], u = this.state.rows?.[0]?.uniqueName, d = this.state.columns?.[0]?.uniqueName; t.field === u ? (a = n[0] || "", i = o[0] || "") : t.field === d ? (a = n[1] || "", i = o[1] || "") : (a = r.items[0]?.[t.field] || "", i = s.items[0]?.[t.field] || ""), typeof a == "string" && (a = a.toLowerCase()), typeof i == "string" && (i = i.toLowerCase()); } return a < i ? t.direction === "asc" ? -1 : 1 : a > i ? t.direction === "asc" ? 1 : -1 : 0; }); } /** * Updates aggregates for all groups in the pivot table. * @private */ updateAggregates() { const e = (t) => { this.state.measures.forEach((r) => { const s = `${this.state.selectedAggregation}_${r.uniqueName}`; if (r.formula && typeof r.formula == "function") { const a = t.items.map( (i) => r.formula ? r.formula(i) : 0 ); t.aggregates[s] = re( a.map((i) => ({ value: i })), "value", r.aggregation || this.state.selectedAggregation ); } else t.aggregates[s] = re( t.items, r.uniqueName, r.aggregation || this.state.selectedAggregation ); }), t.subgroups && t.subgroups.forEach(e); }; this.state.groups.forEach(e); } /** * Applies grouping to the pivot table data. * @private */ applyGrouping(e) { if (!this.state.groupConfig) return; const { rowFields: t, columnFields: r, grouper: s } = this.state.groupConfig; if (!t || !r || !s) { l.error("Invalid groupConfig:", this.state.groupConfig); return; } const a = e || this.config.data || [], i = { ...this.config, data: a }, { rawData: n, groups: o } = be( i, this.state.sortConfig[0] || null, this.state.groupConfig ); this.state.rawData = n, this.state.groups = o, this.updateAggregates(), this.state.processedData = this.generateProcessedDataForDisplay(); } /** * Sets the group configuration for the pivot table. * @param {GroupConfig | null} groupConfig - The group configuration to set. * @public */ setGroupConfig(e) { this.state.groupConfig = e, e ? this.applyGrouping() : (this.state.groups = [], this.state.processedData = this.generateProcessedDataForDisplay()), this._emit(); } /** * Returns the grouped data. * @returns {Group[]} An array of grouped data. * @public */ getGroupedData() { return this.state.groups; } /** * Returns the current state of the pivot table. * @returns {PivotTableState<T>} The current state of the pivot table. * @public */ getState() { return { ...this.state }; } /** * Resets the pivot table to its initial state. * @public */ reset() { this.state = { ...this.state, rawData: this.config.data || [], processedData: this.generateProcessedDataForDisplay(), sortConfig: [], rowSizes: this.initializeRowSizes(this.config.data || []), expandedRows: {}, groupConfig: this.config.groupConfig || null, groups: [] }, this.state.groupConfig && this.applyGrouping(), this._emit(); } /** * Resizes a specific row in the pivot table. * @param {number} index - The index of the row to resize. * @param {number} height - The new height for the row. * @public */ resizeRow(e, t) { const r = this.state.rowSizes.findIndex((s) => s.index === e); r !== -1 && (this.state.rowSizes[r].height = Math.max(20, t), this._emit()); } /** * Toggles the expansion state of a row. * @param {string} rowId - The ID of the row to toggle. * @public */ toggleRowExpansion(e) { this.state.expandedRows[e] = !this.state.expandedRows[e], this._emit(); } /** * Checks if a row is expanded. * @param {string} rowId - The ID of the row to check. * @returns {boolean} True if the row is expanded, false otherwise. * @public */ isRowExpanded(e) { return !!this.state.expandedRows[e]; } /** * Handles dragging a row to a new position. * This method now correctly operates on state.rowGroups. * @param {number} fromIndex - The original index of the row. * @param {number} toIndex - The new index for the row. * @public */ dragRow(e, t) { if (!this.validateDragOperation( e, t, this.state.rowGroups.length )) return; const r = [...this.state.rowGroups], [s] = r.splice(e, 1); r.splice(t, 0, s), this.state.rowGroups = r, typeof this.config.onRowDragEnd == "function" && this.config.onRowDragEnd(e, t, this.state.rowGroups), this._emit(); } /** * Handles dragging a column to a new position. * This method now correctly operates on state.columnGroups. * @param {number} fromIndex - The original index of the column. * @param {number} toIndex - The new index for the column. * @public */ dragColumn(e, t) { if (this.validateDragOperation( e, t, this.state.columnGroups.length )) try { const r = [...this.state.columnGroups], [s] = r.splice(e, 1); if (r.splice(t, 0, s), this.state.columnGroups = r, typeof this.config.onColumnDragEnd == "function") { const a = r.map((i) => ({ uniqueName: i.uniqueName ?? i.key ?? "", caption: i.caption ?? i.key ?? "" })); this.config.onColumnDragEnd(e, t, a); } this._emit(); } catch (r) { l.error("Error during column drag operation:", r); } } // Ensure this validation method also prevents dragging to the same spot validateDragOperation(e, t, r) { if (e === t) return !1; const s = e >= 0 && t >= 0 && e < r && t < r; return s || l.warn( `Invalid drag indices: from ${e} to ${t} with length ${r}` ), s; } /** * Applies filters to the data * @param {FilterConfig[]} filters - Array of filter configurations * @public */ applyFilters(e) { this.filterConfig = e, this.refreshData(), this._emit(); } /** * Sets pagination configuration * @param {PaginationConfig} config - Pagination configuration * @public */ setPagination(e) { this.paginationConfig = { ...this.paginationConfig, ...e }, this.refreshData(), this._emit(); } /** * Returns the current pagination configuration * @returns {PaginationConfig} * @public */ getPagination() { return this.paginationConfig; } /** * Refreshes data with current filters and pagination * @private */ refreshData() { const e = this.getDataForCurrentMode(); let t; this.state.dataHandlingMode === "processed" && this.hasAggregatedFilters() ? t = this.filterProcessedData(e) : t = this.filterData(e), this.paginationConfig.totalPages = Math.ceil( t.length / this.paginationConfig.pageSize ), t = this.paginateData(t), this.state.dataHandlingMode === "raw" ? (this.state.data = t, this.state.rawData = t, this.state.processedData = this.generateProcessedDataForDisplay()) : (this.state.data = t, this.state.rawData = t, this.state.groupConfig && this.applyGrouping(t), this.state.processedData = this.generateProcessedDataForDisplay()); } /** * Gets the appropriate data source based on the current data handling mode * @private */ getDataForCurrentMode() { return [...this.config.data || []]; } /** * Filters data based on filter configuration * @param {T[]} data - Data to filter * @private */ filterData(e) { return this.filterConfig.length ? e.filter( (t) => this.filterConfig.every((r) => { const s = t[r.field], a = typeof s == "number" ? Number(r.value) : r.value; switch (r.operator) { case "equals": return typeof s == "string" && typeof a == "string" ? s.toLowerCase() === a.toLowerCase() : s === a; case "contains": return String(s).toLowerCase().includes(String(a).toLowerCase()); case "greaterThan": return Number(s) > Number(a); case "lessThan": return Number(s) < Number(a); case "between": { const i = a; return s >= i[0] && s <= i[1]; } default: return !0; } }) ) : e; } /** * Paginates data based on pagination configuration * @param {T[]} data - Data to paginate * @private */ paginateData(e) { const { currentPage: t, pageSize: r } = this.paginationConfig, s = (t - 1) * r; return e.slice(s, s + r); } /** * Gets current pagination state * @returns {PaginationConfig} Current pagination configuration * @public */ getPaginationState() { return { ...this.paginationConfig }; } /** * Gets current filter state * @returns {FilterConfig[]} Current filter configuration * @public */ getFilterState() { return [...this.filterConfig]; } /** * Exports the pivot table data to HTML and downloads the file. * @param {string} fileName - The name of the downloaded file (without extension). * @public */ exportToHTML(e = "pivot-table") { l.info("PivotEngine.exportToHTML called with fileName:", e), l.info( "PivotEngine state rawData length:", this.state.rawData?.length || 0 ), E.exportToHTML(this.getState(), e); } /** * Exports the pivot table data to PDF and downloads the file. * @param {string} fileName - The name of the downloaded file (without extension). * @public */ exportToPDF(e = "pivot-table") { l.info("PivotEngine.exportToPDF called with fileName:", e), l.info( "PivotEngine state rawData length:", this.state.rawData?.length || 0 ), E.exportToPDF(this.getState(), e); } /** * Exports the pivot table data to Excel and downloads the file. * @param {string} fileName - The name of the downloaded file (without extension). * @public */ exportToExcel(e = "pivot-table") { l.info("PivotEngine.exportToExcel called with fileName:", e), l.info( "PivotEngine state rawData length:", this.state.rawData?.length || 0 ), E.exportToExcel(this.getState(), e); } /** * Opens a print dialog with the formatted pivot table. * @public */ openPrintDialog() { E.openPrintDialog(this.getState()); } // Add these methods to your PivotEngine class to fix drag functionality /** * Handles dragging a data row (product) to a new position * This method operates on the actual data items, not groups * @param {number} fromIndex - The original index of the product in unique products list * @param {number} toIndex - The new index for the product in unique products list * @public */ dragDataRow(e, t) { const r = [ ...new Set(this.state.data.map((s) => s.product)) ].filter((s) => typeof s == "string"); if (this.validateDragOperation(e, t, r.length)) try { const s = r[e], a = r[t]; l.info(`Reordering products: ${s} -> ${a}`); const i = [...this.state.data], n = [...r], [o] = n.splice(e, 1); n.splice(t, 0, o), i.sort((u, d) => { const h = n.indexOf(u.product), g = n.indexOf(d.product); return h - g; }), this.state.data = i, this.state.rawData = i, this.state.processedData = this.generateProcessedDataForDisplay(), this.state.groups.length > 0 && this.updateAggregates(), typeof this.config.onRowDragEnd == "function" && this.config.onRowDragEnd(e, t, this.state.rowGroups); } catch (s) { l.error("Error during data row drag operation:", s); } } /** * Handles dragging a data column (region) to a new position * This method operates on the actual data structure, not groups * @param {number} fromIndex - The original index of the region * @param {number} toIndex - The new index for the region * @public */ dragDataColumn(e, t) { const r = [ ...new Set(this.state.data.map((s) => s.region)) ].filter((s) => typeof s == "string"); if (this.validateDragOperation(e, t, r.length)) try { const s = [...this.state.data], a = [...r], [i] = a.splice(e, 1); if (a.splice(t, 0, i), this.state.columns && this.state.columns.length > 0) { const n = [...this.state.columns]; n.sort((o, u) => { const d = a.indexOf(o.uniqueName), h = a.indexOf(u.uniqueName); return d === -1 ? 1 : h === -1 ? -1 : d - h; }), this.state.columns = n; } if (this.state.data = s, this.state.rawData = s, this.state.processedData = this.generateProcessedDataForDisplay(), this.state.groups.length > 0 && this.updateAggregates(), typeof this.config.onColumnDragEnd == "function") { const n = a.map((o) => ({ uniqueName: o, caption: o })); this.config.onColumnDragEnd(e, t, n); } } catch (s) { l.error("Error during data column drag operation:", s); } } /** * Alternative method: Reorder products by their names directly * This is more direct for your UI implementation * @param {string} fromProduct - Name of the product being moved * @param {string} toProduct - Name of the product to move before/after * @param {'before' | 'after'} position - Whether to place before or after target * @public */ reorderProductsByName(e, t, r = "before") { try { const s = [ ...new Set(this.s