@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
JavaScript
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}
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