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
1,228 lines (1,019 loc) • 80.5 kB
text/typescript
// MintWaterfall - D3.js compatible waterfall chart component (TypeScript)
// Usage: d3.waterfallChart().width(800).height(400).showTotal(true)(selection)
import * as d3 from 'd3';
// Import TypeScript modules where available
import { DataItem, StackItem, ProcessedDataItem, dataProcessor, createDataProcessor } from './mintwaterfall-data.js';
import { createScaleSystem, createTimeScale, createOrdinalScale } from './mintwaterfall-scales.js';
// Import JavaScript modules for remaining components during gradual migration
import { createBrushSystem } from "./mintwaterfall-brush.js";
import { createAccessibilitySystem } from "./mintwaterfall-accessibility.js";
import { createTooltipSystem } from "./mintwaterfall-tooltip.js";
import { createExportSystem } from "./mintwaterfall-export.js";
import { createZoomSystem } from "./mintwaterfall-zoom.js";
import { createPerformanceManager } from "./mintwaterfall-performance.js";
// NEW: Import advanced features
import {
createSequentialScale,
createDivergingScale,
getConditionalColor,
createWaterfallColorScale,
interpolateThemeColor,
getAdvancedBarColor,
ThemeCollection
} from './mintwaterfall-themes.js';
import {
createShapeGenerators,
createWaterfallConfidenceBands,
createWaterfallMilestones
} from './mintwaterfall-shapes.js';
// NEW: Import MEDIUM PRIORITY analytical enhancement features
import {
createAdvancedDataProcessor,
createWaterfallSequenceAnalyzer,
createWaterfallTickGenerator
} from './mintwaterfall-advanced-data.js';
import {
createAdvancedInteractionSystem,
createWaterfallDragBehavior,
createWaterfallVoronoiConfig,
createWaterfallForceConfig
} from './mintwaterfall-advanced-interactions.js';
import {
createHierarchicalLayoutSystem,
createWaterfallTreemap,
createWaterfallSunburst,
createWaterfallBubbles
} from './mintwaterfall-hierarchical-layouts.js';
// Type definitions
export interface StackData {
value: number;
color: string;
label?: string;
}
export interface ChartData {
label: string;
stacks: StackData[];
}
export interface ProcessedData extends ChartData {
barTotal: number;
cumulativeTotal: number;
prevCumulativeTotal?: number;
stackPositions?: Array<{ start: number; end: number; color: string; value: number; label?: string }>;
}
export interface MarginConfig {
top: number;
right: number;
bottom: number;
left: number;
}
export interface BrushOptions {
extent?: [[number, number], [number, number]];
handleSize?: number;
[key: string]: any;
}
export interface TooltipConfig {
enabled?: boolean;
className?: string;
offset?: { x: number; y: number };
[key: string]: any;
}
export interface ExportConfig {
formats?: string[];
filename?: string;
[key: string]: any;
}
export interface ZoomConfig {
scaleExtent?: [number, number];
translateExtent?: [[number, number], [number, number]];
[key: string]: any;
}
export interface BreakdownConfig {
enabled: boolean;
levels: number;
field?: string;
minGroupSize?: number;
sortStrategy?: string;
showOthers?: boolean;
othersLabel?: string;
maxGroups?: number;
}
// NEW: Advanced feature configurations
export interface AdvancedColorConfig {
enabled: boolean;
scaleType: 'auto' | 'sequential' | 'diverging' | 'conditional';
themeName?: string;
customColorScale?: (value: number) => string;
neutralThreshold?: number;
}
export interface ConfidenceBandConfig {
enabled: boolean;
scenarios?: {
optimistic: Array<{label: string, value: number}>;
pessimistic: Array<{label: string, value: number}>;
};
opacity?: number;
showTrendLines?: boolean;
}
export interface MilestoneConfig {
enabled: boolean;
milestones: Array<{
label: string;
value: number;
type: 'target' | 'threshold' | 'alert' | 'achievement';
description?: string;
}>;
}
export interface BarEventHandler {
(event: Event, data: ProcessedData): void;
}
export interface WaterfallChart {
// Core configuration methods
width(): number;
width(value: number): WaterfallChart;
height(): number;
height(value: number): WaterfallChart;
margin(): MarginConfig;
margin(value: MarginConfig): WaterfallChart;
// Data and display options
stacked(): boolean;
stacked(value: boolean): WaterfallChart;
showTotal(): boolean;
showTotal(value: boolean): WaterfallChart;
totalLabel(): string;
totalLabel(value: string): WaterfallChart;
totalColor(): string;
totalColor(value: string): WaterfallChart;
barPadding(): number;
barPadding(value: number): WaterfallChart;
// Animation and transitions
duration(): number;
duration(value: number): WaterfallChart;
ease(): (t: number) => number;
ease(value: (t: number) => number): WaterfallChart;
// Formatting
formatNumber(): (n: number) => string;
formatNumber(value: (n: number) => string): WaterfallChart;
theme(): string | null;
theme(value: string | null): WaterfallChart;
// Advanced features
enableBrush(): boolean;
enableBrush(value: boolean): WaterfallChart;
brushOptions(): BrushOptions;
brushOptions(value: BrushOptions): WaterfallChart;
// NEW: Advanced color features
enableAdvancedColors(): boolean;
enableAdvancedColors(value: boolean): WaterfallChart;
colorMode(): 'default' | 'conditional' | 'sequential' | 'diverging';
colorMode(value: 'default' | 'conditional' | 'sequential' | 'diverging'): WaterfallChart;
colorTheme(): string;
colorTheme(value: string): WaterfallChart;
neutralThreshold(): number;
neutralThreshold(value: number): WaterfallChart;
staggeredAnimations(): boolean;
staggeredAnimations(value: boolean): WaterfallChart;
staggerDelay(): number;
staggerDelay(value: number): WaterfallChart;
scaleType(): string;
scaleType(value: string): WaterfallChart;
// Trend line features
showTrendLine(): boolean;
showTrendLine(value: boolean): WaterfallChart;
trendLineColor(): string;
trendLineColor(value: string): WaterfallChart;
trendLineWidth(): number;
trendLineWidth(value: number): WaterfallChart;
trendLineStyle(): string;
trendLineStyle(value: string): WaterfallChart;
trendLineOpacity(): number;
trendLineOpacity(value: number): WaterfallChart;
trendLineType(): string;
trendLineType(value: string): WaterfallChart;
trendLineWindow(): number;
trendLineWindow(value: number): WaterfallChart;
trendLineDegree(): number;
trendLineDegree(value: number): WaterfallChart;
// Accessibility and UX features
enableAccessibility(): boolean;
enableAccessibility(value: boolean): WaterfallChart;
enableTooltips(): boolean;
enableTooltips(value: boolean): WaterfallChart;
tooltipConfig(): TooltipConfig;
tooltipConfig(value: TooltipConfig): WaterfallChart;
enableExport(): boolean;
enableExport(value: boolean): WaterfallChart;
exportConfig(): ExportConfig;
exportConfig(value: ExportConfig): WaterfallChart;
enableZoom(): boolean;
enableZoom(value: boolean): WaterfallChart;
zoomConfig(): ZoomConfig;
zoomConfig(value: ZoomConfig): WaterfallChart;
// Enterprise features
breakdownConfig(): BreakdownConfig | null;
breakdownConfig(value: BreakdownConfig | null): WaterfallChart;
// Performance features
enablePerformanceOptimization(): boolean;
enablePerformanceOptimization(value: boolean): WaterfallChart;
performanceDashboard(): boolean;
performanceDashboard(value: boolean): WaterfallChart;
virtualizationThreshold(): number;
virtualizationThreshold(value: number): WaterfallChart;
// Event handling
on(event: string, handler: BarEventHandler | null): WaterfallChart;
// Note: MEDIUM PRIORITY analytical enhancement features are available
// via the exported utility functions but not integrated into the main chart API
// Internal system instances
zoomSystemInstance?: any;
// Rendering
(selection: d3.Selection<any, any, any, any>): void;
}
// Utility function to get bar width from any scale type
function getBarWidth(scale: any, barCount: number, totalWidth: number): number {
if (scale.bandwidth) {
// Band scale has bandwidth method - use it directly
const bandwidth = scale.bandwidth();
// Using band scale bandwidth
return bandwidth;
} else {
// For continuous scales, calculate width based on bar count
const padding = 0.1;
const availableWidth = totalWidth * (1 - padding);
const calculatedWidth = availableWidth / barCount;
// Calculated width for continuous scale
return calculatedWidth;
}
}
// Utility function to get bar position from any scale type
function getBarPosition(scale: any, value: any, barWidth: number): number {
if (scale.bandwidth) {
// Band scale - use scale directly
return scale(value);
} else {
// Continuous scale - center the bar around the scale value
return scale(value) - barWidth / 2;
}
}
export function waterfallChart(): WaterfallChart {
let width: number = 800;
let height: number = 400;
let margin: MarginConfig = { top: 60, right: 80, bottom: 60, left: 80 };
let showTotal: boolean = false;
let totalLabel: string = "Total";
let totalColor: string = "#95A5A6";
let stacked: boolean = false;
let barPadding: number = 0.05;
let duration: number = 750;
let ease: (t: number) => number = d3.easeQuadInOut;
let formatNumber: (n: number) => string = d3.format(".0f");
let theme: string | null = null;
// Advanced features
let enableBrush: boolean = false;
let brushOptions: BrushOptions = {};
let staggeredAnimations: boolean = false;
let staggerDelay: number = 100;
let scaleType: string = "auto"; // 'auto', 'linear', 'time', 'ordinal'
// NEW: Advanced color and shape features
let advancedColorConfig: AdvancedColorConfig = {
enabled: false,
scaleType: 'auto',
themeName: 'default',
neutralThreshold: 0
};
// Advanced color mode for enhanced visual impact
let colorMode: 'default' | 'conditional' | 'sequential' | 'diverging' = 'conditional';
let confidenceBandConfig: ConfidenceBandConfig = {
enabled: false,
opacity: 0.3,
showTrendLines: true
};
let milestoneConfig: MilestoneConfig = {
enabled: false,
milestones: []
};
// Note: Advanced analytical enhancement feature variables removed
// Features are available via exported utility functions
// Note: Hierarchical layout variables removed
// Features are available via exported utility functions
// Trend line features
let showTrendLine: boolean = false;
let trendLineColor: string = "#e74c3c";
let trendLineWidth: number = 2;
let trendLineStyle: string = "solid"; // 'solid', 'dashed', 'dotted'
let trendLineOpacity: number = 0.8;
let trendLineType: string = "linear"; // 'linear', 'moving-average', 'polynomial'
let trendLineWindow: number = 3; // Moving average window size
let trendLineDegree: number = 2; // Polynomial degree
// Accessibility and UX features
let enableAccessibility: boolean = true;
let enableTooltips: boolean = false;
let tooltipConfig: TooltipConfig = {};
let enableExport: boolean = true;
let exportConfig: ExportConfig = {};
let enableZoom: boolean = false;
let zoomConfig: ZoomConfig = {};
// Enterprise features
let breakdownConfig: BreakdownConfig | null = null;
let formattingRules: Map<string, any> = new Map();
// Performance features
let lastDataHash: string | null = null;
let cachedProcessedData: ProcessedData[] | null = null;
// Initialize systems
const scaleSystem = createScaleSystem();
const brushSystem = createBrushSystem();
const accessibilitySystem = createAccessibilitySystem();
const tooltipSystem = createTooltipSystem();
const exportSystem = createExportSystem();
const zoomSystem = createZoomSystem();
// NEW: Initialize advanced feature systems
const shapeGeneratorSystem = createShapeGenerators();
const performanceManager = createPerformanceManager();
// Note: Advanced analytical enhancement system instances removed
// Systems are available via exported utility functions
// Performance configuration
let enablePerformanceOptimization: boolean = false;
let performanceDashboard: boolean = false;
let virtualizationThreshold: number = 10000;
// Event listeners - enhanced with brush events
const listeners = d3.dispatch("barClick", "barMouseover", "barMouseout", "chartUpdate", "brushSelection");
function chart(selection: d3.Selection<any, any, any, any>): void {
selection.each(function(data: ChartData[]) {
// Data validation
if (!data || !Array.isArray(data)) {
console.warn("MintWaterfall: Invalid data provided. Expected an array.");
return;
}
if (data.length === 0) {
console.warn("MintWaterfall: Empty data array provided.");
return;
}
// Validate data structure
const isValidData = data.every(item =>
item &&
typeof item.label === "string" &&
Array.isArray(item.stacks) &&
item.stacks.every(stack =>
typeof stack.value === "number" &&
typeof stack.color === "string"
)
);
if (!isValidData) {
console.error("MintWaterfall: Invalid data structure. Each item must have a 'label' string and 'stacks' array with 'value' numbers and 'color' strings.");
return;
}
// Handle both div containers and existing SVG elements
const element = d3.select(this);
let svg: any;
if (this.tagName === 'svg') {
// Already an SVG element
svg = element;
} else {
// Container element (div) - create or select SVG
svg = element.selectAll('svg').data([0]);
const svgEnter = svg.enter().append('svg');
svg = svgEnter.merge(svg);
// Set SVG dimensions
svg.attr('width', width).attr('height', height);
}
// Get actual SVG dimensions from attributes if available
const svgNode = svg.node() as SVGSVGElement;
if (svgNode) {
const svgWidth = svgNode.getAttribute('width');
const svgHeight = svgNode.getAttribute('height');
if (svgWidth) width = parseInt(svgWidth, 10);
if (svgHeight) height = parseInt(svgHeight, 10);
}
// Chart dimensions set
const container = svg.selectAll(".waterfall-container").data([data]);
// Store reference for zoom system
const svgContainer = svg;
// Create main container group
const containerEnter = container.enter()
.append("g")
.attr("class", "waterfall-container");
const containerUpdate = containerEnter.merge(container);
// Create chart group for zoom transforms
let chartGroup: any = containerUpdate.select(".chart-group");
if (chartGroup.empty()) {
chartGroup = containerUpdate.append("g")
.attr("class", "chart-group");
}
// Add clipping path to prevent overflow - this will be set after margins are calculated
const clipPathId = `chart-clip-${Date.now()}`;
svg.select(`#${clipPathId}`).remove(); // Remove existing if any
const clipPath = svg.append("defs")
.append("clipPath")
.attr("id", clipPathId)
.append("rect");
chartGroup.attr("clip-path", `url(#${clipPathId})`);
try {
// Enable performance optimization for large datasets
if (data.length >= virtualizationThreshold && enablePerformanceOptimization) {
performanceManager.enableVirtualization({
chunkSize: Math.min(1000, Math.floor(data.length / 10)),
renderThreshold: virtualizationThreshold
});
}
// Check if we can use cached data (include showTotal in cache key)
const dataHash = JSON.stringify(data).slice(0, 100) + `_showTotal:${showTotal}`; // Quick hash with showTotal
let processedData: ProcessedData[];
if (dataHash === lastDataHash && cachedProcessedData) {
processedData = cachedProcessedData;
// Using cached processed data
} else {
// Prepare data with cumulative calculations
if (data.length > 50000) {
// For very large datasets, fall back to synchronous processing for now
// TODO: Implement proper async handling in future version
console.warn("MintWaterfall: Large dataset detected, using synchronous processing");
processedData = prepareData(data);
} else {
processedData = prepareData(data);
}
// Cache the processed data
lastDataHash = dataHash;
cachedProcessedData = processedData;
}
// Process data for chart rendering
// Calculate intelligent margins based on data
const intelligentMargins = calculateIntelligentMargins(processedData, margin);
// Set up scales using enhanced scale system
let xScale: any;
if (scaleType === "auto") {
xScale = scaleSystem.createAdaptiveScale(processedData, "x");
// If it's a band scale, apply padding
if (xScale.padding) {
xScale.padding(barPadding);
}
} else if (scaleType === "time") {
const timeValues = processedData.map(d => new Date(d.label));
xScale = scaleSystem.createTimeScale(timeValues);
} else if (scaleType === "ordinal") {
xScale = scaleSystem.createOrdinalScale(processedData.map(d => d.label));
} else {
// Default to band scale for categorical data
xScale = d3.scaleBand()
.domain(processedData.map(d => d.label))
.padding(barPadding);
}
// CRITICAL: Set range for x scale using intelligent margins - this must happen after scale creation
xScale.range([intelligentMargins.left, width - intelligentMargins.right]);
// Ensure the scale system uses the correct default range for future scales
scaleSystem.setDefaultRange([intelligentMargins.left, width - intelligentMargins.right]);
// Update clipping path with proper chart area dimensions
// IMPORTANT: Extend clipping area to include space for labels above bars
const labelSpace = 30; // Extra space for labels above the chart area
clipPath
.attr("x", intelligentMargins.left)
.attr("y", Math.max(0, intelligentMargins.top - labelSpace)) // Extend upward for labels
.attr("width", width - intelligentMargins.left - intelligentMargins.right)
.attr("height", height - intelligentMargins.top - intelligentMargins.bottom + labelSpace);
// Clipping path configured
// Scale configuration complete
// Enhanced Y scale using d3.extent and nice()
const yValues = processedData.map(d => d.cumulativeTotal);
// For waterfall charts, ensure proper baseline handling
const [min, max] = d3.extent(yValues) as [number, number];
const hasNegativeValues = min < 0;
let yScale: any;
if (hasNegativeValues) {
// When we have negative values, create scale that includes them but doesn't extend too far
const range = max - min;
const padding = range * 0.05; // 5% padding
yScale = d3.scaleLinear()
.domain([min - padding, max + padding])
.range([height - intelligentMargins.bottom, intelligentMargins.top]);
} else {
// For positive-only data, start at 0
yScale = scaleSystem.createLinearScale(yValues, {
range: [height - intelligentMargins.bottom, intelligentMargins.top],
nice: true
});
}
// Create/update grid
drawGrid(containerUpdate, yScale, intelligentMargins);
// Create/update axes (on container, not chart group)
drawAxes(containerUpdate, xScale, yScale, intelligentMargins);
// Create/update bars with enhanced animations (in chart group for zoom)
drawBars(chartGroup, processedData, xScale, yScale, intelligentMargins);
// Create/update connectors (in chart group for zoom)
drawConnectors(chartGroup, processedData, xScale, yScale);
// Create/update trend line (handles both show and hide cases)
drawTrendLine(chartGroup, processedData, xScale, yScale);
// NEW: Draw advanced features
drawConfidenceBands(chartGroup, processedData, xScale, yScale);
drawMilestones(chartGroup, processedData, xScale, yScale);
// Add brush functionality if enabled
if (enableBrush) {
addBrushSelection(containerUpdate, processedData, xScale, yScale);
}
// Initialize features after rendering is complete
setTimeout(() => {
if (enableAccessibility) {
initializeAccessibility(svg, processedData);
}
if (enableTooltips) {
initializeTooltips(svg);
}
if (enableExport) {
initializeExport(svg, processedData);
}
if (enableZoom) {
// Initialize zoom system if not already created
if (!(chart as any).zoomSystemInstance) {
(chart as any).zoomSystemInstance = createZoomSystem();
}
// Attach zoom to the SVG container
(chart as any).zoomSystemInstance.attach(svgContainer);
(chart as any).zoomSystemInstance.setDimensions({ width, height, margin: intelligentMargins });
(chart as any).zoomSystemInstance.enable();
} else {
// Disable zoom if it was previously enabled
if ((chart as any).zoomSystemInstance) {
(chart as any).zoomSystemInstance.disable();
(chart as any).zoomSystemInstance.detach();
}
}
}, 50); // Small delay to ensure DOM is ready
} catch (error: any) {
console.error("MintWaterfall rendering error:", error);
console.error("Stack trace:", error.stack);
// Clear any partial rendering and show error
containerUpdate.selectAll("*").remove();
containerUpdate.append("text")
.attr("x", width / 2)
.attr("y", height / 2)
.attr("text-anchor", "middle")
.style("font-size", "14px")
.style("fill", "#ff6b6b")
.text(`Chart Error: ${error.message}`);
}
});
}
function calculateIntelligentMargins(processedData: ProcessedData[], baseMargin: MarginConfig): MarginConfig {
// Calculate required space for labels - handle all edge cases
const allValues = processedData.flatMap(d => [d.cumulativeTotal, d.prevCumulativeTotal || 0]);
const maxValue = d3.max(allValues) || 0;
const minValue = d3.min(allValues) || 0;
// Estimate label dimensions - be more generous with space
const labelHeight = 16; // Increased from 14 to account for font size
const labelPadding = 8; // Increased from 5 for better spacing
const requiredLabelSpace = labelHeight + labelPadding;
const safetyBuffer = 20; // Increased from 10 for more breathing room
// Handle edge cases for different data scenarios
const hasNegativeValues = minValue < 0;
// Start with a more generous top margin to ensure labels fit
const initialTopMargin = Math.max(baseMargin.top, 80); // Ensure minimum 80px for labels
// Create temporary scale that matches the actual rendering logic
let tempYScale: any;
const tempRange: [number, number] = [height - baseMargin.bottom, initialTopMargin];
if (hasNegativeValues) {
// Match the actual scale logic for negative values
const range = maxValue - minValue;
const padding = range * 0.05; // 5% padding (same as actual scale)
tempYScale = d3.scaleLinear()
.domain([minValue - padding, maxValue + padding])
.range(tempRange);
} else {
// For positive-only data, start at 0 with padding
const paddedMax = maxValue * 1.02; // 2% padding (same as actual scale)
tempYScale = d3.scaleLinear()
.domain([0, paddedMax])
.range(tempRange)
.nice(); // Apply nice() like the actual scale
}
// Find the highest point where any label will be positioned
const allLabelPositions = processedData.map(d => {
const barTop = tempYScale(d.cumulativeTotal);
return barTop - labelPadding;
});
const highestLabelPosition = Math.min(...allLabelPositions);
// Calculate required top margin - ensure labels have enough space above them
const spaceNeededFromTop = Math.max(
initialTopMargin - highestLabelPosition + requiredLabelSpace,
requiredLabelSpace + safetyBuffer // Minimum space needed
);
const extraTopMarginNeeded = Math.max(0, spaceNeededFromTop - initialTopMargin);
// For negative values, we might also need bottom space
let extraBottomMargin = 0;
if (hasNegativeValues) {
const negativeData = processedData.filter(d => d.cumulativeTotal < 0);
if (negativeData.length > 0) {
const lowestLabelPosition = Math.max(
...negativeData.map(d => tempYScale(d.cumulativeTotal) + labelHeight + labelPadding)
);
if (lowestLabelPosition > height - baseMargin.bottom) {
extraBottomMargin = lowestLabelPosition - (height - baseMargin.bottom);
}
}
}
// Calculate required right margin for labels
const maxLabelLength = Math.max(...processedData.map(d =>
formatNumber(d.cumulativeTotal).length
));
const estimatedLabelWidth = maxLabelLength * 9; // Increased from 8 to 9px per character
const minRightMargin = Math.max(baseMargin.right, estimatedLabelWidth / 2 + 15);
const intelligentMargin: MarginConfig = {
top: initialTopMargin + extraTopMarginNeeded + safetyBuffer,
right: minRightMargin,
bottom: baseMargin.bottom + extraBottomMargin + (hasNegativeValues ? safetyBuffer : 10),
left: baseMargin.left
};
// Intelligent margins calculated
return intelligentMargin;
}
function prepareData(data: ChartData[]): ProcessedData[] {
let workingData = [...data];
// Apply breakdown analysis if enabled
if (breakdownConfig && breakdownConfig.enabled) {
workingData = applyBreakdownAnalysis(workingData, breakdownConfig);
}
let cumulativeTotal = 0;
let prevCumulativeTotal = 0;
// Process each bar with cumulative totals
const processedData: ProcessedData[] = workingData.map((bar, i) => {
const barTotal = bar.stacks.reduce((sum, stack) => sum + stack.value, 0);
prevCumulativeTotal = cumulativeTotal;
cumulativeTotal += barTotal;
// Apply conditional formatting if enabled
let processedStacks = bar.stacks;
if (formattingRules.size > 0) {
processedStacks = applyConditionalFormatting(bar.stacks, bar, formattingRules);
}
const result: ProcessedData = {
...bar,
stacks: processedStacks,
barTotal,
cumulativeTotal,
prevCumulativeTotal: i === 0 ? 0 : prevCumulativeTotal
};
return result;
});
// Add total bar if enabled
if (showTotal && processedData.length > 0) {
const totalValue = cumulativeTotal;
processedData.push({
label: totalLabel,
stacks: [{ value: totalValue, color: totalColor }],
barTotal: totalValue,
cumulativeTotal: totalValue,
prevCumulativeTotal: 0 // Total bar starts from zero
});
}
return processedData;
}
// Placeholder function implementations - these would be converted separately
function applyBreakdownAnalysis(data: ChartData[], config: BreakdownConfig): ChartData[] {
// Implementation would be migrated from JavaScript version
return data;
}
function applyConditionalFormatting(stacks: StackData[], barData: ChartData, rules: Map<string, any>): StackData[] {
// Implementation would be migrated from JavaScript version
return stacks;
}
function drawGrid(container: any, yScale: any, intelligentMargins: MarginConfig): void {
// Create horizontal grid lines
const gridGroup = container.selectAll(".grid-group").data([0]);
const gridGroupEnter = gridGroup.enter()
.append("g")
.attr("class", "grid-group");
const gridGroupUpdate = gridGroupEnter.merge(gridGroup);
// Get tick values from y scale
const tickValues = yScale.ticks();
// Create grid lines
const gridLines = gridGroupUpdate.selectAll(".grid-line").data(tickValues);
const gridLinesEnter = gridLines.enter()
.append("line")
.attr("class", "grid-line")
.attr("x1", intelligentMargins.left)
.attr("x2", width - intelligentMargins.right)
.attr("stroke", "rgba(224, 224, 224, 0.5)")
.attr("stroke-width", 1)
.style("opacity", 0);
gridLinesEnter.merge(gridLines)
.transition()
.duration(duration)
.ease(ease)
.attr("y1", (d: any) => yScale(d))
.attr("y2", (d: any) => yScale(d))
.attr("x1", intelligentMargins.left)
.attr("x2", width - intelligentMargins.right)
.style("opacity", 1);
gridLines.exit()
.transition()
.duration(duration)
.ease(ease)
.style("opacity", 0)
.remove();
}
function drawAxes(container: any, xScale: any, yScale: any, intelligentMargins: MarginConfig): void {
// Y-axis
const yAxisGroup = container.selectAll(".y-axis").data([0]);
const yAxisGroupEnter = yAxisGroup.enter()
.append("g")
.attr("class", "y-axis")
.attr("transform", `translate(${intelligentMargins.left},0)`);
yAxisGroupEnter.merge(yAxisGroup)
.transition()
.duration(duration)
.ease(ease)
.call(d3.axisLeft(yScale).tickFormat((d: any) => formatNumber(d as number)));
// X-axis
const xAxisGroup = container.selectAll(".x-axis").data([0]);
const xAxisGroupEnter = xAxisGroup.enter()
.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(0,${height - intelligentMargins.bottom})`);
xAxisGroupEnter.merge(xAxisGroup)
.transition()
.duration(duration)
.ease(ease)
.call(d3.axisBottom(xScale));
}
function drawBars(container: any, processedData: ProcessedData[], xScale: any, yScale: any, intelligentMargins: MarginConfig): void {
const barsGroup = container.selectAll(".bars-group").data([0]);
const barsGroupEnter = barsGroup.enter()
.append("g")
.attr("class", "bars-group");
const barsGroupUpdate = barsGroupEnter.merge(barsGroup);
// Bar groups for each data point
const barGroups = barsGroupUpdate.selectAll(".bar-group").data(processedData, (d: any) => d.label);
// For band scales, we don't need manual positioning - the scale handles it
const barGroupsEnter = barGroups.enter()
.append("g")
.attr("class", "bar-group")
.attr("transform", (d: any) => {
if (xScale.bandwidth) {
// Band scale - use the scale directly
return `translate(${xScale(d.label)}, 0)`;
} else {
// Continuous scale - manual positioning using intelligent margins
const barWidth = getBarWidth(xScale, processedData.length, width - intelligentMargins.left - intelligentMargins.right);
const barX = getBarPosition(xScale, d.label, barWidth);
return `translate(${barX}, 0)`;
}
});
const barGroupsUpdate = barGroupsEnter.merge(barGroups)
.transition()
.duration(duration)
.ease(ease)
.attr("transform", (d: any) => {
if (xScale.bandwidth) {
// Band scale - use the scale directly
return `translate(${xScale(d.label)}, 0)`;
} else {
// Continuous scale - manual positioning using intelligent margins
const barWidth = getBarWidth(xScale, processedData.length, width - intelligentMargins.left - intelligentMargins.right);
const barX = getBarPosition(xScale, d.label, barWidth);
return `translate(${barX}, 0)`;
}
});
if (stacked) {
drawStackedBars(barGroupsUpdate, xScale, yScale, intelligentMargins);
} else {
drawWaterfallBars(barGroupsUpdate, xScale, yScale, intelligentMargins, processedData);
}
// Add value labels
drawValueLabels(barGroupsUpdate, xScale, yScale, intelligentMargins);
barGroups.exit()
.transition()
.duration(duration)
.ease(ease)
.style("opacity", 0)
.remove();
}
function drawStackedBars(barGroups: any, xScale: any, yScale: any, intelligentMargins: MarginConfig): void {
barGroups.each(function(this: SVGGElement, d: any) {
const group = d3.select(this);
const stackData = d.stacks.map((stack: any, i: number) => ({
...stack,
stackIndex: i,
parent: d
}));
// Calculate stack positions
let cumulativeHeight = d.prevCumulativeTotal || 0;
stackData.forEach((stack: any) => {
stack.startY = cumulativeHeight;
stack.endY = cumulativeHeight + stack.value;
stack.y = yScale(Math.max(stack.startY, stack.endY));
stack.height = Math.abs(yScale(stack.startY) - yScale(stack.endY));
cumulativeHeight += stack.value;
});
const stacks = group.selectAll(".stack").data(stackData);
// Get bar width - use scale bandwidth if available, otherwise calculate using intelligent margins
const barWidth = xScale.bandwidth ? xScale.bandwidth() : getBarWidth(xScale, barGroups.size(), width - intelligentMargins.left - intelligentMargins.right);
const stacksEnter = stacks.enter()
.append("rect")
.attr("class", "stack")
.attr("x", 0)
.attr("width", barWidth)
.attr("y", yScale(0))
.attr("height", 0)
.attr("fill", (stack: any) => stack.color);
(stacksEnter as any).merge(stacks)
.transition()
.duration(duration)
.ease(ease)
.attr("y", (stack: any) => stack.y)
.attr("height", (stack: any) => stack.height)
.attr("fill", (stack: any) => stack.color)
.attr("width", barWidth);
stacks.exit()
.transition()
.duration(duration)
.ease(ease)
.attr("height", 0)
.attr("y", yScale(0))
.remove();
// Add stack labels if they exist
const stackLabels = group.selectAll(".stack-label").data(stackData.filter((s: any) => s.label));
const stackLabelsEnter = stackLabels.enter()
.append("text")
.attr("class", "stack-label")
.attr("text-anchor", "middle")
.attr("x", barWidth / 2)
.attr("y", yScale(0))
.style("opacity", 0);
(stackLabelsEnter as any).merge(stackLabels)
.transition()
.duration(duration)
.ease(ease)
.attr("y", (stack: any) => stack.y + stack.height / 2 + 4)
.attr("x", barWidth / 2)
.style("opacity", 1)
.text((stack: any) => stack.label);
stackLabels.exit()
.transition()
.duration(duration)
.ease(ease)
.style("opacity", 0)
.remove();
});
}
function drawWaterfallBars(barGroups: any, xScale: any, yScale: any, intelligentMargins: MarginConfig, allData: ProcessedData[] = []): void {
barGroups.each(function(this: SVGGElement, d: any) {
const group = d3.select(this);
// Get bar width - use scale bandwidth if available, otherwise calculate using intelligent margins
const barWidth = xScale.bandwidth ? xScale.bandwidth() : getBarWidth(xScale, barGroups.size(), width - intelligentMargins.left - intelligentMargins.right);
// Determine bar color using advanced color features
const defaultColor = d.stacks.length === 1 ? d.stacks[0].color : "#3498db";
const advancedColor = advancedColorConfig.enabled ?
getAdvancedBarColor(
d.barTotal,
defaultColor,
allData,
advancedColorConfig.themeName as keyof ThemeCollection || 'default',
colorMode
) : defaultColor;
const barData = [{
value: d.barTotal,
color: advancedColor,
y: d.isTotal ?
Math.min(yScale(0), yScale(d.cumulativeTotal)) : // Total bar: position correctly regardless of scale direction
yScale(Math.max(d.prevCumulativeTotal, d.cumulativeTotal)),
height: d.isTotal ?
Math.abs(yScale(0) - yScale(d.cumulativeTotal)) : // Total bar: full height from zero to total
Math.abs(yScale(d.prevCumulativeTotal || 0) - yScale(d.cumulativeTotal)),
parent: d
}];
const bars = group.selectAll(".waterfall-bar").data(barData);
const barsEnter = bars.enter()
.append("rect")
.attr("class", "waterfall-bar")
.attr("x", 0)
.attr("width", barWidth)
.attr("y", yScale(0))
.attr("height", 0)
.attr("fill", (bar: any) => bar.color);
(barsEnter as any).merge(bars)
.transition()
.duration(duration)
.ease(ease)
.attr("y", (bar: any) => bar.y)
.attr("height", (bar: any) => bar.height)
.attr("fill", (bar: any) => bar.color)
.attr("width", barWidth);
bars.exit()
.transition()
.duration(duration)
.ease(ease)
.attr("height", 0)
.attr("y", yScale(0))
.remove();
});
}
function drawValueLabels(barGroups: any, xScale: any, yScale: any, intelligentMargins: MarginConfig): void {
// Always show value labels on bars - this is independent of the total bar setting
// Drawing value labels
barGroups.each(function(this: SVGGElement, d: any) {
const group = d3.select(this);
const barWidth = getBarWidth(xScale, barGroups.size(), width - intelligentMargins.left - intelligentMargins.right);
// Processing label for bar
const labelData = [{
value: d.barTotal,
formattedValue: formatNumber(d.barTotal),
parent: d
}];
const totalLabels = group.selectAll(".total-label").data(labelData);
const totalLabelsEnter = totalLabels.enter()
.append("text")
.attr("class", "total-label")
.attr("text-anchor", "middle")
.attr("x", barWidth / 2)
.attr("y", yScale(0))
.style("opacity", 0)
.style("font-family", "Arial, sans-serif"); // Ensure font is set
const labelUpdate = (totalLabelsEnter as any).merge(totalLabels);
labelUpdate
.transition()
.duration(duration)
.ease(ease)
.attr("y", (labelD: any) => {
const barTop = yScale(labelD.parent.cumulativeTotal);
const padding = 8;
const finalY = barTop - padding;
// Label positioning calculated
return finalY;
})
.attr("x", barWidth / 2)
.style("opacity", 1)
.style("fill", "#333")
.style("font-weight", "bold")
.style("font-size", "14px")
.style("pointer-events", "none")
.style("visibility", "visible") // Ensure visibility
.style("display", "block") // Ensure display
.attr("clip-path", "none") // Remove any clipping from labels themselves
.text((labelD: any) => labelD.formattedValue)
.each(function(this: SVGTextElement, labelD: any) {
// Label element created
});
totalLabels.exit()
.transition()
.duration(duration)
.ease(ease)
.style("opacity", 0)
.remove();
});
}
function drawConnectors(container: any, processedData: ProcessedData[], xScale: any, yScale: any): void {
if (stacked || processedData.length < 2) return; // Only show connectors for waterfall charts
const connectorsGroup = container.selectAll(".connectors-group").data([0]);
const connectorsGroupEnter = connectorsGroup.enter()
.append("g")
.attr("class", "connectors-group");
const connectorsGroupUpdate = connectorsGroupEnter.merge(connectorsGroup);
// Create connector data
const connectorData: any[] = [];
for (let i = 0; i < processedData.length - 1; i++) {
const current = processedData[i];
const next = processedData[i + 1];
const barWidth = getBarWidth(xScale, processedData.length, width - margin.left - margin.right);
const currentX = getBarPosition(xScale, current.label, barWidth);
const nextX = getBarPosition(xScale, next.label, barWidth);
connectorData.push({
x1: currentX + barWidth,
x2: nextX,
y: yScale(current.cumulativeTotal),
id: `${current.label}-${next.label}`
});
}
// Create/update connector lines
const connectors = connectorsGroupUpdate.selectAll(".connector").data(connectorData, (d: any) => d.id);
const connectorsEnter = connectors.enter()
.append("line")
.attr("class", "connector")
.attr("stroke", "#bdc3c7")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "3,3")
.style("opacity", 0)
.attr("x1", (d: any) => d.x1)
.attr("x2", (d: any) => d.x1)
.attr("y1", (d: any) => d.y)
.attr("y2", (d: any) => d.y);
connectorsEnter.merge(connectors)
.transition()
.duration(duration)
.ease(ease)
.delay((d: any, i: number) => staggeredAnimations ? i * staggerDelay : 0)
.attr("x1", (d: any) => d.x1)
.attr("x2", (d: any) => d.x2)
.attr("y1", (d: any) => d.y)
.attr("y2", (d: any) => d.y)
.style("opacity", 0.6);
connectors.exit()
.transition()
.duration(duration)
.ease(ease)
.style("opacity", 0)
.remove();
}
function drawTrendLine(container: any, processedData: ProcessedData[], xScale: any, yScale: any): void {
// Remove trend line if disabled or insufficient data
if (!showTrendLine || processedData.length < 2) {
container.selectAll(".trend-group").remove();
return;
}
const trendGroup = container.selectAll(".trend-group").data([0]);
const trendGroupEnter = trendGroup.enter()
.append("g")
.attr("class", "trend-group");
const trendGroupUpdate = trendGroup