mintwaterfall 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/CONTRIBUTING.md +199 -0
  3. package/README.md +363 -0
  4. package/dist/index.d.ts +149 -0
  5. package/dist/mintwaterfall.cjs.js +7978 -0
  6. package/dist/mintwaterfall.esm.js +7907 -0
  7. package/dist/mintwaterfall.min.js +7 -0
  8. package/dist/mintwaterfall.umd.js +7978 -0
  9. package/index.d.ts +149 -0
  10. package/package.json +126 -0
  11. package/src/enterprise/enterprise-core.js +0 -0
  12. package/src/enterprise/enterprise-feature-template.js +0 -0
  13. package/src/enterprise/feature-registry.js +0 -0
  14. package/src/enterprise/features/breakdown.js +0 -0
  15. package/src/features/breakdown.js +0 -0
  16. package/src/features/conditional-formatting.js +0 -0
  17. package/src/index.js +111 -0
  18. package/src/mintwaterfall-accessibility.ts +680 -0
  19. package/src/mintwaterfall-advanced-data.ts +1034 -0
  20. package/src/mintwaterfall-advanced-interactions.ts +649 -0
  21. package/src/mintwaterfall-advanced-performance.ts +582 -0
  22. package/src/mintwaterfall-animations.ts +595 -0
  23. package/src/mintwaterfall-brush.ts +471 -0
  24. package/src/mintwaterfall-chart-core.ts +296 -0
  25. package/src/mintwaterfall-chart.ts +1915 -0
  26. package/src/mintwaterfall-data.ts +1100 -0
  27. package/src/mintwaterfall-export.ts +475 -0
  28. package/src/mintwaterfall-hierarchical-layouts.ts +724 -0
  29. package/src/mintwaterfall-layouts.ts +647 -0
  30. package/src/mintwaterfall-performance.ts +573 -0
  31. package/src/mintwaterfall-scales.ts +437 -0
  32. package/src/mintwaterfall-shapes.ts +385 -0
  33. package/src/mintwaterfall-statistics.ts +821 -0
  34. package/src/mintwaterfall-themes.ts +391 -0
  35. package/src/mintwaterfall-tooltip.ts +450 -0
  36. package/src/mintwaterfall-zoom.ts +399 -0
  37. package/src/types/js-modules.d.ts +25 -0
  38. package/src/utils/compatibility-layer.js +0 -0
