d3-charts-viz-library
Version:
A comprehensive D3-based chart library with customizable charts and graphs
612 lines (529 loc) • 19.3 kB
JavaScript
import * as d3 from 'd3';
import BaseChart from './BaseChart.js';
/**
* RadialTimelineChart - Creates a radial timeline visualization with animated arcs
* Features layered arcs, interactive tooltips, and smooth animations
* Perfect for showing time-based data with multiple value components
*/
class RadialTimelineChart extends BaseChart {
constructor(container, options = {}) {
const defaultOptions = {
width: 500,
height: 500,
margin: { top: 60, right: 60, bottom: 60, left: 60 },
animationDuration: 1000,
animationDelay: 100,
innerRadius: 40,
outerRadiusOffset: 20,
cornerRadius: 2,
strokeWidth: 1,
segmentWidthRatio: 0.8, // 80% width for gaps
gridRings: 5,
colors: ['#22c55e', '#3b82f6', '#f59e0b'], // Green, Blue, Amber
showGridLines: true,
showYearLabels: true,
showCenterLabel: true,
centerLabelText: 'Timeline',
valueRatios: [0.6, 0.4], // Default ratios for primary/secondary values
...options
};
super(container, defaultOptions);
this.addTooltip();
this.colorScale = d3.scaleOrdinal(this.options.colors);
this.hoveredYear = null;
}
/**
* Process raw data for radial timeline visualization
*/
processData(rawData) {
if (!rawData?.economicSchedule) return [];
const scheduleData = rawData.economicSchedule.slice(0, 12); // Up to 12 years
return scheduleData.map((d, i) => {
const primaryValue = d.remainder * this.options.valueRatios[0];
const secondaryValue = d.remainder * this.options.valueRatios[1];
return {
year: i + 1,
primaryValue: primaryValue,
secondaryValue: secondaryValue,
totalValue: d.remainder,
tertiaryValue: d.distribution || 0,
income: d.income || 0
};
});
}
/**
* Calculate chart dimensions
*/
calculateDimensions() {
const radius = Math.min(this.options.width, this.options.height) / 2 - Math.max(...Object.values(this.options.margin));
const centerX = this.options.width / 2;
const centerY = this.options.height / 2;
return { radius, centerX, centerY };
}
/**
* Create scales for the chart with comprehensive validation
*/
createScales(data) {
// Validate input data and calculate max value
const validData = data.filter(d => d && typeof d === 'object');
const values = validData.map(d => Math.abs(d.totalValue || 0)).filter(v => isFinite(v));
const maxValue = Math.max(1, d3.max(values) || 1);
const { radius } = this.calculateDimensions();
// Ensure valid radius range with comprehensive validation
const innerRadius = Math.max(0, this.options.innerRadius || 0);
const outerRadiusOffset = Math.max(0, this.options.outerRadiusOffset || 0);
const minRadius = innerRadius;
const maxRadius = Math.max(minRadius + 20, radius - outerRadiusOffset);
// Validate radius values
if (!isFinite(minRadius) || !isFinite(maxRadius) || maxRadius <= minRadius) {
console.warn('Invalid radius values, using defaults:', { minRadius, maxRadius });
const fallbackMin = 40;
const fallbackMax = Math.max(fallbackMin + 20, 100);
this.radiusScale = d3.scaleLinear().domain([0, maxValue]).range([fallbackMin, fallbackMax]);
} else {
this.radiusScale = d3.scaleLinear().domain([0, maxValue]).range([minRadius, maxRadius]);
}
// Create angle scale with validation
const dataLength = Math.max(1, validData.length);
this.angleScale = d3.scaleLinear()
.domain([0, dataLength])
.range([0, 2 * Math.PI]);
// Create color gradients with safe color handling
try {
const primaryColor = this.colorScale(0) || '#22c55e';
const secondaryColor = this.colorScale(1) || '#3b82f6';
const tertiaryColor = this.colorScale(2) || '#f59e0b';
this.primaryColorScale = d3.scaleLinear()
.domain([0, maxValue])
.range([d3.color(primaryColor)?.brighter(0.3)?.toString() || primaryColor, primaryColor]);
this.secondaryColorScale = d3.scaleLinear()
.domain([0, maxValue])
.range([d3.color(secondaryColor)?.brighter(0.3)?.toString() || secondaryColor, secondaryColor]);
this.tertiaryColorScale = d3.scaleLinear()
.domain([0, maxValue])
.range([d3.color(tertiaryColor)?.brighter(0.3)?.toString() || tertiaryColor, tertiaryColor]);
} catch (e) {
console.warn('Color scale creation failed, using fallbacks:', e);
// Fallback color scales
this.primaryColorScale = d3.scaleLinear().domain([0, maxValue]).range(['#22c55e', '#16a34a']);
this.secondaryColorScale = d3.scaleLinear().domain([0, maxValue]).range(['#3b82f6', '#2563eb']);
this.tertiaryColorScale = d3.scaleLinear().domain([0, maxValue]).range(['#f59e0b', '#d97706']);
}
}
/**
* Create radial grid lines
*/
createGridLines() {
if (!this.options.showGridLines) return;
const { radius, centerX, centerY } = this.calculateDimensions();
const g = this.svg.append("g")
.attr("transform", `translate(${centerX}, ${centerY})`);
for (let i = 1; i <= this.options.gridRings; i++) {
const ringRadius = (radius / this.options.gridRings) * i;
g.append("circle")
.attr("cx", 0)
.attr("cy", 0)
.attr("r", ringRadius)
.attr("fill", "none")
.attr("stroke", "#374151")
.attr("stroke-width", 0.5)
.attr("stroke-dasharray", "2,2")
.attr("opacity", 0.3);
}
return g;
}
/**
* Create year labels around the circle
*/
createYearLabels(data, mainGroup) {
if (!this.options.showYearLabels) return;
const { radius } = this.calculateDimensions();
data.forEach((d, i) => {
const angle = this.angleScale(i) - Math.PI / 2; // Start from top
const labelRadius = radius + 20;
const x = Math.cos(angle) * labelRadius;
const y = Math.sin(angle) * labelRadius;
mainGroup.append("text")
.attr("x", x)
.attr("y", y)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("font-size", "12px")
.attr("fill", "#94a3b8")
.attr("font-weight", "500")
.text(`Y${d.year}`)
.style("opacity", 0)
.transition()
.delay(i * this.options.animationDelay)
.duration(this.options.animationDuration)
.style("opacity", 1);
});
}
/**
* Create center label
*/
createCenterLabel(mainGroup) {
if (!this.options.showCenterLabel) return;
const centerGroup = mainGroup.append("g").attr("class", "center-label");
centerGroup.append("text")
.attr("x", 0)
.attr("y", -10)
.attr("text-anchor", "middle")
.attr("font-size", "16px")
.attr("font-weight", "bold")
.attr("fill", "#e2e8f0")
.text("RADIAL")
.style("opacity", 0)
.transition()
.delay(500)
.duration(this.options.animationDuration)
.style("opacity", 1);
centerGroup.append("text")
.attr("x", 0)
.attr("y", 10)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("fill", "#94a3b8")
.text(this.options.centerLabelText)
.style("opacity", 0)
.transition()
.delay(700)
.duration(this.options.animationDuration)
.style("opacity", 1);
}
/**
* Process data into arc segments with comprehensive validation
*/
processArcs(data) {
const arcs = [];
data.forEach((d, i) => {
// Validate input data
const totalValue = isFinite(d.totalValue) ? Math.abs(d.totalValue || 0) : 0;
const primaryValue = isFinite(d.primaryValue) ? Math.abs(d.primaryValue || 0) : 0;
const secondaryValue = isFinite(d.secondaryValue) ? Math.abs(d.secondaryValue || 0) : 0;
const tertiaryValue = isFinite(d.tertiaryValue) ? Math.abs(d.tertiaryValue || 0) : 0;
const angle = this.angleScale(i) - Math.PI / 2;
const segmentWidth = (2 * Math.PI) / Math.max(1, data.length) * this.options.segmentWidthRatio;
const startAngle = angle - segmentWidth / 2;
const endAngle = angle + segmentWidth / 2;
// Validate angles
if (!isFinite(startAngle) || !isFinite(endAngle) || startAngle >= endAngle) {
console.warn(`Invalid angles for arc ${i}:`, { startAngle, endAngle });
return; // Skip this arc
}
// Calculate radii with validation
const totalRadiusRaw = this.radiusScale(totalValue);
const primaryRadiusRaw = this.radiusScale(primaryValue);
if (!isFinite(totalRadiusRaw) || !isFinite(primaryRadiusRaw)) {
console.warn(`Invalid radius values for arc ${i}:`, { totalRadiusRaw, primaryRadiusRaw });
return; // Skip this arc
}
const totalRadius = Math.max(0, totalRadiusRaw - this.options.innerRadius);
const primaryRadius = Math.max(0, primaryRadiusRaw - this.options.innerRadius);
const secondaryRadius = Math.max(0, totalRadius - primaryRadius);
// Primary value arc (inner) - only add if valid
if (primaryRadius > 0) {
const innerRadius = Math.max(0, this.options.innerRadius);
const outerRadius = Math.max(innerRadius + 1, innerRadius + primaryRadius);
arcs.push({
type: 'primary',
year: d.year,
value: primaryValue,
innerRadius,
outerRadius,
startAngle,
endAngle,
color: this.primaryColorScale(primaryValue),
data: d,
index: i
});
}
// Secondary value arc (outer) - only add if valid
if (secondaryRadius > 0) {
const innerRadius = Math.max(0, this.options.innerRadius + primaryRadius);
const outerRadius = Math.max(innerRadius + 1, this.options.innerRadius + totalRadius);
arcs.push({
type: 'secondary',
year: d.year,
value: secondaryValue,
innerRadius,
outerRadius,
startAngle,
endAngle,
color: this.secondaryColorScale(secondaryValue),
data: d,
index: i
});
}
// Tertiary value arc (if applicable)
if (tertiaryValue > 0) {
const tertiaryRadiusRaw = this.radiusScale(tertiaryValue) * 0.3; // Smaller scale
if (isFinite(tertiaryRadiusRaw)) {
const tertiaryRadius = Math.max(0, tertiaryRadiusRaw);
if (tertiaryRadius > 0) {
const innerRadius = Math.max(0, this.options.innerRadius + totalRadius + 5);
const outerRadius = Math.max(innerRadius + 1, innerRadius + tertiaryRadius);
arcs.push({
type: 'tertiary',
year: d.year,
value: tertiaryValue,
innerRadius,
outerRadius,
startAngle,
endAngle,
color: this.tertiaryColorScale(tertiaryValue),
data: d,
index: i
});
}
}
}
});
// Final validation of all arcs
const validArcs = arcs.filter(arc => {
const isValid = isFinite(arc.innerRadius) &&
isFinite(arc.outerRadius) &&
isFinite(arc.startAngle) &&
isFinite(arc.endAngle) &&
arc.innerRadius >= 0 &&
arc.outerRadius > arc.innerRadius &&
arc.startAngle < arc.endAngle;
if (!isValid) {
console.warn('Filtered out invalid arc:', arc);
}
return isValid;
});
return validArcs;
}
/**
* Create arc generator with comprehensive validation
*/
createArcGenerator() {
return d3.arc()
.innerRadius(d => {
const value = d.innerRadius || 0;
return isFinite(value) && value >= 0 ? Math.max(0, value) : 0;
})
.outerRadius(d => {
const value = d.outerRadius || 0;
const innerValue = d.innerRadius || 0;
const validValue = isFinite(value) && value >= 0 ? Math.max(0, value) : 0;
const validInner = isFinite(innerValue) && innerValue >= 0 ? Math.max(0, innerValue) : 0;
return Math.max(validInner + 1, validValue); // Ensure outer > inner
})
.startAngle(d => {
const value = d.startAngle;
return isFinite(value) ? value : 0;
})
.endAngle(d => {
const value = d.endAngle;
return isFinite(value) ? value : 0;
})
.cornerRadius(d => {
const cornerRadius = this.options.cornerRadius || 0;
return isFinite(cornerRadius) && cornerRadius >= 0 ? cornerRadius : 0;
});
}
/**
* Draw animated arcs with safe transitions
*/
drawArcs(arcs, mainGroup) {
const arcGenerator = this.createArcGenerator();
const arcPaths = mainGroup.selectAll(".arc")
.data(arcs)
.enter()
.append("path")
.attr("class", "arc")
.attr("fill", d => d.color)
.attr("stroke", "#1e293b")
.attr("stroke-width", this.options.strokeWidth)
.style("cursor", "pointer")
.style("opacity", d => this.hoveredYear && this.hoveredYear !== d.year ? 0.3 : 1)
.attr("d", d => {
// Start with zero radius for animation - ensure all values are valid
const startArc = {
...d,
outerRadius: Math.max(0, d.innerRadius || 0),
innerRadius: Math.max(0, d.innerRadius || 0),
startAngle: isFinite(d.startAngle) ? d.startAngle : 0,
endAngle: isFinite(d.endAngle) ? d.endAngle : 0
};
try {
return arcGenerator(startArc) || 'M0,0';
} catch (e) {
console.warn('Arc generation failed:', e);
return 'M0,0';
}
});
// Animate arcs with safe interpolation
arcPaths
.transition()
.delay(d => d.index * this.options.animationDelay + 300)
.duration(this.options.animationDuration)
.attrTween('d', function(d) {
const node = this;
const startRadius = Math.max(0, d.innerRadius || 0);
const endRadius = Math.max(startRadius + 1, d.outerRadius || 0);
// Create interpolator for radius
const radiusInterpolator = d3.interpolateNumber(startRadius, endRadius);
return function(t) {
const currentRadius = radiusInterpolator(t);
const safeArc = {
...d,
innerRadius: Math.max(0, d.innerRadius || 0),
outerRadius: Math.max(d.innerRadius + 1, currentRadius),
startAngle: isFinite(d.startAngle) ? d.startAngle : 0,
endAngle: isFinite(d.endAngle) ? d.endAngle : 0
};
try {
const path = arcGenerator(safeArc);
return path || 'M0,0';
} catch (e) {
console.warn('Arc animation failed:', e);
return 'M0,0';
}
};
});
// Add hover interactions
arcPaths
.on("mouseover", (event, d) => {
this.hoveredYear = d.year;
this.updateArcOpacity(arcPaths);
this.showArcTooltip(event, d);
})
.on("mousemove", (event) => {
this.moveTooltip(event);
})
.on("mouseout", () => {
this.hoveredYear = null;
this.updateArcOpacity(arcPaths);
this.hideTooltip();
});
return arcPaths;
}
/**
* Update arc opacity based on hover state
*/
updateArcOpacity(arcPaths) {
arcPaths
.transition()
.duration(200)
.style("opacity", d => this.hoveredYear && this.hoveredYear !== d.year ? 0.3 : 1);
}
/**
* Show tooltip for arc
*/
showArcTooltip(event, d) {
const tooltipContent = `
<div style="font-weight: bold; margin-bottom: 4px;">Year ${d.year}</div>
<div style="color: ${this.colorScale(0)};">Primary: ${this.formatValue(d.data.primaryValue)}</div>
<div style="color: ${this.colorScale(1)};">Secondary: ${this.formatValue(d.data.secondaryValue)}</div>
${d.data.tertiaryValue ? `<div style="color: ${this.colorScale(2)};">Tertiary: ${this.formatValue(d.data.tertiaryValue)}</div>` : ''}
<div style="margin-top: 4px; font-weight: bold;">Total: ${this.formatValue(d.data.totalValue)}</div>
`;
this.showTooltip(tooltipContent, event);
}
/**
* Move tooltip with mouse
*/
moveTooltip(event) {
if (this.tooltip) {
this.tooltip
.style("left", (event.pageX + 10) + "px")
.style("top", (event.pageY - 10) + "px");
}
}
/**
* 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)}`;
}
/**
* Create legend
*/
createLegend() {
const legend = this.svg.append("g")
.attr("class", "legend")
.attr("transform", "translate(20, 20)");
// Legend background
legend.append("rect")
.attr("width", 140)
.attr("height", 90)
.attr("fill", "rgba(30, 41, 59, 0.9)")
.attr("stroke", "#475569")
.attr("stroke-width", 1)
.attr("rx", 6)
.style("backdrop-filter", "blur(4px)");
const legendItems = [
{ label: "Primary Value", color: this.colorScale(0) },
{ label: "Secondary Value", color: this.colorScale(1) },
{ label: "Tertiary Value", color: this.colorScale(2) }
];
legendItems.forEach((item, i) => {
const legendItem = legend.append("g")
.attr("transform", `translate(10, ${15 + i * 22})`);
legendItem.append("circle")
.attr("cx", 6)
.attr("cy", 0)
.attr("r", 6)
.attr("fill", item.color);
legendItem.append("text")
.attr("x", 18)
.attr("y", 4)
.attr("font-size", "11px")
.attr("fill", "#e2e8f0")
.text(item.label);
});
}
/**
* 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 scales
this.createScales(processedData);
// Create grid lines
const mainGroup = this.createGridLines();
const { centerX, centerY } = this.calculateDimensions();
const chartGroup = mainGroup || this.svg.append("g")
.attr("transform", `translate(${centerX}, ${centerY})`);
// Create year labels
this.createYearLabels(processedData, chartGroup);
// Create center label
this.createCenterLabel(chartGroup);
// Process arcs
const arcs = this.processArcs(processedData);
// Draw arcs
this.drawArcs(arcs, chartGroup);
// Create legend
this.createLegend();
return this;
}
/**
* Update chart with new data
*/
update(newData) {
this.setData(newData);
return this.render();
}
/**
* Update hover state
*/
setHoveredYear(year) {
this.hoveredYear = year;
const arcPaths = this.svg.selectAll(".arc");
this.updateArcOpacity(arcPaths);
}
}
export default RadialTimelineChart;