d3-charts-viz-library
Version:
A comprehensive D3-based chart library with customizable charts and graphs
580 lines (496 loc) • 16.8 kB
JavaScript
import * as d3 from 'd3';
import BaseChart from './BaseChart.js';
/**
* SpiralChart - Creates an animated spiral visualization showing data progression over time
* Features floating particles, breathing animations, and interactive data points
* Perfect for displaying time-series data with engaging spiral layout
*/
class SpiralChart extends BaseChart {
constructor(container, options = {}) {
const defaultOptions = {
width: 800,
height: 600,
margin: { top: 60, right: 60, bottom: 60, left: 60 },
maxRadius: 250,
turns: 4,
particleCount: 20,
animationDuration: 1000,
animationDelay: 100,
showParticles: true,
showFlowLines: true,
showBreathing: true,
selectedMetric: 'all',
colors: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'],
centerLabel: 'Data Flow',
timeField: 'period',
valueField: 'value',
...options
};
super(container, defaultOptions);
this.addTooltip();
this.colorScale = d3.scaleOrdinal(this.options.colors);
// Animation state
this.isAnimating = false;
this.currentPeriod = 0;
this.hoveredPeriod = null;
this.particles = [];
// Metrics configuration - can be customized via options
this.metrics = this.options.metrics || [
{ key: 'all', label: 'All Metrics', color: '#3b82f6', icon: '🌟' },
{ key: 'primary', label: 'Primary Value', color: '#10b981', icon: '📈' },
{ key: 'secondary', label: 'Secondary Value', color: '#f59e0b', icon: '💵' },
{ key: 'tertiary', label: 'Tertiary Value', color: '#ef4444', icon: '📤' },
{ key: 'quaternary', label: 'Quaternary Value', color: '#8b5cf6', icon: '💎' }
];
}
/**
* Process raw data for spiral visualization
*/
processData(rawData) {
// Handle different data formats
let dataArray = [];
if (Array.isArray(rawData)) {
dataArray = rawData;
} else if (rawData?.timeSeries) {
dataArray = rawData.timeSeries;
} else if (rawData?.data) {
dataArray = rawData.data;
} else if (rawData?.economicSchedule) {
// Legacy support for economic data
dataArray = rawData.economicSchedule;
} else {
return [];
}
return dataArray.slice(0, 20).map((d, i) => {
// Generic data mapping with fallbacks
const period = d[this.options.timeField] || d.year || d.time || d.period || (i + 1);
const primaryValue = Math.abs(d.primaryValue || d.value || d.amount || d.growth || 0);
const secondaryValue = Math.abs(d.secondaryValue || d.income || d.revenue || 0);
const tertiaryValue = Math.abs(d.tertiaryValue || d.distribution || d.expense || 0);
const quaternaryValue = Math.abs(d.quaternaryValue || d.remainder || d.profit || 0);
return {
period,
primaryValue,
secondaryValue,
tertiaryValue,
quaternaryValue,
totalValue: primaryValue + secondaryValue + tertiaryValue + quaternaryValue,
originalData: d
};
});
}
/**
* Calculate spiral positions for each data point
*/
calculateSpiralPositions(data) {
const centerX = this.options.width / 2;
const centerY = this.options.height / 2;
const maxRadius = Math.min(this.options.width, this.options.height) / 2.5;
return data.map((d, i) => {
const progress = i / Math.max(1, data.length - 1);
// Use quadratic progression for better spacing
const radiusProgress = Math.pow(progress, 0.8);
const angle = progress * this.options.turns * 2 * Math.PI;
const radius = Math.max(30, radiusProgress * maxRadius);
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
return {
...d,
x: isFinite(x) ? x : centerX,
y: isFinite(y) ? y : centerY,
angle: isFinite(angle) ? angle : 0,
radius: isFinite(radius) ? radius : 30,
progress
};
});
}
/**
* Create gradient definitions and filters
*/
createDefsAndFilters() {
const defs = this.svg.append("defs");
// Create gradients for each metric
this.metrics.forEach(metric => {
const gradient = defs.append("radialGradient")
.attr("id", `gradient-${metric.key}`)
.attr("cx", "50%")
.attr("cy", "50%")
.attr("r", "50%");
gradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", metric.color)
.attr("stop-opacity", 0.8);
gradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", metric.color)
.attr("stop-opacity", 0.2);
});
// 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 background spiral path
*/
createBackgroundSpiral() {
const centerX = this.options.width / 2;
const centerY = this.options.height / 2;
const maxRadius = Math.min(this.options.width, this.options.height) / 2.5;
const backgroundSpiral = this.svg.append("g").attr("class", "background-spiral");
// Create spiral path
const spiralPath = d3.path();
const pointsPerTurn = 50;
const totalPoints = this.options.turns * pointsPerTurn;
for (let i = 0; i <= totalPoints; i++) {
const angle = (i / pointsPerTurn) * 2 * Math.PI;
const radius = (i / totalPoints) * maxRadius;
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
if (i === 0) {
spiralPath.moveTo(x, y);
} else {
spiralPath.lineTo(x, y);
}
}
backgroundSpiral.append("path")
.attr("d", spiralPath.toString())
.attr("fill", "none")
.attr("stroke", "#e5e7eb")
.attr("stroke-width", 2)
.attr("opacity", 0.3);
return backgroundSpiral;
}
/**
* Create data point nodes along the spiral
*/
createDataNodes(positions, mainGroup) {
const yearNodes = mainGroup.selectAll(".data-node")
.data(positions)
.enter()
.append("g")
.attr("class", "data-node")
.attr("transform", d => `translate(${d.x}, ${d.y})`);
// Add glow circles for each data point
yearNodes.append("circle")
.attr("class", "glow-circle")
.attr("r", 0)
.attr("fill", d => {
const metric = this.metrics.find(m => m.key === this.options.selectedMetric) || this.metrics[0];
return `url(#gradient-${metric.key})`;
})
.attr("filter", "url(#glow)")
.transition()
.delay((d, i) => i * this.options.animationDelay)
.duration(this.options.animationDuration)
.attr("r", d => this.calculateNodeRadius(d));
// Add metric value circles
yearNodes.append("circle")
.attr("class", "metric-circle")
.attr("r", 0)
.attr("fill", "white")
.attr("stroke", d => {
const metric = this.metrics.find(m => m.key === this.options.selectedMetric) || this.metrics[0];
return metric.color;
})
.attr("stroke-width", 3)
.transition()
.delay((d, i) => i * this.options.animationDelay + 200)
.duration(this.options.animationDuration * 0.8)
.attr("r", d => this.calculateNodeRadius(d));
// Add period labels
yearNodes.append("text")
.attr("class", "period-label")
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("font-size", "12px")
.attr("font-weight", "bold")
.attr("fill", "#374151")
.attr("opacity", 0)
.text(d => `${d.period}`)
.transition()
.delay((d, i) => i * this.options.animationDelay + 400)
.duration(this.options.animationDuration * 0.6)
.attr("opacity", 1);
return yearNodes;
}
/**
* Calculate node radius based on selected metric
*/
calculateNodeRadius(d) {
const maxValue = Math.max(1, d3.max(this.processedData, item => this.getMetricValue(item)));
const value = this.getMetricValue(d);
return Math.max(8, 8 + (value / maxValue) * 15); // 8-23px radius
}
/**
* Get metric value based on selected metric
*/
getMetricValue(d) {
switch (this.options.selectedMetric) {
case 'primary':
return d.primaryValue || 0;
case 'secondary':
return d.secondaryValue || 0;
case 'tertiary':
return d.tertiaryValue || 0;
case 'quaternary':
return d.quaternaryValue || 0;
default:
return d.totalValue || d.primaryValue || 0;
}
}
/**
* Create connecting flow lines between data points
*/
createFlowLines(positions, mainGroup) {
if (!this.options.showFlowLines) return;
const flowLines = mainGroup.append("g").attr("class", "flow-lines");
for (let i = 0; i < positions.length - 1; i++) {
const current = positions[i];
const next = positions[i + 1];
const line = flowLines.append("line")
.attr("x1", current.x)
.attr("y1", current.y)
.attr("x2", current.x)
.attr("y2", current.y)
.attr("stroke", "#6366f1")
.attr("stroke-width", 2)
.attr("opacity", 0.6)
.attr("stroke-dasharray", "5,5");
line.transition()
.delay(i * 150 + 600)
.duration(800)
.attr("x2", next.x)
.attr("y2", next.y);
}
return flowLines;
}
/**
* Create floating particles along the spiral
*/
createFloatingParticles(positions, mainGroup) {
if (!this.options.showParticles) return;
const particles = mainGroup.append("g").attr("class", "particles");
for (let i = 0; i < this.options.particleCount; i++) {
const particle = particles.append("circle")
.attr("class", "particle")
.attr("r", Math.random() * 3 + 1)
.attr("fill", this.colorScale(0))
.attr("opacity", 0.6);
this.animateParticle(particle, positions);
}
return particles;
}
/**
* Animate particle along spiral path
*/
animateParticle(particle, positions) {
const randomStart = Math.floor(Math.random() * positions.length);
const startPos = positions[randomStart];
particle
.attr("cx", startPos.x)
.attr("cy", startPos.y)
.transition()
.duration(3000 + Math.random() * 2000)
.ease(d3.easeLinear)
.attrTween("cx", () => {
return (t) => {
const index = Math.floor(t * (positions.length - 1));
const nextIndex = Math.min(index + 1, positions.length - 1);
const localT = (t * (positions.length - 1)) - index;
return positions[index].x + (positions[nextIndex].x - positions[index].x) * localT;
};
})
.attrTween("cy", () => {
return (t) => {
const index = Math.floor(t * (positions.length - 1));
const nextIndex = Math.min(index + 1, positions.length - 1);
const localT = (t * (positions.length - 1)) - index;
return positions[index].y + (positions[nextIndex].y - positions[index].y) * localT;
};
})
.on("end", () => this.animateParticle(particle, positions));
}
/**
* Add breathing animation to nodes
*/
addBreathingAnimation(nodes) {
if (!this.options.showBreathing) return;
const breathe = () => {
nodes.selectAll(".glow-circle")
.transition()
.duration(2000)
.ease(d3.easeSinInOut)
.attr("r", d => this.calculateNodeRadius(d) * 1.15)
.transition()
.duration(2000)
.ease(d3.easeSinInOut)
.attr("r", d => this.calculateNodeRadius(d))
.on("end", function() {
if (!this.isAnimating) {
breathe();
}
});
};
setTimeout(() => {
if (!this.isAnimating) {
breathe();
}
}, 2000);
}
/**
* Create center information display
*/
createCenterInfo() {
const centerX = this.options.width / 2;
const centerY = this.options.height / 2;
const centerInfo = this.svg.append("g")
.attr("class", "center-info")
.attr("transform", `translate(${centerX}, ${centerY})`);
centerInfo.append("circle")
.attr("r", 40)
.attr("fill", "rgba(59, 130, 246, 0.1)")
.attr("stroke", "#3b82f6")
.attr("stroke-width", 2)
.attr("stroke-dasharray", "5,5");
centerInfo.append("text")
.attr("text-anchor", "middle")
.attr("dy", "-0.5em")
.attr("font-size", "14px")
.attr("font-weight", "bold")
.attr("fill", "#374151")
.text("SPIRAL");
centerInfo.append("text")
.attr("text-anchor", "middle")
.attr("dy", "1em")
.attr("font-size", "12px")
.attr("fill", "#6b7280")
.text(this.options.centerLabel);
return centerInfo;
}
/**
* Add hover interactions to nodes
*/
addHoverInteractions(nodes) {
const self = this;
nodes
.on("mouseover", function(event, d) {
self.hoveredPeriod = d.period;
d3.select(this).select(".glow-circle")
.transition()
.duration(200)
.attr("r", self.calculateNodeRadius(d) * 1.3);
self.showDataTooltip(event, d);
})
.on("mouseout", function(event, d) {
self.hoveredPeriod = null;
d3.select(this).select(".glow-circle")
.transition()
.duration(200)
.attr("r", self.calculateNodeRadius(d));
self.hideTooltip();
});
}
/**
* Show tooltip with data information
*/
showDataTooltip(event, d) {
const tooltipContent = `
<div style="font-weight: bold; margin-bottom: 8px;">Period ${d.period}</div>
<div style="color: ${this.colorScale(0)};">Primary: ${this.formatValue(d.primaryValue)}</div>
<div style="color: ${this.colorScale(1)};">Secondary: ${this.formatValue(d.secondaryValue)}</div>
<div style="color: ${this.colorScale(2)};">Tertiary: ${this.formatValue(d.tertiaryValue)}</div>
<div style="color: ${this.colorScale(3)};">Quaternary: ${this.formatValue(d.quaternaryValue)}</div>
<div style="margin-top: 8px; font-weight: bold;">Total: ${this.formatValue(d.totalValue)}</div>
`;
super.showTooltip(tooltipContent, event);
}
/**
* Format values for display
*/
formatValue(value) {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toFixed(0)}K`;
}
return `${value.toFixed(0)}`;
}
/**
* Main render method
*/
render() {
if (!this.data) return this;
// Clear existing content
this.svg.selectAll('*').remove();
// Process data
this.processedData = this.processData(this.data);
if (this.processedData.length === 0) return this;
// Calculate spiral positions
const positions = this.calculateSpiralPositions(this.processedData);
// Create defs and filters
this.createDefsAndFilters();
// Create background spiral
this.createBackgroundSpiral();
// Create main visualization group
const mainGroup = this.svg.append("g").attr("class", "main-visualization");
// Create data nodes
const nodes = this.createDataNodes(positions, mainGroup);
// Create flow lines
this.createFlowLines(positions, mainGroup);
// Create floating particles
this.createFloatingParticles(positions, mainGroup);
// Add breathing animation
this.addBreathingAnimation(nodes);
// Create center info
this.createCenterInfo();
// Add hover interactions
this.addHoverInteractions(nodes);
return this;
}
/**
* Update chart with new data
*/
update(newData) {
this.setData(newData);
return this.render();
}
/**
* Set selected metric and re-render
*/
setSelectedMetric(metric) {
this.options.selectedMetric = metric;
return this.render();
}
/**
* Start animation sequence
*/
startAnimation() {
this.isAnimating = true;
// Implement custom animation logic here
setTimeout(() => {
this.isAnimating = false;
}, 5000);
return this;
}
/**
* Reset chart to initial state
*/
reset() {
this.isAnimating = false;
this.currentPeriod = 0;
this.options.selectedMetric = 'all';
return this.render();
}
}
export default SpiralChart;