d3-charts-viz-library
Version:
A comprehensive D3-based chart library with customizable charts and graphs
716 lines (622 loc) • 24.2 kB
JavaScript
import * as d3 from 'd3';
import BaseChart from './BaseChart.js';
/**
* FlowContainersChart - Creates an animated flow visualization with bucket containers
* Features dollar symbols flowing into containers with liquid-like filling effects
* Perfect for showing financial data flow with engaging animations
*/
class FlowContainersChart extends BaseChart {
constructor(container, options = {}) {
const defaultOptions = {
width: 800,
height: 600,
margin: { top: 60, right: 40, bottom: 60, left: 40 },
containerWidth: 120,
containerHeight: 200,
containerSpacing: 180,
animationDuration: 1000,
animationDelay: 100,
maxDollarSymbols: 12,
particleCount: 10,
sparkleCount: 20,
colors: ['#10b981', '#8b5cf6', '#f59e0b', '#ef4444', '#06b6d4'],
showSparkles: true,
showParticles: true,
showYearLabels: true,
autoPlay: false,
...options
};
super(container, defaultOptions);
this.addTooltip();
this.colorScale = d3.scaleOrdinal(this.options.colors);
// Animation state
this.isAnimating = false;
this.currentYear = 0;
this.showTotals = false;
// Container definitions
this.containers = [
{ key: 'primaryValue', label: 'Primary', icon: '💰', color: this.colorScale(0) },
{ key: 'secondaryValue', label: 'Secondary', icon: '📈', color: this.colorScale(1) },
{ key: 'tertiaryValue', label: 'Tertiary', icon: '💵', color: this.colorScale(2) },
{ key: 'distributionValue', label: 'Distribution', icon: '📤', color: this.colorScale(3) },
{ key: 'remainderValue', label: 'Remainder', icon: '💎', color: this.colorScale(4) }
];
}
/**
* Process raw data for flow containers visualization
*/
processData(rawData) {
// Handle new containers data format
if (rawData?.containers) {
return rawData.containers.slice(0, 10).map((d, i) => ({
year: d.year || (i + 1),
primaryValue: Math.abs(d.amount * 0.4) || 0,
secondaryValue: Math.abs(d.amount * 0.3) || 0,
tertiaryValue: Math.abs(d.amount * 0.2) || 0,
distributionValue: Math.abs(d.amount * 0.1) || 0,
remainderValue: Math.abs(d.amount * 0.05) || 0,
totalValue: Math.abs(d.amount) || 0,
fillPercentage: d.fillPercentage || 0.5,
label: d.label || `Year ${d.year || (i + 1)}`
}));
}
// Handle legacy economicSchedule format
if (rawData?.economicSchedule) {
const scheduleData = rawData.economicSchedule.slice(0, 10);
return scheduleData.map((d, i) => ({
year: i + 1,
primaryValue: Math.abs(d.remainder * 0.4) || 0,
secondaryValue: Math.abs(d.remainder * 0.3) || 0,
tertiaryValue: Math.abs(d.remainder * 0.2) || 0,
distributionValue: Math.abs(d.distribution) || 0,
remainderValue: Math.abs(d.remainder * 0.1) || 0,
totalValue: Math.abs(d.remainder) || 0,
fillPercentage: 0.5,
label: `Year ${i + 1}`
}));
}
return [];
}
/**
* Create gradient definitions and filters
*/
createDefsAndFilters() {
const defs = this.svg.append("defs");
// Create gradients for each container
this.containers.forEach(container => {
const gradient = defs.append("linearGradient")
.attr("id", `gradient-${container.key}`)
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "0%")
.attr("y2", "100%");
gradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", d3.color(container.color)?.brighter(0.5)?.toString() || container.color)
.attr("stop-opacity", 0.9);
gradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", container.color)
.attr("stop-opacity", 0.7);
});
// Create glow filter
const filter = defs.append("filter")
.attr("id", "glow")
.attr("x", "-50%")
.attr("y", "-50%")
.attr("width", "200%")
.attr("height", "200%");
filter.append("feGaussianBlur")
.attr("stdDeviation", "3")
.attr("result", "coloredBlur");
const feMerge = filter.append("feMerge");
feMerge.append("feMergeNode").attr("in", "coloredBlur");
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
return defs;
}
/**
* Create floating sparkles background
*/
createSparkles(mainGroup) {
if (!this.options.showSparkles) return;
const sparkleGroup = mainGroup.append("g").attr("class", "sparkles");
const { width, height } = this.options;
for (let i = 0; i < this.options.sparkleCount; i++) {
sparkleGroup.append("circle")
.attr("cx", Math.random() * width)
.attr("cy", Math.random() * height)
.attr("r", Math.random() * 3 + 1)
.attr("fill", "#fbbf24")
.attr("opacity", Math.random() * 0.5 + 0.2)
.attr("filter", "url(#glow)")
.transition()
.duration(2000 + Math.random() * 3000)
.ease(d3.easeSinInOut)
.attr("cy", Math.random() * height)
.attr("opacity", Math.random() * 0.3 + 0.1)
.on("end", function repeat() {
d3.select(this)
.transition()
.duration(2000 + Math.random() * 3000)
.ease(d3.easeSinInOut)
.attr("cy", Math.random() * height)
.attr("opacity", Math.random() * 0.5 + 0.2)
.on("end", repeat);
});
}
}
/**
* Create container groups with bucket shapes
*/
createContainers(mainGroup, data) {
const startX = (this.options.width - (this.containers.length * this.options.containerSpacing - (this.options.containerSpacing - this.options.containerWidth))) / 2;
// Calculate maximum values for scaling
const maxValues = this.containers.map(container => {
return data.reduce((sum, d) => sum + Math.abs(d[container.key] || 0), 0);
});
const containerGroups = mainGroup.selectAll(".container-group")
.data(this.containers)
.enter()
.append("g")
.attr("class", "container-group")
.attr("transform", (d, i) => `translate(${startX + i * this.options.containerSpacing}, 200)`);
// Draw bucket-shaped container outlines
containerGroups.append("path")
.attr("class", "container-outline")
.attr("d", () => {
const topWidth = this.options.containerWidth * 0.8;
const bottomWidth = this.options.containerWidth;
const height = this.options.containerHeight;
const topLeft = (this.options.containerWidth - topWidth) / 2;
const topRight = (this.options.containerWidth + topWidth) / 2;
return `M ${topLeft} 0 L ${topRight} 0 L ${bottomWidth} ${height} L 0 ${height} Z`;
})
.attr("fill", "none")
.attr("stroke", d => d.color)
.attr("stroke-width", 3)
.attr("stroke-opacity", 0.6)
.attr("rx", 5);
// Add container labels
containerGroups.append("text")
.attr("class", "container-label")
.attr("x", this.options.containerWidth / 2)
.attr("y", -15)
.attr("text-anchor", "middle")
.attr("font-size", "14px")
.attr("font-weight", "bold")
.attr("fill", d => d.color)
.text(d => `${d.icon} ${d.label}`);
// Add value displays
containerGroups.append("text")
.attr("class", "value-display")
.attr("x", this.options.containerWidth / 2)
.attr("y", this.options.containerHeight + 25)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("font-weight", "bold")
.attr("fill", "#374151")
.text("$0");
// Create sub-groups for different elements
containerGroups.append("g").attr("class", "dollar-container");
containerGroups.append("g").attr("class", "year-lines");
containerGroups.append("g").attr("class", "dollar-particles");
// Add amount display backgrounds
containerGroups.append("rect")
.attr("class", "amount-bg")
.attr("x", -10)
.attr("y", this.options.containerHeight + 10)
.attr("width", this.options.containerWidth + 20)
.attr("height", 30)
.attr("rx", 15)
.attr("fill", d => d.color)
.attr("opacity", 0.2)
.attr("filter", "url(#glow)");
// Add fill level indicator line
containerGroups.append("line")
.attr("class", "fill-level-indicator")
.attr("x1", -5)
.attr("x2", this.options.containerWidth + 5)
.attr("y1", this.options.containerHeight)
.attr("y2", this.options.containerHeight)
.attr("stroke", d => d.color)
.attr("stroke-width", 3)
.attr("stroke-dasharray", "8,4")
.attr("opacity", 0)
.attr("filter", "url(#glow)");
// Initialize fill elements
this.initializeFillElements(containerGroups);
return { containerGroups, maxValues };
}
/**
* Initialize liquid fill elements for containers
*/
initializeFillElements(containerGroups) {
containerGroups.each((d, i) => {
const containerGroup = d3.select(containerGroups.nodes()[i]);
const container = this.containers[i];
// Main fill rectangle
containerGroup.append("rect")
.attr("class", "fill-background")
.attr("x", 8)
.attr("width", this.options.containerWidth - 16)
.attr("y", this.options.containerHeight)
.attr("height", 0)
.attr("fill", container.color)
.attr("opacity", 0.15)
.attr("rx", 5);
// Liquid surface effect
containerGroup.append("ellipse")
.attr("class", "liquid-surface")
.attr("cx", this.options.containerWidth / 2)
.attr("cy", this.options.containerHeight)
.attr("rx", (this.options.containerWidth - 16) / 2)
.attr("ry", 3)
.attr("fill", container.color)
.attr("opacity", 0)
.attr("filter", "url(#glow)");
// Bubble effects
for (let b = 0; b < 3; b++) {
containerGroup.append("circle")
.attr("class", `bubble-${b}`)
.attr("cx", this.options.containerWidth / 2 + (Math.random() - 0.5) * 40)
.attr("cy", this.options.containerHeight)
.attr("r", 1)
.attr("fill", container.color)
.attr("opacity", 0)
.attr("filter", "url(#glow)");
}
});
}
/**
* Animate a specific year
*/
animateYear(yearIndex, data, containerGroups, maxValues, mainGroup) {
if (yearIndex >= data.length) {
this.showTotals = true;
this.isAnimating = false;
this._showTotalValuesInternal(data, containerGroups, maxValues);
return;
}
const yearData = data[yearIndex];
// Update containers for this year
this.containers.forEach((container, i) => {
if (!container || !container.key) return;
const cumulativeValue = data.slice(0, yearIndex + 1).reduce((sum, d) => {
return sum + Math.abs((d && d[container.key]) || 0);
}, 0);
const maxValue = maxValues && maxValues[i] ? maxValues[i] : 1;
const fillHeight = maxValue > 0 ? (cumulativeValue / maxValue) * this.options.containerHeight * 0.8 : 0;
const currentValue = Math.abs((yearData && yearData[container.key]) || 0);
const numDollars = Math.min(Math.floor(fillHeight / 15), this.options.maxDollarSymbols);
const containerGroup = d3.select(containerGroups.nodes()[i]);
if (containerGroup.empty()) return;
this.updateContainerFill(containerGroup, container, fillHeight, currentValue, yearIndex);
this.createDollarSymbols(containerGroup, container, numDollars, currentValue);
this.createFlowingParticles(containerGroup, container, currentValue, fillHeight, numDollars);
this.updateValueDisplay(containerGroup, cumulativeValue);
});
// Update year indicator
this.updateYearIndicator(mainGroup, yearData.year);
// Continue to next year
const totalCurrentValue = this.containers.reduce((sum, c) => sum + Math.abs(yearData[c.key] || 0), 0);
const delay = totalCurrentValue > 0 ? this.options.animationDuration + 1000 : 500;
d3.timeout(() => {
if (this.isAnimating) {
this.animateYear(yearIndex + 1, data, containerGroups, maxValues, mainGroup);
}
}, delay);
}
/**
* Update container fill animation
*/
updateContainerFill(containerGroup, container, fillHeight, currentValue, yearIndex) {
// Add year separation line
const yearLinesGroup = containerGroup.select(".year-lines");
yearLinesGroup.append("line")
.attr("x1", 10)
.attr("x2", this.options.containerWidth - 10)
.attr("y1", this.options.containerHeight - fillHeight)
.attr("y2", this.options.containerHeight - fillHeight)
.attr("stroke", container.color)
.attr("stroke-width", 2)
.attr("opacity", 0)
.transition()
.duration(500)
.attr("opacity", currentValue > 0 ? 0.4 : 0);
if (this.options.showYearLabels) {
yearLinesGroup.append("text")
.attr("x", this.options.containerWidth + 15)
.attr("y", this.options.containerHeight - fillHeight + 5)
.attr("font-size", "12px")
.attr("font-weight", "bold")
.attr("fill", container.color)
.attr("opacity", 0)
.text(`Y${yearIndex + 1}`)
.transition()
.duration(500)
.attr("opacity", currentValue > 0 ? 0.9 : 0);
}
// Animate liquid fill
containerGroup.select(".fill-background")
.transition()
.duration(this.options.animationDuration * 1.5)
.ease(d3.easeExpOut)
.attr("y", this.options.containerHeight - fillHeight)
.attr("height", fillHeight)
.attr("opacity", fillHeight > 0 ? 0.25 : 0.15);
// Animate liquid surface
containerGroup.select(".liquid-surface")
.transition()
.duration(this.options.animationDuration * 1.5)
.ease(d3.easeExpOut)
.attr("cy", this.options.containerHeight - fillHeight)
.attr("opacity", fillHeight > 0 ? 0.6 : 0)
.attr("ry", fillHeight > 0 ? 4 : 3);
// Animate bubbles
for (let b = 0; b < 3; b++) {
containerGroup.select(`.bubble-${b}`)
.transition()
.duration(this.options.animationDuration * 1.2)
.delay(b * 200)
.ease(d3.easeExpOut)
.attr("cy", this.options.containerHeight - fillHeight + Math.random() * 10)
.attr("opacity", fillHeight > 0 ? 0.4 : 0)
.attr("r", fillHeight > 0 ? Math.random() * 3 + 1 : 1);
}
// Animate fill level indicator
containerGroup.select(".fill-level-indicator")
.transition()
.duration(this.options.animationDuration * 1.5)
.ease(d3.easeExpOut)
.attr("y1", this.options.containerHeight - fillHeight)
.attr("y2", this.options.containerHeight - fillHeight)
.attr("opacity", fillHeight > 0 ? 0.9 : 0)
.attr("stroke-width", fillHeight > 0 ? 4 : 3);
}
/**
* Create dollar symbols in container
*/
createDollarSymbols(containerGroup, container, numDollars, currentValue) {
const dollarGroup = containerGroup.select(".dollar-container");
dollarGroup.selectAll("*").remove();
for (let d = 0; d < numDollars; d++) {
const dollarY = this.options.containerHeight - (d + 1) * 15;
const dollarX = this.options.containerWidth / 2 + (Math.random() - 0.5) * 60;
dollarGroup.append("text")
.attr("x", dollarX)
.attr("y", this.options.containerHeight + 20)
.attr("text-anchor", "middle")
.attr("font-size", "16px")
.attr("font-weight", "bold")
.attr("fill", container.color)
.attr("opacity", 0)
.attr("filter", "url(#glow)")
.text("$")
.attr("transform", "scale(0.5)")
.transition()
.duration(this.options.animationDuration * 0.8)
.delay(d * 100)
.ease(d3.easeExpOut)
.attr("y", dollarY)
.attr("opacity", 0.9)
.attr("transform", `scale(1.2) rotate(${(Math.random() - 0.5) * 10})`)
.on("end", function() {
// Add floating animation
d3.select(this)
.transition()
.duration(3000 + Math.random() * 2000)
.ease(d3.easeSinInOut)
.attr("y", dollarY - 3)
.transition()
.duration(3000 + Math.random() * 2000)
.ease(d3.easeSinInOut)
.attr("y", dollarY + 3)
.on("end", function floatRepeat() {
d3.select(this)
.transition()
.duration(3000 + Math.random() * 2000)
.ease(d3.easeSinInOut)
.attr("y", dollarY - 3)
.transition()
.duration(3000 + Math.random() * 2000)
.ease(d3.easeSinInOut)
.attr("y", dollarY + 3)
.on("end", floatRepeat);
});
});
}
}
/**
* Create flowing particle effects
*/
createFlowingParticles(containerGroup, container, currentValue, fillHeight, numDollars) {
if (!this.options.showParticles || currentValue <= 0) return;
const particleGroup = containerGroup.select(".dollar-particles");
const { animationDuration, containerWidth, containerHeight } = this.options;
for (let p = 0; p < this.options.particleCount; p++) {
const startY = -80 - (Math.random() * 40);
const endY = containerHeight - fillHeight - 20;
const particle = particleGroup.append("text")
.attr("x", containerWidth / 2 + (Math.random() - 0.5) * 50)
.attr("y", startY)
.attr("text-anchor", "middle")
.attr("font-size", "16px")
.attr("font-weight", "bold")
.attr("fill", container.color)
.attr("opacity", 0.8)
.attr("filter", "url(#glow)")
.text("$")
.attr("transform", `rotate(${(Math.random() - 0.5) * 45}) scale(1.2)`);
// Falling animation
particle.transition()
.duration(animationDuration * 0.8)
.delay(p * 120)
.ease(d3.easeQuadIn)
.attr("y", endY)
.attr("transform", `rotate(${(Math.random() - 0.5) * 90}) scale(1.0)`)
.on("end", function() {
// Settle animation
d3.select(this)
.transition()
.duration(animationDuration * 0.3)
.ease(d3.easeBounceOut)
.attr("y", containerHeight - (Math.floor(Math.random() * numDollars) + 1) * 15)
.attr("x", containerWidth / 2 + (Math.random() - 0.5) * 70)
.attr("opacity", 0.9)
.attr("transform", `rotate(${(Math.random() - 0.5) * 20}) scale(1.1)`)
.on("end", function() {
// Fade out
d3.select(this)
.transition()
.duration(500)
.attr("opacity", 0)
.remove();
});
});
}
}
/**
* Update value display
*/
updateValueDisplay(containerGroup, cumulativeValue) {
containerGroup.select(".value-display")
.transition()
.duration(this.options.animationDuration)
.tween("text", function() {
const node = this;
const previousValue = parseFloat(node.textContent.replace(/[$,]/g, '')) || 0;
const interpolate = d3.interpolateNumber(previousValue, cumulativeValue);
return function(t) {
node.textContent = `$${Math.round(interpolate(t)).toLocaleString()}`;
};
});
}
/**
* Update year indicator
*/
updateYearIndicator(mainGroup, year) {
let yearIndicator = mainGroup.select(".year-indicator");
if (yearIndicator.empty()) {
yearIndicator = mainGroup.append("text")
.attr("class", "year-indicator")
.attr("x", this.options.width / 2)
.attr("y", 50)
.attr("text-anchor", "middle")
.attr("font-size", "24px")
.attr("font-weight", "bold")
.attr("fill", "#1f2937");
}
yearIndicator.text(`Year ${year}`);
}
/**
* Show total values (internal method with parameters)
*/
_showTotalValuesInternal(data, containerGroups, maxValues) {
const totals = this.containers.map(container => {
return data.reduce((sum, d) => sum + Math.abs(d[container.key] || 0), 0);
});
this.containers.forEach((container, i) => {
const total = totals[i];
const maxTotal = Math.max(...totals);
const fillHeight = maxTotal > 0 ? (total / maxTotal) * this.options.containerHeight * 0.8 : 0;
const containerGroup = d3.select(containerGroups.nodes()[i]);
// Update fill
containerGroup.select(".fill-background")
.transition()
.duration(1000)
.ease(d3.easeQuadInOut)
.attr("y", this.options.containerHeight - fillHeight)
.attr("height", fillHeight);
// Update value display
containerGroup.select(".value-display")
.transition()
.duration(1000)
.tween("text", function() {
const node = this;
const currentValue = parseFloat(node.textContent.replace(/[$,]/g, '')) || 0;
const interpolate = d3.interpolateNumber(currentValue, total);
return function(t) {
node.textContent = `$${Math.round(interpolate(t)).toLocaleString()}`;
};
});
});
// Update year indicator
const mainGroup = this.svg.select(".main-visualization");
mainGroup.select(".year-indicator").text("Total Values");
}
/**
* Main render method
*/
render() {
if (!this.data) return this;
// Clear existing content
this.svg.selectAll('*').remove();
// Process data
const processedData = this.processData(this.data);
if (processedData.length === 0) return this;
// Create defs and filters
this.createDefsAndFilters();
// Create main group
const mainGroup = this.svg.append("g").attr("class", "main-visualization");
// Create sparkles
this.createSparkles(mainGroup);
// Create containers
const { containerGroups, maxValues } = this.createContainers(mainGroup, processedData);
// Start animation if auto-play is enabled
if (this.options.autoPlay) {
this.startAnimation(processedData, containerGroups, maxValues, mainGroup);
}
return this;
}
/**
* Start animation
*/
startAnimation(data, containerGroups, maxValues, mainGroup) {
// If called without parameters (from playground), use current state
if (!data) {
const processedData = this.processData(this.data);
const currentContainerGroups = this.svg.selectAll(".container-group");
const currentMaxValues = this.containers.map(container => {
return processedData.reduce((sum, d) => sum + Math.abs((d && d[container.key]) || 0), 0);
});
const currentMainGroup = this.svg.select(".main-group");
return this.startAnimation(processedData, currentContainerGroups, currentMaxValues, currentMainGroup);
}
this.isAnimating = true;
this.currentYear = 0;
this.showTotals = false;
// Clear existing animations
this.svg.selectAll(".dollar-container").selectAll("*").remove();
this.svg.selectAll(".year-lines").selectAll("*").remove();
this.animateYear(0, data, containerGroups, maxValues, mainGroup);
}
/**
* Show totals
*/
showTotalValues() {
const processedData = this.processData(this.data);
const containerGroups = this.svg.selectAll(".container-group");
const maxValues = this.containers.map(container => {
return processedData.reduce((sum, d) => sum + Math.abs(d[container.key] || 0), 0);
});
// Call the internal method with parameters
this._showTotalValuesInternal(processedData, containerGroups, maxValues);
}
/**
* Reset animation
*/
reset() {
this.isAnimating = false;
this.currentYear = 0;
this.showTotals = false;
return this.render();
}
/**
* Update chart with new data
*/
update(newData) {
this.setData(newData);
return this.render();
}
}
export default FlowContainersChart;