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
399 lines (339 loc) • 13.4 kB
text/typescript
// MintWaterfall Zoom & Pan System - TypeScript Version
// Provides interactive zoom and pan functionality with smooth performance and full type safety
import * as d3 from 'd3';
// Type definitions for zoom system
export interface ZoomConfig {
enabled: boolean;
scaleExtent: [number, number];
translateExtent: [[number, number], [number, number]] | null;
wheelDelta: ((event: WheelEvent) => number) | null;
touchable: boolean;
filter: ((event: any) => boolean) | null;
constrain: {
x: boolean;
y: boolean;
};
duration: number;
ease: (t: number) => number;
}
export interface ChartDimensions {
width: number;
height: number;
margin: {
top: number;
right: number;
bottom: number;
left: number;
};
}
export interface ZoomEventData {
transform: d3.ZoomTransform;
sourceEvent: any;
}
export interface ZoomTransition {
to: d3.ZoomTransform;
duration?: number;
ease?: (t: number) => number;
}
export interface ZoomBounds {
x: [number, number];
y: [number, number];
}
export interface ZoomSystem {
enable(): ZoomSystem;
disable(): ZoomSystem;
attach(container: d3.Selection<any, any, any, any>): ZoomSystem;
detach(): ZoomSystem;
transform(selection: d3.Selection<any, any, any, any>, transform: d3.ZoomTransform, duration?: number): ZoomSystem;
reset(duration?: number): ZoomSystem;
zoomTo(bounds: ZoomBounds, duration?: number): ZoomSystem;
setDimensions(dimensions: ChartDimensions): ZoomSystem;
configure(newConfig: Partial<ZoomConfig>): ZoomSystem;
getCurrentTransform(): d3.ZoomTransform;
isEnabled(): boolean;
on(type: string, callback: (event: ZoomEventData) => void): ZoomSystem;
off(type: string, callback?: (event: ZoomEventData) => void): ZoomSystem;
}
export type ZoomEventType = 'zoomstart' | 'zoom' | 'zoomend' | 'reset';
export function createZoomSystem(): ZoomSystem {
// Zoom configuration
const config: ZoomConfig = {
enabled: true,
scaleExtent: [0.1, 10],
translateExtent: null, // Auto-calculated based on chart dimensions
wheelDelta: null, // Use D3 default for proper zoom in/out
touchable: true,
filter: null, // Custom filter function
constrain: {
x: true,
y: true
},
duration: 250,
ease: d3.easeQuadOut
};
let zoomBehavior: d3.ZoomBehavior<any, any> | null = null;
let currentTransform: d3.ZoomTransform = d3.zoomIdentity;
let chartContainer: d3.Selection<any, any, any, any> | null = null;
let chartDimensions: ChartDimensions = {
width: 800,
height: 400,
margin: { top: 60, right: 80, bottom: 60, left: 80 }
};
// Event listeners
const listeners = d3.dispatch("zoomstart", "zoom", "zoomend", "reset");
function createZoomBehavior(): d3.ZoomBehavior<any, any> {
if (zoomBehavior) return zoomBehavior;
zoomBehavior = d3.zoom<any, any>()
.scaleExtent(config.scaleExtent)
.touchable(config.touchable)
.filter(config.filter || defaultFilter)
.on("start", handleZoomStart)
.on("zoom", handleZoom)
.on("end", handleZoomEnd);
// Only set wheelDelta if explicitly configured (null means use D3 default)
if (config.wheelDelta !== null) {
zoomBehavior.wheelDelta(config.wheelDelta);
}
updateTranslateExtent();
return zoomBehavior;
}
function defaultFilter(event: any): boolean {
// Allow zoom on wheel, but prevent on right-click
return (!event.ctrlKey || event.type === "wheel") && !event.button;
}
function updateTranslateExtent(): void {
if (!zoomBehavior || !chartDimensions) return;
const { width, height, margin } = chartDimensions;
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// Calculate translate extent based on zoom constraints
const extent: [[number, number], [number, number]] = config.translateExtent || [
[-chartWidth * 2, -chartHeight * 2],
[chartWidth * 3, chartHeight * 3]
];
zoomBehavior.translateExtent(extent);
}
function handleZoomStart(event: d3.D3ZoomEvent<any, any>): void {
const eventData: ZoomEventData = {
transform: event.transform,
sourceEvent: event.sourceEvent
};
listeners.call("zoomstart", undefined, eventData);
}
function handleZoom(event: d3.D3ZoomEvent<any, any>): void {
currentTransform = event.transform;
// Apply constraints if specified
if (!config.constrain.x) {
currentTransform = currentTransform.translate(-currentTransform.x, 0);
}
if (!config.constrain.y) {
currentTransform = currentTransform.translate(0, -currentTransform.y);
}
// Apply transform to chart elements
if (chartContainer) {
applyTransform(chartContainer, currentTransform);
}
const eventData: ZoomEventData = {
transform: currentTransform,
sourceEvent: event.sourceEvent
};
listeners.call("zoom", undefined, eventData);
}
function handleZoomEnd(event: d3.D3ZoomEvent<any, any>): void {
const eventData: ZoomEventData = {
transform: event.transform,
sourceEvent: event.sourceEvent
};
listeners.call("zoomend", undefined, eventData);
}
function applyTransform(container: d3.Selection<any, any, any, any>, transform: d3.ZoomTransform): void {
// Apply transform to the main chart group
const chartGroup = container.select(".chart-group");
if (!chartGroup.empty()) {
chartGroup.attr("transform", transform.toString());
}
// Update axes if they exist
updateAxes(container, transform);
}
function updateAxes(container: d3.Selection<any, any, any, any>, transform: d3.ZoomTransform): void {
// Update X axis if constrained
if (config.constrain.x) {
const xAxisGroup = container.select(".x-axis");
if (!xAxisGroup.empty()) {
const xScale = getScaleFromAxis(xAxisGroup);
if (xScale) {
const newXScale = transform.rescaleX(xScale);
(xAxisGroup as any).call(d3.axisBottom(newXScale));
}
}
}
// Update Y axis if constrained
if (config.constrain.y) {
const yAxisGroup = container.select(".y-axis");
if (!yAxisGroup.empty()) {
const yScale = getScaleFromAxis(yAxisGroup);
if (yScale) {
const newYScale = transform.rescaleY(yScale);
(yAxisGroup as any).call(d3.axisLeft(newYScale));
}
}
}
}
function getScaleFromAxis(axisGroup: d3.Selection<any, any, any, any>): d3.ScaleLinear<number, number> | null {
// This is a simplified implementation - in practice, you'd store references to scales
// or implement a more sophisticated scale retrieval mechanism
try {
const axisNode = axisGroup.node();
if (axisNode && (axisNode as any).__scale__) {
return (axisNode as any).__scale__;
}
} catch (e) {
// Scale retrieval failed - this is expected in some cases
}
return null;
}
// Enable zoom
function enable(): ZoomSystem {
config.enabled = true;
if (zoomBehavior && chartContainer) {
chartContainer.call(zoomBehavior);
}
return zoomSystem;
}
// Disable zoom
function disable(): ZoomSystem {
config.enabled = false;
if (chartContainer) {
chartContainer.on(".zoom", null);
}
return zoomSystem;
}
// Attach zoom to container
function attach(container: d3.Selection<any, any, any, any>): ZoomSystem {
chartContainer = container;
if (config.enabled) {
const behavior = createZoomBehavior();
container.call(behavior);
}
return zoomSystem;
}
// Detach zoom from container
function detach(): ZoomSystem {
if (chartContainer) {
chartContainer.on(".zoom", null);
chartContainer = null;
}
return zoomSystem;
}
// Transform to specific state
function transform(selection: d3.Selection<any, any, any, any>, newTransform: d3.ZoomTransform, duration: number = 0): ZoomSystem {
if (!zoomBehavior) createZoomBehavior();
if (duration > 0) {
selection
.transition()
.duration(duration)
.ease(config.ease)
.call(zoomBehavior!.transform, newTransform);
} else {
selection.call(zoomBehavior!.transform, newTransform);
}
return zoomSystem;
}
// Reset zoom to identity
function reset(duration: number = config.duration): ZoomSystem {
if (chartContainer) {
transform(chartContainer, d3.zoomIdentity, duration);
listeners.call("reset", undefined, { transform: d3.zoomIdentity, sourceEvent: null });
}
return zoomSystem;
}
// Zoom to specific bounds
function zoomTo(bounds: ZoomBounds, duration: number = config.duration): ZoomSystem {
if (!chartContainer || !chartDimensions) return zoomSystem;
const { width, height, margin } = chartDimensions;
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// Calculate transform to fit bounds
const [x0, x1] = bounds.x;
const [y0, y1] = bounds.y;
const scale = Math.min(
chartWidth / (x1 - x0),
chartHeight / (y1 - y0)
);
const translateX = chartWidth / 2 - scale * (x0 + x1) / 2;
const translateY = chartHeight / 2 - scale * (y0 + y1) / 2;
const newTransform = d3.zoomIdentity
.translate(translateX, translateY)
.scale(scale);
transform(chartContainer, newTransform, duration);
return zoomSystem;
}
// Set chart dimensions
function setDimensions(dimensions: ChartDimensions): ZoomSystem {
chartDimensions = dimensions;
updateTranslateExtent();
return zoomSystem;
}
// Configure zoom system
function configure(newConfig: Partial<ZoomConfig>): ZoomSystem {
Object.assign(config, newConfig);
// Update zoom behavior if it exists
if (zoomBehavior) {
if (newConfig.scaleExtent) {
zoomBehavior.scaleExtent(newConfig.scaleExtent);
}
if (newConfig.touchable !== undefined) {
zoomBehavior.touchable(newConfig.touchable);
}
if (newConfig.filter !== undefined) {
zoomBehavior.filter(newConfig.filter || defaultFilter);
}
if (newConfig.wheelDelta !== undefined) {
if (newConfig.wheelDelta) {
zoomBehavior.wheelDelta(newConfig.wheelDelta);
}
}
}
updateTranslateExtent();
return zoomSystem;
}
// Get current transform
function getCurrentTransform(): d3.ZoomTransform {
return currentTransform;
}
// Check if zoom is enabled
function isEnabled(): boolean {
return config.enabled;
}
// Add event listener
function on(type: string, callback: (event: ZoomEventData) => void): ZoomSystem {
(listeners as any).on(type, callback);
return zoomSystem;
}
// Remove event listener
function off(type: string, callback?: (event: ZoomEventData) => void): ZoomSystem {
if (callback) {
(listeners as any).on(type, null);
} else {
(listeners as any).on(type, null);
}
return zoomSystem;
}
const zoomSystem: ZoomSystem = {
enable,
disable,
attach,
detach,
transform,
reset,
zoomTo,
setDimensions,
configure,
getCurrentTransform,
isEnabled,
on,
off
};
return zoomSystem;
}
// The function is already exported above, no need for duplicate export