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.
- package/CHANGELOG.md +223 -0
- package/CONTRIBUTING.md +199 -0
- package/README.md +363 -0
- package/dist/index.d.ts +149 -0
- package/dist/mintwaterfall.cjs.js +7978 -0
- package/dist/mintwaterfall.esm.js +7907 -0
- package/dist/mintwaterfall.min.js +7 -0
- package/dist/mintwaterfall.umd.js +7978 -0
- package/index.d.ts +149 -0
- package/package.json +126 -0
- package/src/enterprise/enterprise-core.js +0 -0
- package/src/enterprise/enterprise-feature-template.js +0 -0
- package/src/enterprise/feature-registry.js +0 -0
- package/src/enterprise/features/breakdown.js +0 -0
- package/src/features/breakdown.js +0 -0
- package/src/features/conditional-formatting.js +0 -0
- package/src/index.js +111 -0
- package/src/mintwaterfall-accessibility.ts +680 -0
- package/src/mintwaterfall-advanced-data.ts +1034 -0
- package/src/mintwaterfall-advanced-interactions.ts +649 -0
- package/src/mintwaterfall-advanced-performance.ts +582 -0
- package/src/mintwaterfall-animations.ts +595 -0
- package/src/mintwaterfall-brush.ts +471 -0
- package/src/mintwaterfall-chart-core.ts +296 -0
- package/src/mintwaterfall-chart.ts +1915 -0
- package/src/mintwaterfall-data.ts +1100 -0
- package/src/mintwaterfall-export.ts +475 -0
- package/src/mintwaterfall-hierarchical-layouts.ts +724 -0
- package/src/mintwaterfall-layouts.ts +647 -0
- package/src/mintwaterfall-performance.ts +573 -0
- package/src/mintwaterfall-scales.ts +437 -0
- package/src/mintwaterfall-shapes.ts +385 -0
- package/src/mintwaterfall-statistics.ts +821 -0
- package/src/mintwaterfall-themes.ts +391 -0
- package/src/mintwaterfall-tooltip.ts +450 -0
- package/src/mintwaterfall-zoom.ts +399 -0
- package/src/types/js-modules.d.ts +25 -0
- 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
|
+
};
|