@@ -0,0 +1,1100 @@
1
+ // MintWaterfall Data Processing Utilities - TypeScript Version
2
+ // Provides data transformation, aggregation, and manipulation functions with full type safety
3
+
4
+ import * as d3 from 'd3';
5
+
6
+ // Type definitions for data structures
7
+ export interface StackItem {
8
+ value: number;
9
+ color: string;
10
+ label: string;
11
+ }
12
+
13
+ export interface DataItem {
14
+ label: string;
15
+ stacks: StackItem[];
16
+ }
17
+
18
+ export interface ProcessedDataItem extends DataItem {
19
+ aggregatedValue?: number;
20
+ originalStacks?: StackItem[];
21
+ }
22
+
23
+ export interface LoadDataOptions {
24
+ parseNumbers?: boolean;
25
+ dateColumns?: string[];
26
+ valueColumn?: string;
27
+ labelColumn?: string;
28
+ colorColumn?: string;
29
+ stacksColumn?: string;
30
+ }
31
+
32
+ export interface TransformOptions {
33
+ valueColumn?: string;
34
+ labelColumn?: string;
35
+ colorColumn?: string;
36
+ stacksColumn?: string;
37
+ defaultColor?: string;
38
+ parseNumbers?: boolean;
39
+ }
40
+
41
+ export interface TimeScaleOptions {
42
+ range?: [number, number];
43
+ nice?: boolean;
44
+ tickFormat?: string | 'auto';
45
+ }
46
+
47
+ export interface OrdinalScaleOptions {
48
+ range?: string[];
49
+ unknown?: string;
50
+ }
51
+
52
+ export interface BandScaleOptions {
53
+ padding?: number;
54
+ paddingInner?: number | null;
55
+ paddingOuter?: number | null;
56
+ align?: number;
57
+ }
58
+
59
+ export type AggregationType = 'sum' | 'average' | 'max' | 'min';
60
+ export type SortDirection = 'ascending' | 'descending';
61
+ export type SortBy = 'label' | 'total' | 'maxStack' | 'minStack';
62
+
63
+ // Raw data types (before transformation)
64
+ export interface RawDataItem {
65
+ [key: string]: any;
66
+ }
67
+
68
+ // Advanced data operation types
69
+ export type GroupByFunction<T> = (item: T) => any;
70
+ export type GroupByKeys<T> = Array<GroupByFunction<T>>;
71
+ export type ReduceFunction<T, R> = (values: T[]) => R;
72
+
73
+ // Multi-dimensional grouping result types
74
+ export type GroupedData<T> = Map<any, T[]>;
75
+ export type NestedGroupedData<T> = Map<any, Map<any, T[]>>;
76
+ export type RollupData<R> = Map<any, R>;
77
+ export type NestedRollupData<R> = Map<any, Map<any, R>>;
78
+
79
+ // Cross-tabulation types
80
+ export interface CrossTabResult<T1, T2, R> {
81
+ row: T1;
82
+ col: T2;
83
+ value: R;
84
+ }
85
+
86
+ // Index map types
87
+ export type IndexMap<T> = Map<any, T>;
88
+ export type NestedIndexMap<T> = Map<any, Map<any, T>>;
89
+
90
+ // Temporal aggregation types
91
+ export interface TemporalOptions {
92
+ timeAccessor: (d: any) => Date;
93
+ valueAccessor: (d: any) => number;
94
+ interval: d3.TimeInterval;
95
+ aggregation?: AggregationType;
96
+ }
97
+
98
+ // Advanced data processor interface extensions
99
+ export interface AdvancedDataOperations {
100
+ // D3.js group() operations
101
+ groupBy<T>(data: T[], ...keys: GroupByKeys<T>): GroupedData<T> | NestedGroupedData<T>;
102
+
103
+ // D3.js rollup() operations
104
+ rollupBy<T, R>(data: T[], reducer: ReduceFunction<T, R>, ...keys: GroupByKeys<T>): RollupData<R> | NestedRollupData<R>;
105
+
106
+ // D3.js flatRollup() operations
107
+ flatRollupBy<T, R>(data: T[], reducer: ReduceFunction<T, R>, ...keys: GroupByKeys<T>): Array<[...any[], R]>;
108
+
109
+ // D3.js cross() operations
110
+ crossTabulate<T1, T2, R>(data1: T1[], data2: T2[], combiner?: (a: T1, b: T2) => R): Array<CrossTabResult<T1, T2, R>>;
111
+
112
+ // D3.js index() operations
113
+ indexBy<T>(data: T[], ...keys: GroupByKeys<T>): IndexMap<T> | NestedIndexMap<T>;
114
+
115
+ // Temporal aggregation helpers
116
+ aggregateByTime(data: any[], options: TemporalOptions): DataItem[];
117
+
118
+ // Waterfall-specific helpers
119
+ createMultiDimensionalWaterfall(data: any[], groupKeys: string[], valueKey: string): DataItem[];
120
+ aggregateWaterfallByPeriod(data: DataItem[], timeKey: string, interval: d3.TimeInterval): DataItem[];
121
+ createBreakdownWaterfall(data: any[], primaryKey: string, breakdownKey: string, valueKey: string): DataItem[];
122
+ }
123
+
124
+ // Data loading utilities
125
+ export async function loadData(
126
+ source: string | DataItem[] | RawDataItem[],
127
+ options: LoadDataOptions = {}
128
+ ): Promise<DataItem[]> {
129
+ const {
130
+ // parseNumbers = true, // Reserved for future use
131
+ // dateColumns = [], // Reserved for future use
132
+ // valueColumn = "value", // Reserved for future use
133
+ // labelColumn = "label", // Reserved for future use
134
+ // colorColumn = "color", // Reserved for future use
135
+ // stacksColumn = "stacks" // Reserved for future use
136
+ } = options;
137
+
138
+ try {
139
+ let rawData: any;
140
+
141
+ if (typeof source === "string") {
142
+ // URL or file path
143
+ if (source.endsWith(".csv")) {
144
+ rawData = await d3.csv(source);
145
+ } else if (source.endsWith(".json")) {
146
+ rawData = await d3.json(source);
147
+ } else if (source.endsWith(".tsv")) {
148
+ rawData = await d3.tsv(source);
149
+ } else {
150
+ // Try to detect if it's a URL by checking for http/https
151
+ if (source.startsWith("http")) {
152
+ const response = await fetch(source);
153
+ const contentType = response.headers.get("content-type");
154
+
155
+ if (contentType?.includes("application/json")) {
156
+ rawData = await response.json();
157
+ } else if (contentType?.includes("text/csv")) {
158
+ const text = await response.text();
159
+ rawData = d3.csvParse(text);
160
+ } else {
161
+ rawData = await response.json(); // fallback
162
+ }
163
+ } else {
164
+ throw new Error(`Unsupported file format: ${source}`);
165
+ }
166
+ }
167
+ } else if (Array.isArray(source)) {
168
+ // Already an array
169
+ rawData = source;
170
+ } else {
171
+ throw new Error("Source must be a URL, file path, or data array");
172
+ }
173
+
174
+ // Transform raw data to MintWaterfall format if needed
175
+ return transformToWaterfallFormat(rawData, options);
176
+
177
+ } catch (error) {
178
+ console.error("Error loading data:", error);
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ // Transform various data formats to MintWaterfall format
184
+ export function transformToWaterfallFormat(
185
+ data: any[],
186
+ options: TransformOptions = {}
187
+ ): DataItem[] {
188
+ const {
189
+ valueColumn = "value",
190
+ labelColumn = "label",
191
+ colorColumn = "color",
192
+ // stacksColumn = "stacks", // Reserved for future use
193
+ defaultColor = "#3498db",
194
+ parseNumbers = true
195
+ } = options;
196
+
197
+ if (!Array.isArray(data)) {
198
+ throw new Error("Data must be an array");
199
+ }
200
+
201
+ return data.map((item: any, index: number): DataItem => {
202
+ // If already in correct format, return as-is
203
+ if (item.label && Array.isArray(item.stacks)) {
204
+ return item as DataItem;
205
+ }
206
+
207
+ // Transform flat format to stacked format
208
+ const label = item[labelColumn] || `Item ${index + 1}`;
209
+ let value = item[valueColumn];
210
+
211
+ if (parseNumbers && typeof value === "string") {
212
+ value = parseFloat(value.replace(/[,$]/g, "")) || 0;
213
+ } else if (typeof value !== "number") {
214
+ value = 0;
215
+ }
216
+
217
+ const color = item[colorColumn] || defaultColor;
218
+
219
+ return {
220
+ label: String(label),
221
+ stacks: [{
222
+ value: value,
223
+ color: color,
224
+ label: item.stackLabel || `${value >= 0 ? "+" : ""}${value}`
225
+ }]
226
+ };
227
+ });
228
+ }
229
+
230
+ export interface DataProcessor extends AdvancedDataOperations {
231
+ validateData(data: DataItem[]): boolean;
232
+ loadData(source: string | any[], options?: LoadDataOptions): Promise<DataItem[]>;
233
+ transformToWaterfallFormat(data: any[], options?: TransformOptions): DataItem[];
234
+ aggregateData(data: DataItem[], aggregateBy?: AggregationType): ProcessedDataItem[];
235
+ sortData(data: DataItem[], sortBy?: SortBy, direction?: SortDirection): DataItem[];
236
+ filterData(data: DataItem[], filterFn: (item: DataItem) => boolean): DataItem[];
237
+ getDataSummary(data: DataItem[]): DataSummary;
238
+ transformData(data: DataItem[], transformFn: (item: DataItem) => DataItem): DataItem[];
239
+ groupData(data: DataItem[], groupBy: string | ((item: DataItem) => string)): Map<string, DataItem[]>;
240
+ transformStacks(data: DataItem[], transformer: (stack: StackItem) => StackItem): DataItem[];
241
+ normalizeValues(data: DataItem[], targetMax: number): DataItem[];
242
+ groupByCategory(data: DataItem[], categoryFunction: (item: DataItem) => string): { [key: string]: DataItem[] };
243
+ calculatePercentages(data: DataItem[]): DataItem[];
244
+ interpolateData(data1: DataItem[], data2: DataItem[], t: number): DataItem[];
245
+ generateSampleData(itemCount: number, stacksPerItem: number, valueRange?: [number, number]): DataItem[];
246
+ }
247
+
248
+ export interface DataSummary {
249
+ totalItems: number;
250
+ totalStacks: number;
251
+ valueRange: { min: number; max: number };
252
+ cumulativeTotal: number;
253
+ stackColors: string[];
254
+ labels: string[];
255
+ }
256
+
257
+ export function createDataProcessor(): DataProcessor {
258
+
259
+ function validateData(data: DataItem[]): boolean {
260
+ if (!data || !Array.isArray(data)) {
261
+ throw new Error("Data must be an array");
262
+ }
263
+
264
+ if (data.length === 0) {
265
+ throw new Error("Data array cannot be empty");
266
+ }
267
+
268
+ const isValid = data.every((item: DataItem, index: number) => {
269
+ if (!item || typeof item !== "object") {
270
+ throw new Error(`Item at index ${index} must be an object`);
271
+ }
272
+
273
+ if (typeof item.label !== "string") {
274
+ throw new Error(`Item at index ${index} must have a string 'label' property`);
275
+ }
276
+
277
+ if (!Array.isArray(item.stacks)) {
278
+ throw new Error(`Item at index ${index} must have an array 'stacks' property`);
279
+ }
280
+
281
+ if (item.stacks.length === 0) {
282
+ throw new Error(`Item at index ${index} must have at least one stack`);
283
+ }
284
+
285
+ item.stacks.forEach((stack: StackItem, stackIndex: number) => {
286
+ if (typeof stack.value !== "number" || isNaN(stack.value)) {
287
+ throw new Error(`Stack ${stackIndex} in item ${index} must have a numeric 'value'`);
288
+ }
289
+
290
+ if (typeof stack.color !== "string") {
291
+ throw new Error(`Stack ${stackIndex} in item ${index} must have a string 'color'`);
292
+ }
293
+ });
294
+
295
+ return true;
296
+ });
297
+
298
+ return isValid;
299
+ }
300
+
301
+ function aggregateData(data: DataItem[], aggregateBy: AggregationType = "sum"): ProcessedDataItem[] {
302
+ validateData(data);
303
+
304
+ return data.map((item: DataItem): ProcessedDataItem => {
305
+ let aggregatedValue: number;
306
+
307
+ switch (aggregateBy) {
308
+ case "sum":
309
+ aggregatedValue = item.stacks.reduce((sum, stack) => sum + stack.value, 0);
310
+ break;
311
+ case "average":
312
+ aggregatedValue = item.stacks.reduce((sum, stack) => sum + stack.value, 0) / item.stacks.length;
313
+ break;
314
+ case "max":
315
+ aggregatedValue = Math.max(...item.stacks.map(s => s.value));
316
+ break;
317
+ case "min":
318
+ aggregatedValue = Math.min(...item.stacks.map(s => s.value));
319
+ break;
320
+ default:
321
+ aggregatedValue = item.stacks.reduce((sum, stack) => sum + stack.value, 0);
322
+ }
323
+
324
+ return {
325
+ ...item,
326
+ aggregatedValue,
327
+ originalStacks: item.stacks
328
+ };
329
+ });
330
+ }
331
+
332
+ function sortData(data: DataItem[], sortBy: SortBy = "label", direction: SortDirection = "ascending"): DataItem[] {
333
+ validateData(data);
334
+
335
+ const sorted = [...data].sort((a: DataItem, b: DataItem) => {
336
+ let valueA: number | string, valueB: number | string;
337
+
338
+ switch (sortBy) {
339
+ case "label":
340
+ valueA = a.label.toLowerCase();
341
+ valueB = b.label.toLowerCase();
342
+ break;
343
+ case "total":
344
+ // Calculate total for each item
345
+ const totalA = a.stacks.reduce((sum, stack) => sum + stack.value, 0);
346
+ const totalB = b.stacks.reduce((sum, stack) => sum + stack.value, 0);
347
+
348
+ // Smart sorting: use absolute value for comparison to handle decremental waterfalls
349
+ // This ensures that larger impacts (whether positive or negative) are sorted appropriately
350
+ valueA = Math.abs(totalA);
351
+ valueB = Math.abs(totalB);
352
+ break;
353
+ case "maxStack":
354
+ valueA = Math.max(...a.stacks.map(s => s.value));
355
+ valueB = Math.max(...b.stacks.map(s => s.value));
356
+ break;
357
+ case "minStack":
358
+ valueA = Math.min(...a.stacks.map(s => s.value));
359
+ valueB = Math.min(...b.stacks.map(s => s.value));
360
+ break;
361
+ default:
362
+ valueA = a.label.toLowerCase();
363
+ valueB = b.label.toLowerCase();
364
+ }
365
+
366
+ let comparison: number;
367
+ if (typeof valueA === "string" && typeof valueB === "string") {
368
+ comparison = valueA.localeCompare(valueB);
369
+ } else {
370
+ comparison = (valueA as number) - (valueB as number);
371
+ }
372
+
373
+ return direction === "ascending" ? comparison : -comparison;
374
+ });
375
+
376
+ return sorted;
377
+ }
378
+
379
+ function filterData(data: DataItem[], filterFn: (item: DataItem) => boolean): DataItem[] {
380
+ validateData(data);
381
+ return data.filter(filterFn);
382
+ }
383
+
384
+ function getDataSummary(data: DataItem[]): DataSummary {
385
+ validateData(data);
386
+
387
+ const allValues: number[] = [];
388
+ const allColors: string[] = [];
389
+ let totalStacks = 0;
390
+
391
+ data.forEach(item => {
392
+ item.stacks.forEach(stack => {
393
+ allValues.push(stack.value);
394
+ allColors.push(stack.color);
395
+ totalStacks++;
396
+ });
397
+ });
398
+
399
+ const valueRange = {
400
+ min: Math.min(...allValues),
401
+ max: Math.max(...allValues)
402
+ };
403
+
404
+ const cumulativeTotal = allValues.reduce((sum, value) => sum + value, 0);
405
+ const stackColors = [...new Set(allColors)];
406
+ const labels = data.map(item => item.label);
407
+
408
+ return {
409
+ totalItems: data.length,
410
+ totalStacks,
411
+ valueRange,
412
+ cumulativeTotal,
413
+ stackColors,
414
+ labels
415
+ };
416
+ }
417
+
418
+ function transformData(data: DataItem[], transformFn: (item: DataItem) => DataItem): DataItem[] {
419
+ validateData(data);
420
+ return data.map(transformFn);
421
+ }
422
+
423
+ function groupData(data: DataItem[], groupBy: string | ((item: DataItem) => string)): Map<string, DataItem[]> {
424
+ validateData(data);
425
+
426
+ const groups = new Map<string, DataItem[]>();
427
+
428
+ data.forEach(item => {
429
+ const key = typeof groupBy === "function" ? groupBy(item) : item.label;
430
+
431
+ if (!groups.has(key)) {
432
+ groups.set(key, []);
433
+ }
434
+
435
+ groups.get(key)!.push(item);
436
+ });
437
+
438
+ return groups;
439
+ }
440
+
441
+ function transformStacks(data: DataItem[], transformer: (stack: StackItem) => StackItem): DataItem[] {
442
+ if (typeof transformer !== 'function') {
443
+ throw new Error('Transformer must be a function');
444
+ }
445
+
446
+ return data.map(item => ({
447
+ ...item,
448
+ stacks: item.stacks.map(transformer)
449
+ }));
450
+ }
451
+
452
+ function normalizeValues(data: DataItem[], targetMax: number): DataItem[] {
453
+ // Find the maximum absolute value across all stacks
454
+ let maxValue = 0;
455
+ data.forEach(item => {
456
+ item.stacks.forEach(stack => {
457
+ maxValue = Math.max(maxValue, Math.abs(stack.value));
458
+ });
459
+ });
460
+
461
+ if (maxValue === 0) return data;
462
+
463
+ const scaleFactor = targetMax / maxValue;
464
+
465
+ return data.map(item => ({
466
+ ...item,
467
+ stacks: item.stacks.map(stack => ({
468
+ ...stack,
469
+ originalValue: stack.value,
470
+ value: stack.value * scaleFactor
471
+ }))
472
+ }));
473
+ }
474
+
475
+ function groupByCategory(data: DataItem[], categoryFunction: (item: DataItem) => string): { [key: string]: DataItem[] } {
476
+ if (typeof categoryFunction !== 'function') {
477
+ throw new Error('Category function must be a function');
478
+ }
479
+
480
+ const groups: { [key: string]: DataItem[] } = {};
481
+
482
+ data.forEach(item => {
483
+ const category = categoryFunction(item);
484
+ if (!groups[category]) {
485
+ groups[category] = [];
486
+ }
487
+ groups[category].push(item);
488
+ });
489
+
490
+ return groups;
491
+ }
492
+
493
+ function calculatePercentages(data: DataItem[]): DataItem[] {
494
+ return data.map(item => {
495
+ const total = item.stacks.reduce((sum, stack) => sum + Math.abs(stack.value), 0);
496
+
497
+ return {
498
+ ...item,
499
+ stacks: item.stacks.map(stack => ({
500
+ ...stack,
501
+ percentage: total === 0 ? 0 : (Math.abs(stack.value) / total) * 100
502
+ }))
503
+ };
504
+ });
505
+ }
506
+
507
+ function interpolateData(data1: DataItem[], data2: DataItem[], t: number): DataItem[] {
508
+ if (data1.length !== data2.length) {
509
+ throw new Error('Data arrays must have the same length');
510
+ }
511
+
512
+ return data1.map((item1, index) => {
513
+ const item2 = data2[index];
514
+ const minStacks = Math.min(item1.stacks.length, item2.stacks.length);
515
+
516
+ return {
517
+ label: item1.label,
518
+ stacks: Array.from({ length: minStacks }, (_, i) => ({
519
+ value: item1.stacks[i].value + (item2.stacks[i].value - item1.stacks[i].value) * t,
520
+ color: item1.stacks[i].color,
521
+ label: item1.stacks[i].label
522
+ }))
523
+ };
524
+ });
525
+ }
526
+
527
+ function generateSampleData(itemCount: number, stacksPerItem: number, valueRange: [number, number] = [10, 100]): DataItem[] {
528
+ const [minValue, maxValue] = valueRange;
529
+ const colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'];
530
+
531
+ return Array.from({ length: itemCount }, (_, i) => ({
532
+ label: `Item ${i + 1}`,
533
+ stacks: Array.from({ length: stacksPerItem }, (_, j) => ({
534
+ value: Math.random() * (maxValue - minValue) + minValue,
535
+ color: colors[j % colors.length],
536
+ label: `Stack ${j + 1}`
537
+ }))
538
+ }));
539
+ }
540
+
541
+ // === ADVANCED D3.JS DATA OPERATIONS ===
542
+
543
+ /**
544
+ * Advanced multi-dimensional grouping using D3.js group() API
545
+ * Supports 1-3 levels of nested grouping
546
+ */
547
+ function groupBy<T>(data: T[], ...keys: GroupByKeys<T>): GroupedData<T> | NestedGroupedData<T> {
548
+ if (!Array.isArray(data)) {
549
+ throw new Error("Data must be an array");
550
+ }
551
+
552
+ if (keys.length === 0) {
553
+ throw new Error("At least one grouping key must be provided");
554
+ }
555
+
556
+ if (keys.length === 1) {
557
+ return d3.group(data, keys[0]);
558
+ } else if (keys.length === 2) {
559
+ return d3.group(data, keys[0], keys[1]) as NestedGroupedData<T>;
560
+ } else if (keys.length === 3) {
561
+ return d3.group(data, keys[0], keys[1], keys[2]) as any;
562
+ } else {
563
+ throw new Error("Maximum 3 levels of grouping supported");
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Advanced aggregation using D3.js rollup() API
569
+ * Supports multi-dimensional rollup with custom reducers
570
+ */
571
+ function rollupBy<T, R>(data: T[], reducer: ReduceFunction<T, R>, ...keys: GroupByKeys<T>): RollupData<R> | NestedRollupData<R> {
572
+ if (!Array.isArray(data)) {
573
+ throw new Error("Data must be an array");
574
+ }
575
+
576
+ if (typeof reducer !== "function") {
577
+ throw new Error("Reducer must be a function");
578
+ }
579
+
580
+ if (keys.length === 0) {
581
+ throw new Error("At least one grouping key must be provided");
582
+ }
583
+
584
+ if (keys.length === 1) {
585
+ return d3.rollup(data, reducer, keys[0]);
586
+ } else if (keys.length === 2) {
587
+ return d3.rollup(data, reducer, keys[0], keys[1]) as NestedRollupData<R>;
588
+ } else if (keys.length === 3) {
589
+ return d3.rollup(data, reducer, keys[0], keys[1], keys[2]) as any;
590
+ } else {
591
+ throw new Error("Maximum 3 levels of rollup supported");
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Flatten hierarchical rollup using D3.js flatRollup() API
597
+ * Returns array of [key1, key2, ..., value] tuples
598
+ */
599
+ function flatRollupBy<T, R>(data: T[], reducer: ReduceFunction<T, R>, ...keys: GroupByKeys<T>): Array<[...any[], R]> {
600
+ if (!Array.isArray(data)) {
601
+ throw new Error("Data must be an array");
602
+ }
603
+
604
+ if (typeof reducer !== "function") {
605
+ throw new Error("Reducer must be a function");
606
+ }
607
+
608
+ if (keys.length === 0) {
609
+ throw new Error("At least one grouping key must be provided");
610
+ }
611
+
612
+ return d3.flatRollup(data, reducer, ...keys) as Array<[...any[], R]>;
613
+ }
614
+
615
+ /**
616
+ * Cross-tabulation using D3.js cross() API
617
+ * Creates cartesian product with optional combiner function
618
+ */
619
+ function crossTabulate<T1, T2, R>(
620
+ data1: T1[],
621
+ data2: T2[],
622
+ combiner?: (a: T1, b: T2) => R
623
+ ): Array<CrossTabResult<T1, T2, R>> {
624
+ if (!Array.isArray(data1) || !Array.isArray(data2)) {
625
+ throw new Error("Both data arrays must be arrays");
626
+ }
627
+
628
+ if (combiner) {
629
+ return d3.cross(data1, data2, (a, b) => ({
630
+ row: a,
631
+ col: b,
632
+ value: combiner(a, b)
633
+ }));
634
+ } else {
635
+ return d3.cross(data1, data2, (a, b) => ({
636
+ row: a,
637
+ col: b,
638
+ value: undefined as R
639
+ }));
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Fast data indexing using D3.js index() API
645
+ * Creates map-based indexes for O(1) lookups
646
+ */
647
+ function indexBy<T>(data: T[], ...keys: GroupByKeys<T>): IndexMap<T> | NestedIndexMap<T> {
648
+ if (!Array.isArray(data)) {
649
+ throw new Error("Data must be an array");
650
+ }
651
+
652
+ if (keys.length === 0) {
653
+ throw new Error("At least one indexing key must be provided");
654
+ }
655
+
656
+ if (keys.length === 1) {
657
+ return d3.index(data, keys[0]);
658
+ } else if (keys.length === 2) {
659
+ return d3.index(data, keys[0], keys[1]) as NestedIndexMap<T>;
660
+ } else {
661
+ throw new Error("Maximum 2 levels of indexing supported");
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Temporal aggregation for time-series waterfall data
667
+ * Groups data by time intervals and aggregates values
668
+ */
669
+ function aggregateByTime(data: any[], options: TemporalOptions): DataItem[] {
670
+ const { timeAccessor, valueAccessor, interval, aggregation = 'sum' } = options;
671
+
672
+ if (!Array.isArray(data)) {
673
+ throw new Error("Data must be an array");
674
+ }
675
+
676
+ if (typeof timeAccessor !== "function" || typeof valueAccessor !== "function") {
677
+ throw new Error("Time and value accessors must be functions");
678
+ }
679
+
680
+ // Group by time interval
681
+ const grouped = d3.rollup(
682
+ data,
683
+ (values) => {
684
+ switch (aggregation) {
685
+ case 'sum':
686
+ return d3.sum(values, valueAccessor);
687
+ case 'average':
688
+ return d3.mean(values, valueAccessor) || 0;
689
+ case 'max':
690
+ return d3.max(values, valueAccessor) || 0;
691
+ case 'min':
692
+ return d3.min(values, valueAccessor) || 0;
693
+ default:
694
+ return d3.sum(values, valueAccessor);
695
+ }
696
+ },
697
+ (d) => interval(timeAccessor(d))
698
+ );
699
+
700
+ // Convert to waterfall format
701
+ return Array.from(grouped.entries()).map(([date, value]) => ({
702
+ label: d3.timeFormat("%Y-%m-%d")(date),
703
+ stacks: [{
704
+ value: value,
705
+ color: value >= 0 ? "#2ecc71" : "#e74c3c",
706
+ label: `${value >= 0 ? "+" : ""}${d3.format(".2f")(value)}`
707
+ }]
708
+ }));
709
+ }
710
+
711
+ /**
712
+ * Create multi-dimensional waterfall from hierarchical data
713
+ * Groups by multiple keys and creates stacked waterfall segments
714
+ */
715
+ function createMultiDimensionalWaterfall(
716
+ data: any[],
717
+ groupKeys: string[],
718
+ valueKey: string
719
+ ): DataItem[] {
720
+ if (!Array.isArray(data) || !Array.isArray(groupKeys)) {
721
+ throw new Error("Data and groupKeys must be arrays");
722
+ }
723
+
724
+ if (groupKeys.length === 0) {
725
+ throw new Error("At least one group key must be provided");
726
+ }
727
+
728
+ // Create accessor functions for the keys
729
+ const accessors = groupKeys.map(key => (d: any) => d[key]);
730
+
731
+ // Use flatRollup to get flat aggregated data
732
+ const aggregated = d3.flatRollup(
733
+ data,
734
+ (values) => d3.sum(values, (d: any) => d[valueKey] || 0),
735
+ ...accessors
736
+ );
737
+
738
+ // Convert to waterfall format
739
+ return aggregated.map((item) => {
740
+ const keys = item.slice(0, -1); // All but last element
741
+ const value = item[item.length - 1]; // Last element
742
+ const label = keys.join(" → ");
743
+ const colors = ["#3498db", "#2ecc71", "#f39c12", "#e74c3c", "#9b59b6"];
744
+ const colorIndex = Math.abs(keys.join("").split("").reduce((a, b) => a + b.charCodeAt(0), 0)) % colors.length;
745
+
746
+ return {
747
+ label,
748
+ stacks: [{
749
+ value: value as number,
750
+ color: colors[colorIndex],
751
+ label: `${value >= 0 ? "+" : ""}${d3.format(".2f")(value as number)}`
752
+ }]
753
+ };
754
+ });
755
+ }
756
+
757
+ /**
758
+ * Aggregate existing waterfall data by time periods
759
+ * Useful for rolling up daily waterfalls into weekly/monthly
760
+ */
761
+ function aggregateWaterfallByPeriod(
762
+ data: DataItem[],
763
+ timeKey: string,
764
+ interval: d3.TimeInterval
765
+ ): DataItem[] {
766
+ validateData(data);
767
+
768
+ // Extract time values and group by interval
769
+ const timeGrouped = d3.rollup(
770
+ data,
771
+ (items) => {
772
+ // Aggregate all stacks across items in this time period
773
+ const allStacks: StackItem[] = [];
774
+ items.forEach(item => allStacks.push(...item.stacks));
775
+
776
+ // Group stacks by color and sum values
777
+ const stacksByColor = d3.rollup(
778
+ allStacks,
779
+ (stacks) => ({
780
+ value: d3.sum(stacks, s => s.value),
781
+ label: stacks[0].label,
782
+ color: stacks[0].color
783
+ }),
784
+ (s) => s.color
785
+ );
786
+
787
+ return Array.from(stacksByColor.values());
788
+ },
789
+ (item) => {
790
+ // Try to parse time from the item (assuming it's in the label or a property)
791
+ const timeStr = (item as any)[timeKey] || item.label;
792
+ const date = new Date(timeStr);
793
+ return isNaN(date.getTime()) ? new Date() : interval(date);
794
+ }
795
+ );
796
+
797
+ // Convert to waterfall format
798
+ return Array.from(timeGrouped.entries()).map(([date, stacks]) => ({
799
+ label: d3.timeFormat("%Y-%m-%d")(date),
800
+ stacks: stacks
801
+ }));
802
+ }
803
+
804
+ /**
805
+ * Create breakdown waterfall showing primary categories and their breakdowns
806
+ * Useful for drill-down analysis
807
+ */
808
+ function createBreakdownWaterfall(
809
+ data: any[],
810
+ primaryKey: string,
811
+ breakdownKey: string,
812
+ valueKey: string
813
+ ): DataItem[] {
814
+ if (!Array.isArray(data)) {
815
+ throw new Error("Data must be an array");
816
+ }
817
+
818
+ // First group by primary key, then by breakdown key
819
+ const nested = d3.rollup(
820
+ data,
821
+ (values) => d3.sum(values, (d: any) => d[valueKey] || 0),
822
+ (d: any) => d[primaryKey],
823
+ (d: any) => d[breakdownKey]
824
+ );
825
+
826
+ // Convert to waterfall format with stacked breakdowns
827
+ return Array.from(nested.entries()).map(([primaryValue, breakdowns]) => {
828
+ const stacks = Array.from(breakdowns.entries()).map(([breakdownValue, value], index) => {
829
+ const colors = ["#3498db", "#2ecc71", "#f39c12", "#e74c3c", "#9b59b6", "#1abc9c", "#34495e"];
830
+ return {
831
+ value: value as number,
832
+ color: colors[index % colors.length],
833
+ label: `${breakdownValue}: ${value >= 0 ? "+" : ""}${d3.format(".2f")(value as number)}`
834
+ };
835
+ });
836
+
837
+ return {
838
+ label: String(primaryValue),
839
+ stacks
840
+ };
841
+ });
842
+ }
843
+
844
+ // Internal wrapper functions for the standalone functions
845
+ async function loadDataWrapper(source: string | any[], options: LoadDataOptions = {}): Promise<DataItem[]> {
846
+ return await loadData(source, options);
847
+ }
848
+
849
+ function transformToWaterfallFormatWrapper(data: any[], options: TransformOptions = {}): DataItem[] {
850
+ return transformToWaterfallFormat(data, options);
851
+ }
852
+
853
+ return {
854
+ // Original methods
855
+ validateData,
856
+ loadData: loadDataWrapper,
857
+ transformToWaterfallFormat: transformToWaterfallFormatWrapper,
858
+ aggregateData,
859
+ sortData,
860
+ filterData,
861
+ getDataSummary,
862
+ transformData,
863
+ groupData,
864
+ transformStacks,
865
+ normalizeValues,
866
+ groupByCategory,
867
+ calculatePercentages,
868
+ interpolateData,
869
+ generateSampleData,
870
+
871
+ // Advanced D3.js data operations
872
+ groupBy,
873
+ rollupBy,
874
+ flatRollupBy,
875
+ crossTabulate,
876
+ indexBy,
877
+ aggregateByTime,
878
+ createMultiDimensionalWaterfall,
879
+ aggregateWaterfallByPeriod,
880
+ createBreakdownWaterfall
881
+ };
882
+ }
883
+
884
+ // Export a default instance for backward compatibility
885
+ export const dataProcessor = createDataProcessor();
886
+
887
+ // === STANDALONE ADVANCED DATA OPERATION HELPERS ===
888
+
889
+ /**
890
+ * Create revenue waterfall by grouping sales data by multiple dimensions
891
+ * Example: Group by Region → Product → Channel
892
+ */
893
+ export function createRevenueWaterfall(
894
+ salesData: any[],
895
+ dimensions: string[],
896
+ valueField: string = 'revenue'
897
+ ): DataItem[] {
898
+ return dataProcessor.createMultiDimensionalWaterfall(salesData, dimensions, valueField);
899
+ }
900
+
901
+ /**
902
+ * Aggregate financial data by time periods for waterfall analysis
903
+ * Example: Roll up daily P&L data into monthly waterfall
904
+ */
905
+ export function createTemporalWaterfall(
906
+ data: any[],
907
+ timeField: string,
908
+ valueField: string,
909
+ interval: 'day' | 'week' | 'month' | 'quarter' | 'year' = 'month'
910
+ ): DataItem[] {
911
+ const timeIntervals = {
912
+ day: d3.timeDay,
913
+ week: d3.timeWeek,
914
+ month: d3.timeMonth,
915
+ quarter: d3.timeMonth.every(3)!,
916
+ year: d3.timeYear
917
+ };
918
+
919
+ return dataProcessor.aggregateByTime(data, {
920
+ timeAccessor: (d) => new Date(d[timeField]),
921
+ valueAccessor: (d) => d[valueField] || 0,
922
+ interval: timeIntervals[interval],
923
+ aggregation: 'sum'
924
+ });
925
+ }
926
+
927
+ /**
928
+ * Create variance analysis waterfall comparing actuals vs budget
929
+ * Shows positive/negative variances as waterfall segments
930
+ */
931
+ export function createVarianceWaterfall(
932
+ data: any[],
933
+ categoryField: string,
934
+ actualField: string = 'actual',
935
+ budgetField: string = 'budget'
936
+ ): DataItem[] {
937
+ return data.map(item => {
938
+ const actual = item[actualField] || 0;
939
+ const budget = item[budgetField] || 0;
940
+ const variance = actual - budget;
941
+
942
+ return {
943
+ label: item[categoryField],
944
+ stacks: [{
945
+ value: variance,
946
+ color: variance >= 0 ? '#2ecc71' : '#e74c3c',
947
+ label: `Variance: ${variance >= 0 ? '+' : ''}${d3.format('.2f')(variance)}`
948
+ }]
949
+ };
950
+ });
951
+ }
952
+
953
+ /**
954
+ * Advanced data grouping with waterfall-optimized aggregation
955
+ * Supports nested grouping with automatic color assignment
956
+ */
957
+ export function groupWaterfallData<T extends Record<string, any>>(
958
+ data: T[],
959
+ groupBy: GroupByFunction<T>[],
960
+ valueAccessor: (item: T) => number,
961
+ labelAccessor?: (item: T) => string
962
+ ): DataItem[] {
963
+ const grouped = dataProcessor.flatRollupBy(
964
+ data,
965
+ (values) => d3.sum(values, valueAccessor),
966
+ ...groupBy
967
+ );
968
+
969
+ const colors = ['#3498db', '#2ecc71', '#f39c12', '#e74c3c', '#9b59b6', '#1abc9c', '#34495e', '#95a5a6'];
970
+
971
+ return grouped.map((item, index) => {
972
+ const keys = item.slice(0, -1); // All but last element
973
+ const value = item[item.length - 1]; // Last element
974
+ const label = labelAccessor && data[0]
975
+ ? keys.map((key, i) => `${Object.keys(data[0] as object)[i]}: ${key}`).join(' | ')
976
+ : keys.join(' → ');
977
+
978
+ return {
979
+ label,
980
+ stacks: [{
981
+ value: value as number,
982
+ color: colors[index % colors.length],
983
+ label: `${value >= 0 ? '+' : ''}${d3.format('.2f')(value as number)}`
984
+ }]
985
+ };
986
+ });
987
+ }
988
+
989
+ /**
990
+ * Cross-tabulate two datasets to create comparison waterfall
991
+ * Useful for period-over-period analysis
992
+ */
993
+ export function createComparisonWaterfall<T1, T2>(
994
+ currentPeriod: T1[],
995
+ previousPeriod: T2[],
996
+ categoryAccessor: (item: T1 | T2) => string,
997
+ valueAccessor: (item: T1 | T2) => number
998
+ ): DataItem[] {
999
+ // Index previous period for fast lookup
1000
+ const prevIndex = d3.index(previousPeriod, categoryAccessor);
1001
+
1002
+ return currentPeriod.map(currentItem => {
1003
+ const category = categoryAccessor(currentItem);
1004
+ const currentValue = valueAccessor(currentItem);
1005
+ const prevItem = prevIndex.get(category);
1006
+ const prevValue = prevItem ? valueAccessor(prevItem) : 0;
1007
+ const change = currentValue - prevValue;
1008
+
1009
+ return {
1010
+ label: category,
1011
+ stacks: [{
1012
+ value: change,
1013
+ color: change >= 0 ? '#2ecc71' : '#e74c3c',
1014
+ label: `Change: ${change >= 0 ? '+' : ''}${d3.format('.2f')(change)}`
1015
+ }]
1016
+ };
1017
+ });
1018
+ }
1019
+
1020
+ /**
1021
+ * Transform flat transaction data into hierarchical waterfall
1022
+ * Automatically detects categories and subcategories
1023
+ */
1024
+ export function transformTransactionData(
1025
+ transactions: any[],
1026
+ categoryField: string,
1027
+ subcategoryField?: string,
1028
+ valueField: string = 'amount',
1029
+ dateField?: string
1030
+ ): DataItem[] {
1031
+ if (subcategoryField) {
1032
+ // Two-level breakdown
1033
+ return dataProcessor.createBreakdownWaterfall(
1034
+ transactions,
1035
+ categoryField,
1036
+ subcategoryField,
1037
+ valueField
1038
+ );
1039
+ } else {
1040
+ // Simple category aggregation
1041
+ const aggregated = dataProcessor.rollupBy(
1042
+ transactions,
1043
+ (values) => d3.sum(values, (d: any) => d[valueField] || 0),
1044
+ (d: any) => d[categoryField]
1045
+ ) as Map<string, number>;
1046
+
1047
+ const colors = ['#3498db', '#2ecc71', '#f39c12', '#e74c3c', '#9b59b6'];
1048
+ let colorIndex = 0;
1049
+
1050
+ return Array.from(aggregated.entries()).map(([category, value]) => ({
1051
+ label: String(category),
1052
+ stacks: [{
1053
+ value: value,
1054
+ color: colors[colorIndex++ % colors.length],
1055
+ label: `${value >= 0 ? '+' : ''}${d3.format('.2f')(value)}`
1056
+ }]
1057
+ }));
1058
+ }
1059
+ }
1060
+
1061
+ /**
1062
+ * Create common financial reducers for rollup operations
1063
+ */
1064
+ export const financialReducers = {
1065
+ sum: (values: any[]) => d3.sum(values, (d: any) => d.value || 0),
1066
+ average: (values: any[]) => d3.mean(values, (d: any) => d.value || 0) || 0,
1067
+ weightedAverage: (values: any[], weightField: string = 'weight') => {
1068
+ const totalWeight = d3.sum(values, (d: any) => d[weightField] || 0);
1069
+ if (totalWeight === 0) return 0;
1070
+ return d3.sum(values, (d: any) => (d.value || 0) * (d[weightField] || 0)) / totalWeight;
1071
+ },
1072
+ variance: (values: any[]) => {
1073
+ const mean = d3.mean(values, (d: any) => d.value || 0) || 0;
1074
+ return d3.mean(values, (d: any) => Math.pow((d.value || 0) - mean, 2)) || 0;
1075
+ },
1076
+ percentile: (p: number) => (values: any[]) => {
1077
+ const sorted = values.map((d: any) => d.value || 0).sort(d3.ascending);
1078
+ return d3.quantile(sorted, p / 100) || 0;
1079
+ }
1080
+ };
1081
+
1082
+ /**
1083
+ * Export commonly used D3.js data manipulation functions for convenience
1084
+ */
1085
+ export const d3DataUtils = {
1086
+ group: d3.group,
1087
+ rollup: d3.rollup,
1088
+ flatRollup: d3.flatRollup,
1089
+ cross: d3.cross,
1090
+ index: d3.index,
1091
+ sum: d3.sum,
1092
+ mean: d3.mean,
1093
+ median: d3.median,
1094
+ quantile: d3.quantile,
1095
+ min: d3.min,
1096
+ max: d3.max,
1097
+ extent: d3.extent,
1098
+ ascending: d3.ascending,
1099
+ descending: d3.descending
1100
+ };