mintwaterfall
Version:
A powerful, D3.js-compatible waterfall chart component with enterprise features including breakdown analysis, conditional formatting, stacking capabilities, animations, and extensive customization options
648 lines (559 loc) • 22.3 kB
text/typescript
// MintWaterfall - D3.js compatible hierarchical layout system - TypeScript Version
// Implements d3.hierarchy, d3.treemap, d3.partition, and other layout algorithms with full type safety
import * as d3 from 'd3';
// Type definitions for hierarchical layout system
export interface HierarchicalData {
id?: string;
name?: string;
value?: number;
data?: any;
children?: HierarchicalData[];
}
export interface LayoutOptions {
size?: [number, number];
padding?: number;
round?: boolean;
type?: LayoutType;
paddingInner?: number;
paddingOuter?: number;
paddingTop?: number;
paddingRight?: number;
paddingBottom?: number;
paddingLeft?: number;
ratio?: number;
orientation?: "horizontal" | "vertical";
nodeSize?: [number, number] | null;
separation?: ((a: d3.HierarchyNode<any>, b: d3.HierarchyNode<any>) => number) | null;
}
export interface HierarchyOptions {
idAccessor?: (d: any) => string;
parentAccessor?: (d: any) => string | null;
valueAccessor?: (d: any) => number;
sort?: (a: d3.HierarchyNode<any>, b: d3.HierarchyNode<any>) => number;
includeRoot?: boolean;
includeInternal?: boolean;
maxDepth?: number;
}
export interface ExtractionOptions {
includeRoot?: boolean;
includeInternal?: boolean;
maxDepth?: number;
}
export interface ConversionOptions extends ExtractionOptions {
colorScale?: any;
}
export interface ExtractedNode {
id: string;
name: string;
value: number;
depth: number;
height: number;
parent: string | null;
x0: number;
y0: number;
x1: number;
y1: number;
r?: number;
originalData: any;
}
export interface WaterfallStackItem {
value: number;
color: string;
label: string;
}
export interface WaterfallFormatNode {
label: string;
stacks: WaterfallStackItem[];
hierarchyData: {
id: string;
depth: number;
height: number;
parent: string | null;
x0: number;
y0: number;
x1: number;
y1: number;
r?: number;
};
}
export type LayoutType = "treemap" | "partition" | "pack" | "cluster" | "tree";
export interface PartitionOptions {
orientation: "horizontal" | "vertical";
}
export interface TreeOptions {
nodeSize: [number, number] | null;
separation: ((a: d3.HierarchyNode<any>, b: d3.HierarchyNode<any>) => number) | null;
}
// Extended interfaces for D3 hierarchy nodes with layout properties
export interface LayoutNode extends d3.HierarchyNode<any> {
x0?: number;
y0?: number;
x1?: number;
y1?: number;
r?: number;
}
export interface HierarchicalLayout {
(data: d3.HierarchyNode<any>): LayoutNode;
size(): [number, number];
size(size: [number, number]): HierarchicalLayout;
padding(): number;
padding(padding: number): HierarchicalLayout;
paddingInner(): number;
paddingInner(padding: number): HierarchicalLayout;
paddingOuter(): number;
paddingOuter(padding: number): HierarchicalLayout;
paddingTop(): number;
paddingTop(padding: number): HierarchicalLayout;
paddingRight(): number;
paddingRight(padding: number): HierarchicalLayout;
paddingBottom(): number;
paddingBottom(padding: number): HierarchicalLayout;
paddingLeft(): number;
paddingLeft(padding: number): HierarchicalLayout;
round(): boolean;
round(round: boolean): HierarchicalLayout;
ratio(): number;
ratio(ratio: number): HierarchicalLayout;
type(): LayoutType;
type(type: LayoutType): HierarchicalLayout;
partitionOrientation(): "horizontal" | "vertical";
partitionOrientation(orientation: "horizontal" | "vertical"): HierarchicalLayout;
nodeSize(): [number, number] | null;
nodeSize(size: [number, number] | null): HierarchicalLayout;
separation(): ((a: d3.HierarchyNode<any>, b: d3.HierarchyNode<any>) => number) | null;
separation(separation: ((a: d3.HierarchyNode<any>, b: d3.HierarchyNode<any>) => number) | null): HierarchicalLayout;
}
export interface HierarchyLayouts {
treemap(data: HierarchicalData | any[], options?: LayoutOptions & HierarchyOptions): LayoutNode;
partition(data: HierarchicalData | any[], options?: LayoutOptions & HierarchyOptions): LayoutNode;
pack(data: HierarchicalData | any[], options?: LayoutOptions & HierarchyOptions): LayoutNode;
}
/**
* Creates a hierarchical layout system for advanced data visualization
* @returns {HierarchicalLayout} Layout system API
*/
export function createHierarchicalLayout(): HierarchicalLayout {
let size: [number, number] = [800, 400];
let padding = 0;
let round = false;
let layoutType: LayoutType = "treemap";
let paddingInner = 0;
let paddingOuter = 0;
let paddingTop = 0;
let paddingRight = 0;
let paddingBottom = 0;
let paddingLeft = 0;
let ratio = 1.618033988749895; // Golden ratio by default
// Additional layout-specific options
let partitionOptions: PartitionOptions = {
orientation: "horizontal"
};
let treeOptions: TreeOptions = {
nodeSize: null,
separation: null
};
/**
* Main layout function that processes hierarchical data
* @param {d3.HierarchyNode<any>} data - d3.hierarchy compatible data
* @returns {LayoutNode} Processed layout data
*/
function layout(data: d3.HierarchyNode<any>): LayoutNode {
if (!data) {
console.error("MintWaterfall: No hierarchical data provided to layout");
throw new Error("No hierarchical data provided");
}
// Ensure we have a d3.hierarchy object with proper sum calculation
const root = data;
// Apply the selected layout
switch (layoutType) {
case "treemap":
return applyTreemapLayout(root);
case "partition":
return applyPartitionLayout(root);
case "pack":
return applyPackLayout(root);
case "cluster":
return applyClusterLayout(root);
case "tree":
return applyTreeLayout(root);
default:
console.warn(`MintWaterfall: Unknown layout type '${layoutType}', falling back to treemap`);
return applyTreemapLayout(root);
}
}
/**
* Applies treemap layout to hierarchical data
* @param {d3.HierarchyNode<any>} root - d3.hierarchy data
* @returns {d3.HierarchyNode<any>} Processed layout data
*/
function applyTreemapLayout(root: d3.HierarchyNode<any>): LayoutNode {
const treemap = d3.treemap()
.size(size)
.round(round)
.padding(padding);
// Apply additional padding options if they exist
if (paddingInner !== undefined) treemap.paddingInner(paddingInner);
if (paddingOuter !== undefined) treemap.paddingOuter(paddingOuter);
if (paddingTop !== undefined) treemap.paddingTop(paddingTop);
if (paddingRight !== undefined) treemap.paddingRight(paddingRight);
if (paddingBottom !== undefined) treemap.paddingBottom(paddingBottom);
if (paddingLeft !== undefined) treemap.paddingLeft(paddingLeft);
return treemap(root) as LayoutNode;
}
/**
* Applies partition layout to hierarchical data
* @param {d3.HierarchyNode<any>} root - d3.hierarchy data
* @returns {LayoutNode} Processed layout data
*/
function applyPartitionLayout(root: d3.HierarchyNode<any>): LayoutNode {
const partitionLayout = d3.partition()
.size(size)
.round(round)
.padding(padding);
// Apply partition layout
const result = partitionLayout(root) as LayoutNode;
// Handle orientation for partition layout
if (partitionOptions.orientation === "vertical") {
// Swap x/y coordinates and dimensions for vertical orientation
result.each((node: LayoutNode) => {
if (node.x0 !== undefined && node.y0 !== undefined &&
node.x1 !== undefined && node.y1 !== undefined) {
const temp = node.x0;
node.x0 = node.y0;
node.y0 = temp;
const tempX1 = node.x1;
node.x1 = node.y1;
node.y1 = tempX1;
}
});
}
return result;
}
/**
* Applies pack layout to hierarchical data
* @param {d3.HierarchyNode<any>} root - d3.hierarchy data
* @returns {LayoutNode} Processed layout data
*/
function applyPackLayout(root: d3.HierarchyNode<any>): LayoutNode {
return d3.pack()
.size(size)
.padding(padding)
(root) as LayoutNode;
}
/**
* Applies cluster layout to hierarchical data
* @param {d3.HierarchyNode<any>} root - d3.hierarchy data
* @returns {LayoutNode} Processed layout data
*/
function applyClusterLayout(root: d3.HierarchyNode<any>): LayoutNode {
const clusterLayout = d3.cluster()
.size(size);
if (treeOptions.nodeSize) {
clusterLayout.nodeSize(treeOptions.nodeSize);
}
if (treeOptions.separation) {
clusterLayout.separation(treeOptions.separation);
}
return clusterLayout(root) as LayoutNode;
}
/**
* Applies tree layout to hierarchical data
* @param {d3.HierarchyNode<any>} root - d3.hierarchy data
* @returns {LayoutNode} Processed layout data
*/
function applyTreeLayout(root: d3.HierarchyNode<any>): LayoutNode {
const treeLayout = d3.tree()
.size(size);
if (treeOptions.nodeSize) {
treeLayout.nodeSize(treeOptions.nodeSize);
}
if (treeOptions.separation) {
treeLayout.separation(treeOptions.separation);
}
return treeLayout(root) as LayoutNode;
}
// Create layout object with simple object assignment
const hierarchicalLayout = layout as any;
// Add chainable methods
hierarchicalLayout.size = function(_?: [number, number]) {
return arguments.length ? (size = _!, hierarchicalLayout) : size;
};
hierarchicalLayout.padding = function(_?: number) {
return arguments.length ? (padding = _!, hierarchicalLayout) : padding;
};
hierarchicalLayout.paddingInner = function(_?: number) {
return arguments.length ? (paddingInner = _!, hierarchicalLayout) : paddingInner;
};
hierarchicalLayout.paddingOuter = function(_?: number) {
return arguments.length ? (paddingOuter = _!, hierarchicalLayout) : paddingOuter;
};
hierarchicalLayout.paddingTop = function(_?: number) {
return arguments.length ? (paddingTop = _!, hierarchicalLayout) : paddingTop;
};
hierarchicalLayout.paddingRight = function(_?: number) {
return arguments.length ? (paddingRight = _!, hierarchicalLayout) : paddingRight;
};
hierarchicalLayout.paddingBottom = function(_?: number) {
return arguments.length ? (paddingBottom = _!, hierarchicalLayout) : paddingBottom;
};
hierarchicalLayout.paddingLeft = function(_?: number) {
return arguments.length ? (paddingLeft = _!, hierarchicalLayout) : paddingLeft;
};
hierarchicalLayout.round = function(_?: boolean) {
return arguments.length ? (round = _!, hierarchicalLayout) : round;
};
hierarchicalLayout.ratio = function(_?: number) {
return arguments.length ? (ratio = _!, hierarchicalLayout) : ratio;
};
hierarchicalLayout.type = function(_?: LayoutType) {
return arguments.length ? (layoutType = _!, hierarchicalLayout) : layoutType;
};
hierarchicalLayout.partitionOrientation = function(_?: "horizontal" | "vertical") {
return arguments.length ? (partitionOptions.orientation = _!, hierarchicalLayout) : partitionOptions.orientation;
};
hierarchicalLayout.nodeSize = function(_?: [number, number] | null) {
return arguments.length ? (treeOptions.nodeSize = _!, hierarchicalLayout) : treeOptions.nodeSize;
};
hierarchicalLayout.separation = function(_?: ((a: d3.HierarchyNode<any>, b: d3.HierarchyNode<any>) => number) | null) {
return arguments.length ? (treeOptions.separation = _!, hierarchicalLayout) : treeOptions.separation;
};
return hierarchicalLayout as HierarchicalLayout;
}
/**
* Helper function to create hierarchical data structure from flat data
* @param {any[]} data - Flat data array
* @param {(d: any) => string} idAccessor - Function to get node ID
* @param {(d: any) => string | null} parentAccessor - Function to get parent ID
* @param {(d: any) => number} valueAccessor - Function to get node value
* @returns {HierarchicalData | null} Hierarchical data structure
*/
export function createHierarchyFromFlatData(
data: any[],
idAccessor: (d: any) => string,
parentAccessor: (d: any) => string | null,
valueAccessor?: (d: any) => number
): HierarchicalData | null {
if (!data || !Array.isArray(data) || data.length === 0) {
console.error("MintWaterfall: Invalid data provided to createHierarchyFromFlatData");
return null;
}
// Create map for fast lookup
const dataMap = new Map<string, HierarchicalData>();
// First pass: create nodes
const root: HierarchicalData = {
id: "root",
name: "Root",
children: []
};
dataMap.set("root", root);
// Create nodes for each data item
data.forEach(item => {
const id = idAccessor(item);
if (!id) {
console.warn("MintWaterfall: Item missing ID in createHierarchyFromFlatData");
return;
}
const node: HierarchicalData = {
id,
name: id,
data: item,
value: valueAccessor ? valueAccessor(item) : undefined,
children: []
};
dataMap.set(id, node);
});
// Second pass: establish parent-child relationships
data.forEach(item => {
const id = idAccessor(item);
const parentId = parentAccessor(item) || "root";
const node = dataMap.get(id);
const parent = dataMap.get(parentId);
if (parent && node && parent.children) {
parent.children.push(node);
}
});
// Return the root node
return root;
}
/**
* Creates a d3.hierarchy object from data
* @param {HierarchicalData | any[]} data - Hierarchical data or flat data array
* @param {HierarchyOptions} options - Configuration options
* @returns {d3.HierarchyNode<any> | null} d3.hierarchy object
*/
export function createHierarchy(
data: HierarchicalData | any[],
options: HierarchyOptions = {}
): d3.HierarchyNode<any> | null {
if (!data) {
console.error("MintWaterfall: No data provided to createHierarchy");
return null;
}
let hierarchyData: HierarchicalData | null;
// If data is flat array and we have accessor functions, convert to hierarchical
if (Array.isArray(data) && options.idAccessor && options.parentAccessor) {
hierarchyData = createHierarchyFromFlatData(
data,
options.idAccessor,
options.parentAccessor,
options.valueAccessor
);
} else {
// Assume data is already in hierarchical format
hierarchyData = data as HierarchicalData;
}
if (!hierarchyData) {
return null;
}
// Create d3.hierarchy object
const hierarchy = d3.hierarchy(hierarchyData);
// Apply value accessor if provided
if (options.valueAccessor) {
hierarchy.sum(d => {
// For leaf nodes, use the value accessor
if (!d.children || d.children.length === 0) {
return d.value !== undefined ? d.value : (options.valueAccessor!(d) || 0);
}
// For parent nodes, return 0 (sum will be calculated from children)
return 0;
});
} else {
hierarchy.sum(d => d.value || 0);
}
// Apply sorting if provided
if (options.sort) {
hierarchy.sort(options.sort);
}
return hierarchy;
}
/**
* Helper function to extract data from a hierarchical layout
* @param {d3.HierarchyNode<any>} layoutData - Processed layout data
* @param {ExtractionOptions} options - Extraction options
* @returns {ExtractedNode[]} Extracted data
*/
export function extractLayoutData(
layoutData: LayoutNode,
options: ExtractionOptions = {}
): ExtractedNode[] {
if (!layoutData) {
console.error("MintWaterfall: No layout data provided to extractLayoutData");
return [];
}
const result: ExtractedNode[] = [];
const includeRoot = options.includeRoot || false;
const includeInternal = options.includeInternal || false;
const maxDepth = options.maxDepth || Infinity;
// Traverse the hierarchy
layoutData.each((node: LayoutNode) => {
// Skip root if not included
if (!includeRoot && !node.parent) {
return;
}
// Skip internal nodes if not included
if (!includeInternal && node.children && node.children.length > 0) {
return;
}
// Skip nodes beyond max depth
if (node.depth > maxDepth) {
return;
}
// Extract node data with proper type casting
result.push({
id: node.data.id || `node-${node.depth}-${node.height}`,
name: node.data.name || "",
value: node.value!,
depth: node.depth,
height: node.height,
parent: node.parent ? (node.parent.data.id || `node-${node.parent.depth}-${node.parent.height}`) : null,
x0: node.x0 || 0,
y0: node.y0 || 0,
x1: node.x1 || 0,
y1: node.y1 || 0,
r: node.r,
originalData: node.data
});
});
return result;
}
/**
* Helper function to convert hierarchical layout to waterfall-compatible format
* @param {d3.HierarchyNode<any>} layoutData - Processed layout data
* @param {ConversionOptions} options - Conversion options
* @returns {WaterfallFormatNode[]} Waterfall-compatible data
*/
export function convertToWaterfallFormat(
layoutData: LayoutNode,
options: ConversionOptions = {}
): WaterfallFormatNode[] {
if (!layoutData) {
console.error("MintWaterfall: No layout data provided to convertToWaterfallFormat");
return [];
}
const extractedData = extractLayoutData(layoutData, {
includeRoot: options.includeRoot || false,
includeInternal: options.includeInternal || true,
maxDepth: options.maxDepth || 2
});
// Generate color scale if not provided
const colorScale = options.colorScale || d3.scaleOrdinal(d3.schemeCategory10);
// Convert to waterfall format
return extractedData.map(node => {
return {
label: node.name,
stacks: [{
value: node.value,
color: typeof colorScale === "function" ? colorScale(node.depth.toString()) : colorScale,
label: node.value.toString()
}],
hierarchyData: {
id: node.id,
depth: node.depth,
height: node.height,
parent: node.parent,
x0: node.x0,
y0: node.y0,
x1: node.x1,
y1: node.y1,
r: node.r
}
};
});
}
// Export layout helpers for direct use
export const hierarchyLayouts: HierarchyLayouts = {
treemap: function(data: HierarchicalData | any[], options: LayoutOptions & HierarchyOptions = {}) {
const layout = createHierarchicalLayout()
.type("treemap")
.size(options.size || [800, 400])
.padding(options.padding || 1);
const hierarchy = createHierarchy(data, options);
if (!hierarchy) {
throw new Error("Failed to create hierarchy");
}
return layout(hierarchy);
},
partition: function(data: HierarchicalData | any[], options: LayoutOptions & HierarchyOptions = {}) {
const layout = createHierarchicalLayout()
.type("partition")
.size(options.size || [800, 400])
.padding(options.padding || 1)
.partitionOrientation(options.orientation || "horizontal");
const hierarchy = createHierarchy(data, options);
if (!hierarchy) {
throw new Error("Failed to create hierarchy");
}
return layout(hierarchy);
},
pack: function(data: HierarchicalData | any[], options: LayoutOptions & HierarchyOptions = {}) {
const layout = createHierarchicalLayout()
.type("pack")
.size(options.size || [800, 400])
.padding(options.padding || 1);
const hierarchy = createHierarchy(data, options);
if (!hierarchy) {
throw new Error("Failed to create hierarchy");
}
return layout(hierarchy);
}
};