UNPKG

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

650 lines (555 loc) 22.8 kB
// MintWaterfall Advanced Interactions // Sophisticated D3.js interaction capabilities for enhanced waterfall analysis import * as d3 from 'd3'; import { drag } from 'd3-drag'; import { forceSimulation, forceCenter, forceCollide, forceManyBody } from 'd3-force'; // ============================================================================ // TYPE DEFINITIONS // ============================================================================ export interface DragConfig { enabled: boolean; axis: 'both' | 'horizontal' | 'vertical'; constraints?: { minValue?: number; maxValue?: number; snapToGrid?: boolean; gridSize?: number; }; onDragStart?: (event: d3.D3DragEvent<any, any, any>, data: any) => void; onDrag?: (event: d3.D3DragEvent<any, any, any>, data: any) => void; onDragEnd?: (event: d3.D3DragEvent<any, any, any>, data: any) => void; } export interface VoronoiConfig { enabled: boolean; extent: [[number, number], [number, number]]; showCells?: boolean; cellOpacity?: number; onCellEnter?: (event: MouseEvent, data: any) => void; onCellLeave?: (event: MouseEvent, data: any) => void; onCellClick?: (event: MouseEvent, data: any) => void; } export interface ForceSimulationConfig { enabled: boolean; forces: { collision?: boolean; centering?: boolean; positioning?: boolean; links?: boolean; }; strength: { collision?: number; centering?: number; positioning?: number; links?: number; }; duration?: number; onTick?: (simulation: d3.Simulation<any, any>) => void; onEnd?: (simulation: d3.Simulation<any, any>) => void; } export interface InteractionSystem { // Drag functionality enableDrag(config: DragConfig): void; disableDrag(): void; updateDragConstraints(constraints: DragConfig['constraints']): void; // Enhanced hover detection (simplified approach) enableEnhancedHover(config: VoronoiConfig): void; disableEnhancedHover(): void; updateHoverExtent(extent: [[number, number], [number, number]]): void; // Force simulation for dynamic layouts startForceSimulation(config: ForceSimulationConfig): d3.Simulation<any, any>; stopForceSimulation(): void; updateForces(forces: ForceSimulationConfig['forces']): void; // Combined interaction management setInteractionMode(mode: 'drag' | 'voronoi' | 'force' | 'combined' | 'none'): void; getActiveInteractions(): string[]; // Data management updateData(data: any[]): void; // Event management on(event: string, callback: Function): void; off(event: string): void; trigger(event: string, data?: any): void; } // ============================================================================ // ADVANCED INTERACTION SYSTEM IMPLEMENTATION // ============================================================================ export function createAdvancedInteractionSystem( container: d3.Selection<any, any, any, any>, xScale: d3.ScaleLinear<number, number> | d3.ScaleBand<string>, yScale: d3.ScaleLinear<number, number> ): InteractionSystem { // Internal state let dragBehavior: d3.DragBehavior<any, any, any> | null = null; let enhancedHoverEnabled: boolean = false; let currentSimulation: d3.Simulation<any, any> | null = null; let currentData: any[] = []; let eventListeners: Map<string, Function[]> = new Map(); // ======================================================================== // DRAG FUNCTIONALITY (d3.drag) // ======================================================================== function enableDrag(config: DragConfig): void { if (!config || !config.enabled) { disableDrag(); return; } // Create drag behavior dragBehavior = drag<any, any>() .on('start', (event, d) => { // Visual feedback on drag start d3.select(event.sourceEvent.target) .raise() .attr('stroke', '#ff6b6b') .attr('stroke-width', 2); if (config.onDragStart) { config.onDragStart(event, d); } trigger('dragStart', { event, data: d }); }) .on('drag', (event, d) => { const bar = d3.select(event.sourceEvent.target); let newValue = d.value || 0; // Handle axis constraints if (config.axis === 'vertical' || config.axis === 'both') { const newY = event.y; newValue = yScale.invert(newY); // Apply constraints if (config.constraints) { const { minValue, maxValue, snapToGrid, gridSize } = config.constraints; if (minValue !== undefined) newValue = Math.max(minValue, newValue); if (maxValue !== undefined) newValue = Math.min(maxValue, newValue); if (snapToGrid && gridSize) { newValue = Math.round(newValue / gridSize) * gridSize; } } // Update visual position const barHeight = Math.abs(yScale(0) - yScale(newValue)); const barY = newValue >= 0 ? yScale(newValue) : yScale(0); bar.attr('y', barY) .attr('height', barHeight); // Update data d.value = newValue; if (d.stacks && d.stacks.length > 0) { d.stacks[0].value = newValue; } } // Handle horizontal movement (for reordering) if (config.axis === 'horizontal' || config.axis === 'both') { const newX = event.x; // Implementation for horizontal dragging/reordering const barWidth = parseFloat(bar.attr('width') || '0'); bar.attr('transform', `translate(${newX - barWidth / 2}, 0)`); } if (config.onDrag) { config.onDrag(event, d); } trigger('drag', { event, data: d, newValue }); }) .on('end', (event, d) => { // Remove visual feedback d3.select(event.sourceEvent.target) .attr('stroke', null) .attr('stroke-width', null); if (config.onDragEnd) { config.onDragEnd(event, d); } trigger('dragEnd', { event, data: d }); }); // Apply drag behavior to all bars container.selectAll('.bar') .call(dragBehavior); trigger('dragEnabled', config); } function disableDrag(): void { if (dragBehavior) { container.selectAll('.bar') .on('.drag', null); dragBehavior = null; trigger('dragDisabled'); } } function updateDragConstraints(constraints: DragConfig['constraints']): void { // Constraints are checked during drag events trigger('dragConstraintsUpdated', constraints); } // ======================================================================== // ENHANCED HOVER DETECTION (Simplified approach) // ======================================================================== function enableEnhancedHover(config: VoronoiConfig): void { if (!config || !config.enabled || currentData.length === 0) { disableEnhancedHover(); return; } enhancedHoverEnabled = true; // Create enhanced hover zones around bars const hoverGroup = container.selectAll('.enhanced-hover-group') .data([0]); const hoverGroupEnter = hoverGroup.enter() .append('g') .attr('class', 'enhanced-hover-group'); const hoverGroupMerged = hoverGroupEnter.merge(hoverGroup as any); // Add enhanced hover zones const zones = hoverGroupMerged.selectAll('.hover-zone') .data(currentData); zones.enter() .append('rect') .attr('class', 'hover-zone') .merge(zones as any) .attr('x', d => getBarCenterX(d) - getBarWidth(d) * 0.75) .attr('y', d => Math.min(getBarCenterY(d), yScale(0)) - 10) .attr('width', d => getBarWidth(d) * 1.5) .attr('height', d => Math.abs(yScale(0) - getBarCenterY(d)) + 20) .style('fill', 'transparent') .style('pointer-events', 'all') .on('mouseenter', function(event, d) { highlightBar(d); if (config.onCellEnter) { config.onCellEnter(event, d); } trigger('enhancedHoverEnter', { event, data: d }); }) .on('mouseleave', function(event, d) { unhighlightBar(d); if (config.onCellLeave) { config.onCellLeave(event, d); } trigger('enhancedHoverLeave', { event, data: d }); }) .on('click', function(event, d) { if (config.onCellClick) { config.onCellClick(event, d); } trigger('enhancedHoverClick', { event, data: d }); }); zones.exit().remove(); trigger('enhancedHoverEnabled', config); } function disableEnhancedHover(): void { try { // Only attempt to remove elements if container has proper D3 methods if (container && container.selectAll && typeof container.selectAll === 'function') { const selection = container.selectAll('.enhanced-hover-group'); if (selection && selection.remove && typeof selection.remove === 'function') { selection.remove(); } } } catch (error) { // Silently handle any DOM manipulation errors in test environment } enhancedHoverEnabled = false; trigger('enhancedHoverDisabled'); } function updateHoverExtent(extent: [[number, number], [number, number]]): void { if (enhancedHoverEnabled) { // Re-enable with current data const currentConfig = { enabled: true, extent }; enableEnhancedHover(currentConfig); } } // ======================================================================== // FORCE SIMULATION FOR DYNAMIC LAYOUTS (d3.forceSimulation) // ======================================================================== function startForceSimulation(config: ForceSimulationConfig): d3.Simulation<any, any> { if (!config || !config.enabled || currentData.length === 0) { return forceSimulation<any, any>([]); } // Stop any existing simulation stopForceSimulation(); // Create new simulation currentSimulation = forceSimulation(currentData); // Add forces based on configuration if (config.forces.collision) { currentSimulation.force('collision', forceCollide() .radius(d => getBarWidth(d) / 2 + 5) .strength(config.strength.collision || 0.7)); } if (config.forces.centering) { const centerX = (xScale.range()[0] + xScale.range()[1]) / 2; const centerY = (yScale.range()[0] + yScale.range()[1]) / 2; currentSimulation.force('center', forceCenter(centerX, centerY) .strength(config.strength.centering || 0.1)); } if (config.forces.positioning) { currentSimulation.force('x', d3.forceX(d => getBarCenterX(d)) .strength(config.strength.positioning || 0.5)); currentSimulation.force('y', d3.forceY(d => getBarCenterY(d)) .strength(config.strength.positioning || 0.5)); } if (config.forces.links && currentData.length > 1) { // Create links between consecutive bars const links = currentData.slice(1).map((d, i) => ({ source: currentData[i], target: d })); currentSimulation.force('link', d3.forceLink(links) .distance(50) .strength(config.strength.links || 0.3)); } // Set up tick handler currentSimulation.on('tick', () => { updateBarPositions(); if (config.onTick && currentSimulation) { config.onTick(currentSimulation); } trigger('forceTick', currentSimulation); }); // Set up end handler currentSimulation.on('end', () => { if (config.onEnd && currentSimulation) { config.onEnd(currentSimulation); } trigger('forceEnd', currentSimulation); }); // Set alpha decay for animation duration if (config.duration) { const targetAlpha = 0.001; const decay = 1 - Math.pow(targetAlpha, 1 / config.duration); currentSimulation.alphaDecay(decay); } trigger('forceSimulationStarted', config); return currentSimulation; } function stopForceSimulation(): void { if (currentSimulation) { currentSimulation.stop(); currentSimulation = null; trigger('forceSimulationStopped'); } } function updateForces(forces: ForceSimulationConfig['forces']): void { if (currentSimulation) { // Update or remove forces based on configuration if (!forces.collision) currentSimulation.force('collision', null); if (!forces.centering) currentSimulation.force('center', null); if (!forces.positioning) { currentSimulation.force('x', null); currentSimulation.force('y', null); } if (!forces.links) currentSimulation.force('link', null); currentSimulation.alpha(1).restart(); trigger('forcesUpdated', forces); } } // ======================================================================== // INTERACTION MODE MANAGEMENT // ======================================================================== function setInteractionMode(mode: 'drag' | 'voronoi' | 'force' | 'combined' | 'none'): void { // Disable all interactions first disableDrag(); disableEnhancedHover(); stopForceSimulation(); const xRange = (xScale as any).range() || [0, 800]; const yRange = (yScale as any).range() || [400, 0]; switch (mode) { case 'drag': enableDrag({ enabled: true, axis: 'vertical', constraints: { snapToGrid: true, gridSize: 10 } }); break; case 'voronoi': enableEnhancedHover({ enabled: true, extent: [[0, 0], [xRange[1], yRange[0]]] }); break; case 'force': startForceSimulation({ enabled: true, forces: { collision: true, positioning: true }, strength: { collision: 0.7, positioning: 0.5 }, duration: 1000 }); break; case 'combined': enableEnhancedHover({ enabled: true, extent: [[0, 0], [xRange[1], yRange[0]]] }); enableDrag({ enabled: true, axis: 'vertical' }); break; case 'none': default: // All interactions disabled break; } trigger('interactionModeChanged', mode); } function getActiveInteractions(): string[] { const active: string[] = []; if (dragBehavior) active.push('drag'); if (enhancedHoverEnabled) active.push('hover'); if (currentSimulation) active.push('force'); return active; } // ======================================================================== // EVENT MANAGEMENT // ======================================================================== function on(event: string, callback: Function): void { if (!eventListeners.has(event)) { eventListeners.set(event, []); } eventListeners.get(event)!.push(callback); } function off(event: string): void { eventListeners.delete(event); } function trigger(event: string, data?: any): void { const callbacks = eventListeners.get(event); if (callbacks) { callbacks.forEach(callback => callback(data)); } } // ======================================================================== // UTILITY FUNCTIONS // ======================================================================== function getBarCenterX(d: any): number { const scale = xScale as any; // Type assertion for compatibility if (scale.bandwidth) { // Band scale return (scale(d.label) || 0) + scale.bandwidth() / 2; } else { // Linear scale - assume equal spacing return scale(parseFloat(d.label) || 0); } } function getBarCenterY(d: any): number { const value = d.value || (d.stacks && d.stacks[0] ? d.stacks[0].value : 0); return yScale(value / 2); } function getBarWidth(d: any): number { const scale = xScale as any; // Type assertion for compatibility if (scale.bandwidth) { return scale.bandwidth(); } return 40; // Default width for linear scales } function highlightBar(data: any): void { container.selectAll('.bar') .filter((d: any) => d === data) .transition() .duration(150) .attr('opacity', 0.8) .attr('stroke', '#ff6b6b') .attr('stroke-width', 2); } function unhighlightBar(data: any): void { container.selectAll('.bar') .filter((d: any) => d === data) .transition() .duration(150) .attr('opacity', 1) .attr('stroke', null) .attr('stroke-width', null); } function updateBarPositions(): void { if (!forceSimulation) return; container.selectAll('.bar') .data(currentData) .attr('transform', (d: any) => { const x = (d as any).x || getBarCenterX(d); const y = (d as any).y || getBarCenterY(d); return `translate(${x - getBarWidth(d) / 2}, ${y})`; }); } // ======================================================================== // PUBLIC API // ======================================================================== // Method to update data for interactions function updateData(data: any[]): void { currentData = data; // Update active interactions with new data if (enhancedHoverEnabled) { const config = { enabled: true, extent: [[0, 0], [800, 600]] as [[number, number], [number, number]] }; enableEnhancedHover(config); } if (currentSimulation) { currentSimulation.nodes(data); currentSimulation.alpha(1).restart(); } } return { enableDrag, disableDrag, updateDragConstraints, enableEnhancedHover, disableEnhancedHover, updateHoverExtent, startForceSimulation, stopForceSimulation, updateForces, setInteractionMode, getActiveInteractions, updateData, on, off, trigger }; } // ============================================================================ // SPECIALIZED WATERFALL INTERACTION UTILITIES // ============================================================================ /** * Create drag behavior specifically optimized for waterfall charts */ export function createWaterfallDragBehavior( onValueChange: (data: any, newValue: number) => void, constraints?: { min?: number; max?: number } ): DragConfig { return { enabled: true, axis: 'vertical', constraints: { minValue: constraints?.min, maxValue: constraints?.max, snapToGrid: true, gridSize: 100 // Snap to hundreds }, onDrag: (event, data) => { if (onValueChange) { onValueChange(data, data.value); } } }; } /** * Create voronoi configuration optimized for waterfall hover detection */ export function createWaterfallVoronoiConfig( chartWidth: number, chartHeight: number, margin: { top: number; right: number; bottom: number; left: number } ): VoronoiConfig { return { enabled: true, extent: [ [margin.left, margin.top], [chartWidth - margin.right, chartHeight - margin.bottom] ], showCells: false, cellOpacity: 0.1 }; } /** * Create force simulation for animated waterfall reordering */ export function createWaterfallForceConfig( animationDuration: number = 1000 ): ForceSimulationConfig { return { enabled: true, forces: { collision: true, positioning: true, centering: false, links: false }, strength: { collision: 0.8, positioning: 0.6 }, duration: animationDuration }; }