d3-charts-viz-library
Version:
A comprehensive D3-based chart library with customizable charts and graphs
1,985 lines (1,672 loc) • 236 kB
JavaScript
import * as d3 from 'd3';
/**
* Base Chart class that provides common functionality for all chart types
*/
class BaseChart {
constructor(container, options = {}) {
this.container = container;
this.options = {
width: 800,
height: 400,
margin: { top: 20, right: 30, bottom: 40, left: 40 },
backgroundColor: '#ffffff',
...options
};
this.data = null;
this.svg = null;
this.chartGroup = null;
this.init();
}
/**
* Initialize the SVG container and chart group
*/
init() {
// Clear existing content
d3.select(this.container).selectAll('*').remove();
// Create SVG
this.svg = d3.select(this.container)
.append('svg')
.attr('width', this.options.width)
.attr('height', this.options.height)
.style('background-color', this.options.backgroundColor);
// Create chart group with margins
this.chartGroup = this.svg.append('g')
.attr('transform', `translate(${this.options.margin.left}, ${this.options.margin.top})`);
// Calculate inner dimensions
this.innerWidth = this.options.width - this.options.margin.left - this.options.margin.right;
this.innerHeight = this.options.height - this.options.margin.top - this.options.margin.bottom;
}
/**
* Set data for the chart
*/
setData(data) {
this.data = data;
return this;
}
/**
* Update chart options
*/
updateOptions(newOptions) {
this.options = { ...this.options, ...newOptions };
this.init();
return this;
}
/**
* Get chart dimensions
*/
getDimensions() {
return {
width: this.innerWidth,
height: this.innerHeight,
margin: this.options.margin
};
}
/**
* Add title to the chart
*/
addTitle(title, options = {}) {
const titleOptions = {
fontSize: '16px',
fontWeight: 'bold',
textAnchor: 'middle',
fill: '#333',
...options
};
this.svg.append('text')
.attr('x', this.options.width / 2)
.attr('y', titleOptions.fontSize === '16px' ? 16 : parseInt(titleOptions.fontSize))
.style('font-size', titleOptions.fontSize)
.style('font-weight', titleOptions.fontWeight)
.style('text-anchor', titleOptions.textAnchor)
.style('fill', titleOptions.fill)
.text(title);
return this;
}
/**
* Add legend to the chart
*/
addLegend(items, options = {}) {
const legendOptions = {
x: this.options.width - 100,
y: 30,
itemHeight: 20,
fontSize: '12px',
...options
};
const legend = this.svg.append('g')
.attr('class', 'legend')
.attr('transform', `translate(${legendOptions.x}, ${legendOptions.y})`);
const legendItems = legend.selectAll('.legend-item')
.data(items)
.enter()
.append('g')
.attr('class', 'legend-item')
.attr('transform', (d, i) => `translate(0, ${i * legendOptions.itemHeight})`);
legendItems.append('rect')
.attr('width', 12)
.attr('height', 12)
.attr('fill', d => d.color);
legendItems.append('text')
.attr('x', 16)
.attr('y', 9)
.style('font-size', legendOptions.fontSize)
.style('alignment-baseline', 'middle')
.text(d => d.label);
return this;
}
/**
* Add tooltip functionality
*/
addTooltip() {
this.tooltip = d3.select('body')
.append('div')
.attr('class', 'd3-tooltip')
.style('position', 'absolute')
.style('visibility', 'hidden')
.style('background-color', 'rgba(0, 0, 0, 0.8)')
.style('color', 'white')
.style('padding', '8px')
.style('border-radius', '4px')
.style('font-size', '12px')
.style('pointer-events', 'none')
.style('z-index', '1000');
return this;
}
/**
* Show tooltip
*/
showTooltip(content, event) {
if (this.tooltip) {
this.tooltip
.style('visibility', 'visible')
.html(content)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 10) + 'px');
}
}
/**
* Hide tooltip
*/
hideTooltip() {
if (this.tooltip) {
this.tooltip.style('visibility', 'hidden');
}
}
/**
* Render method to be implemented by subclasses
*/
render() {
throw new Error('render() method must be implemented by subclasses');
}
/**
* Destroy the chart and clean up
*/
destroy() {
if (this.tooltip) {
this.tooltip.remove();
}
d3.select(this.container).selectAll('*').remove();
}
}
/**
* Bar Chart implementation
*/
class BarChart extends BaseChart {
constructor(container, options = {}) {
const defaultOptions = {
barPadding: 0.1,
barColor: '#3498db',
hoverColor: '#2980b9',
showValues: false,
orientation: 'vertical', // 'vertical' or 'horizontal'
...options
};
super(container, defaultOptions);
this.addTooltip();
}
/**
* Render the bar chart
*/
render() {
if (!this.data || this.data.length === 0) {
console.warn('No data provided for BarChart');
return this;
}
// Clear previous chart
this.chartGroup.selectAll('*').remove();
if (this.options.orientation === 'vertical') {
this.renderVerticalBars();
} else {
this.renderHorizontalBars();
}
return this;
}
/**
* Render vertical bars
*/
renderVerticalBars() {
// Create scales
const xScale = d3.scaleBand()
.domain(this.data.map(d => d.label))
.range([0, this.innerWidth])
.padding(this.options.barPadding);
const yScale = d3.scaleLinear()
.domain([0, d3.max(this.data, d => d.value)])
.range([this.innerHeight, 0]);
// Create axes
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
// Add X axis
this.chartGroup.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0, ${this.innerHeight})`)
.call(xAxis);
// Add Y axis
this.chartGroup.append('g')
.attr('class', 'y-axis')
.call(yAxis);
// Create bars
const bars = this.chartGroup.selectAll('.bar')
.data(this.data)
.enter()
.append('rect')
.attr('class', 'bar')
.attr('x', d => xScale(d.label))
.attr('width', xScale.bandwidth())
.attr('y', this.innerHeight)
.attr('height', 0)
.attr('fill', this.options.barColor)
.style('cursor', 'pointer');
// Animate bars
bars.transition()
.duration(800)
.attr('y', d => yScale(d.value))
.attr('height', d => this.innerHeight - yScale(d.value));
// Add interactivity
this.addBarInteractivity(bars);
// Add value labels if requested
if (this.options.showValues) {
this.addValueLabels(xScale, yScale);
}
}
/**
* Render horizontal bars
*/
renderHorizontalBars() {
// Create scales
const yScale = d3.scaleBand()
.domain(this.data.map(d => d.label))
.range([0, this.innerHeight])
.padding(this.options.barPadding);
const xScale = d3.scaleLinear()
.domain([0, d3.max(this.data, d => d.value)])
.range([0, this.innerWidth]);
// Create axes
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
// Add X axis
this.chartGroup.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0, ${this.innerHeight})`)
.call(xAxis);
// Add Y axis
this.chartGroup.append('g')
.attr('class', 'y-axis')
.call(yAxis);
// Create bars
const bars = this.chartGroup.selectAll('.bar')
.data(this.data)
.enter()
.append('rect')
.attr('class', 'bar')
.attr('y', d => yScale(d.label))
.attr('height', yScale.bandwidth())
.attr('x', 0)
.attr('width', 0)
.attr('fill', this.options.barColor)
.style('cursor', 'pointer');
// Animate bars
bars.transition()
.duration(800)
.attr('width', d => xScale(d.value));
// Add interactivity
this.addBarInteractivity(bars);
}
/**
* Add interactivity to bars
*/
addBarInteractivity(bars) {
const self = this;
bars
.on('mouseover', function(event, d) {
d3.select(this).attr('fill', self.options.hoverColor);
self.showTooltip(`${d.label}: ${d.value}`, event);
})
.on('mouseout', function() {
d3.select(this).attr('fill', self.options.barColor);
self.hideTooltip();
})
.on('click', function(event, d) {
if (self.options.onClick) {
self.options.onClick(d, event);
}
});
}
/**
* Add value labels on bars
*/
addValueLabels(xScale, yScale) {
if (this.options.orientation === 'vertical') {
this.chartGroup.selectAll('.value-label')
.data(this.data)
.enter()
.append('text')
.attr('class', 'value-label')
.attr('x', d => xScale(d.label) + xScale.bandwidth() / 2)
.attr('y', d => yScale(d.value) - 5)
.attr('text-anchor', 'middle')
.style('font-size', '12px')
.style('fill', '#333')
.text(d => d.value);
} else {
this.chartGroup.selectAll('.value-label')
.data(this.data)
.enter()
.append('text')
.attr('class', 'value-label')
.attr('x', d => xScale(d.value) + 5)
.attr('y', d => yScale(d.label) + yScale.bandwidth() / 2)
.attr('text-anchor', 'start')
.attr('alignment-baseline', 'middle')
.style('font-size', '12px')
.style('fill', '#333')
.text(d => d.value);
}
}
/**
* Update chart with new data
*/
updateData(newData) {
this.setData(newData);
this.render();
return this;
}
}
/**
* Line Chart implementation
*/
class LineChart extends BaseChart {
constructor(container, options = {}) {
const defaultOptions = {
lineColor: '#3498db',
lineWidth: 2,
pointRadius: 4,
pointColor: '#3498db',
pointHoverRadius: 6,
showPoints: true,
showArea: false,
areaColor: 'rgba(52, 152, 219, 0.3)',
curve: d3.curveLinear,
...options
};
super(container, defaultOptions);
this.addTooltip();
}
/**
* Render the line chart
*/
render() {
if (!this.data || this.data.length === 0) {
console.warn('No data provided for LineChart');
return this;
}
// Clear previous chart
this.chartGroup.selectAll('*').remove();
// Create scales
const xScale = d3.scaleLinear()
.domain(d3.extent(this.data, d => d.x))
.range([0, this.innerWidth]);
const yScale = d3.scaleLinear()
.domain(d3.extent(this.data, d => d.y))
.range([this.innerHeight, 0]);
// Create line generator
const line = d3.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y))
.curve(this.options.curve);
// Create area generator (if needed)
let area;
if (this.options.showArea) {
area = d3.area()
.x(d => xScale(d.x))
.y0(this.innerHeight)
.y1(d => yScale(d.y))
.curve(this.options.curve);
}
// Create axes
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
// Add X axis
this.chartGroup.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0, ${this.innerHeight})`)
.call(xAxis);
// Add Y axis
this.chartGroup.append('g')
.attr('class', 'y-axis')
.call(yAxis);
// Add area if requested
if (this.options.showArea && area) {
this.chartGroup.append('path')
.datum(this.data)
.attr('class', 'area')
.attr('fill', this.options.areaColor)
.attr('d', area);
}
// Add line
const path = this.chartGroup.append('path')
.datum(this.data)
.attr('class', 'line')
.attr('fill', 'none')
.attr('stroke', this.options.lineColor)
.attr('stroke-width', this.options.lineWidth)
.attr('d', line);
// Animate line drawing
const totalLength = path.node().getTotalLength();
path
.attr('stroke-dasharray', totalLength + ' ' + totalLength)
.attr('stroke-dashoffset', totalLength)
.transition()
.duration(1000)
.attr('stroke-dashoffset', 0);
// Add points if requested
if (this.options.showPoints) {
this.addPoints(xScale, yScale);
}
return this;
}
/**
* Add interactive points to the line
*/
addPoints(xScale, yScale) {
const self = this;
const points = this.chartGroup.selectAll('.point')
.data(this.data)
.enter()
.append('circle')
.attr('class', 'point')
.attr('cx', d => xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('r', 0)
.attr('fill', this.options.pointColor)
.style('cursor', 'pointer');
// Animate points
points.transition()
.delay((d, i) => i * 50)
.duration(300)
.attr('r', this.options.pointRadius);
// Add interactivity
points
.on('mouseover', function(event, d) {
d3.select(this)
.transition()
.duration(150)
.attr('r', self.options.pointHoverRadius);
self.showTooltip(`(${d.x}, ${d.y})`, event);
})
.on('mouseout', function() {
d3.select(this)
.transition()
.duration(150)
.attr('r', self.options.pointRadius);
self.hideTooltip();
})
.on('click', function(event, d) {
if (self.options.onClick) {
self.options.onClick(d, event);
}
});
}
/**
* Add multiple lines for multi-series data
*/
renderMultiSeries(seriesData) {
if (!seriesData || seriesData.length === 0) {
console.warn('No series data provided for LineChart');
return this;
}
// Clear previous chart
this.chartGroup.selectAll('*').remove();
// Get all data points for scaling
const allData = seriesData.flatMap(series => series.data);
// Create scales
const xScale = d3.scaleLinear()
.domain(d3.extent(allData, d => d.x))
.range([0, this.innerWidth]);
const yScale = d3.scaleLinear()
.domain(d3.extent(allData, d => d.y))
.range([this.innerHeight, 0]);
// Color scale for different series
const colorScale = d3.scaleOrdinal(d3.schemeCategory10);
// Create line generator
const line = d3.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y))
.curve(this.options.curve);
// Create axes
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
// Add axes
this.chartGroup.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0, ${this.innerHeight})`)
.call(xAxis);
this.chartGroup.append('g')
.attr('class', 'y-axis')
.call(yAxis);
// Add lines for each series
seriesData.forEach((series, index) => {
const color = series.color || colorScale(index);
// Add line
const path = this.chartGroup.append('path')
.datum(series.data)
.attr('class', `line series-${index}`)
.attr('fill', 'none')
.attr('stroke', color)
.attr('stroke-width', this.options.lineWidth)
.attr('d', line);
// Animate line drawing
const totalLength = path.node().getTotalLength();
path
.attr('stroke-dasharray', totalLength + ' ' + totalLength)
.attr('stroke-dashoffset', totalLength)
.transition()
.delay(index * 200)
.duration(1000)
.attr('stroke-dashoffset', 0);
// Add points if requested
if (this.options.showPoints) {
this.addSeriesPoints(series.data, xScale, yScale, color, series.name);
}
});
// Add legend
const legendItems = seriesData.map((series, index) => ({
label: series.name || `Series ${index + 1}`,
color: series.color || colorScale(index)
}));
this.addLegend(legendItems);
return this;
}
/**
* Add points for a specific series
*/
addSeriesPoints(data, xScale, yScale, color, seriesName) {
const self = this;
const points = this.chartGroup.selectAll(`.point-${seriesName}`)
.data(data)
.enter()
.append('circle')
.attr('class', `point point-${seriesName}`)
.attr('cx', d => xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('r', 0)
.attr('fill', color)
.style('cursor', 'pointer');
// Animate points
points.transition()
.delay((d, i) => i * 30)
.duration(300)
.attr('r', this.options.pointRadius);
// Add interactivity
points
.on('mouseover', function(event, d) {
d3.select(this)
.transition()
.duration(150)
.attr('r', self.options.pointHoverRadius);
self.showTooltip(`${seriesName}: (${d.x}, ${d.y})`, event);
})
.on('mouseout', function() {
d3.select(this)
.transition()
.duration(150)
.attr('r', self.options.pointRadius);
self.hideTooltip();
});
}
/**
* Update chart with new data
*/
updateData(newData) {
this.setData(newData);
this.render();
return this;
}
}
/**
* Pie Chart implementation
*/
class PieChart extends BaseChart {
constructor(container, options = {}) {
const defaultOptions = {
innerRadius: 0,
outerRadius: null, // Will be calculated based on chart size
padAngle: 0.02,
cornerRadius: 0,
colors: d3.schemeCategory10,
showLabels: true,
labelOffset: 20,
showPercentages: true,
...options
};
super(container, defaultOptions);
this.addTooltip();
}
/**
* Render the pie chart
*/
render() {
if (!this.data || this.data.length === 0) {
console.warn('No data provided for PieChart');
return this;
}
// Clear previous chart
this.chartGroup.selectAll('*').remove();
// Calculate radius if not provided
const radius = this.options.outerRadius ||
Math.min(this.innerWidth, this.innerHeight) / 2 - 10;
// Center the chart
const centerX = this.innerWidth / 2;
const centerY = this.innerHeight / 2;
const chartCenter = this.chartGroup.append('g')
.attr('transform', `translate(${centerX}, ${centerY})`);
// Create pie layout
const pie = d3.pie()
.value(d => d.value)
.sort(null)
.padAngle(this.options.padAngle);
// Create arc generator
const arc = d3.arc()
.innerRadius(this.options.innerRadius)
.outerRadius(radius)
.cornerRadius(this.options.cornerRadius);
// Create arc generator for labels
const labelArc = d3.arc()
.innerRadius(radius + this.options.labelOffset)
.outerRadius(radius + this.options.labelOffset);
// Color scale
const colorScale = d3.scaleOrdinal(this.options.colors);
// Create pie slices
const slices = chartCenter.selectAll('.slice')
.data(pie(this.data))
.enter()
.append('g')
.attr('class', 'slice');
// Add paths
const paths = slices.append('path')
.attr('fill', (d, i) => colorScale(i))
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.style('cursor', 'pointer')
.each(function(d) { this._current = { startAngle: 0, endAngle: 0 }; });
// Animate slices
paths.transition()
.duration(1000)
.attrTween('d', function(d) {
const interpolate = d3.interpolate(this._current, d);
this._current = interpolate(0);
return function(t) {
return arc(interpolate(t));
};
});
// Add interactivity
this.addSliceInteractivity(paths, arc);
// Add labels if requested
if (this.options.showLabels) {
this.addLabels(slices, pie(this.data), labelArc);
}
return this;
}
/**
* Add interactivity to pie slices
*/
addSliceInteractivity(paths, arc) {
const self = this;
const hoverArc = d3.arc()
.innerRadius(this.options.innerRadius)
.outerRadius(arc.outerRadius()() + 10)
.cornerRadius(this.options.cornerRadius);
paths
.on('mouseover', function(event, d) {
// Expand slice
d3.select(this)
.transition()
.duration(200)
.attr('d', hoverArc);
// Show tooltip
const percentage = ((d.endAngle - d.startAngle) / (2 * Math.PI) * 100).toFixed(1);
const tooltipContent = `${d.data.label}: ${d.data.value} (${percentage}%)`;
self.showTooltip(tooltipContent, event);
})
.on('mouseout', function(event, d) {
// Return to normal size
d3.select(this)
.transition()
.duration(200)
.attr('d', arc);
self.hideTooltip();
})
.on('click', function(event, d) {
if (self.options.onClick) {
self.options.onClick(d.data, event);
}
});
}
/**
* Add labels to pie slices
*/
addLabels(slices, pieData, labelArc) {
const labels = slices.append('text')
.attr('transform', d => `translate(${labelArc.centroid(d)})`)
.attr('text-anchor', 'middle')
.attr('alignment-baseline', 'middle')
.style('font-size', '12px')
.style('fill', '#333')
.style('opacity', 0);
// Add label text
labels.text(d => {
if (this.options.showPercentages) {
const percentage = ((d.endAngle - d.startAngle) / (2 * Math.PI) * 100).toFixed(1);
return `${d.data.label} (${percentage}%)`;
}
return d.data.label;
});
// Animate labels
labels.transition()
.delay(500)
.duration(500)
.style('opacity', 1);
// Add lines connecting labels to slices
this.addLabelLines(slices, pieData, labelArc);
}
/**
* Add lines connecting labels to slices
*/
addLabelLines(slices, pieData, labelArc) {
const arc = d3.arc()
.innerRadius(this.options.innerRadius)
.outerRadius(this.options.outerRadius ||
Math.min(this.innerWidth, this.innerHeight) / 2 - 10);
const lines = slices.append('polyline')
.attr('fill', 'none')
.attr('stroke', '#999')
.attr('stroke-width', 1)
.style('opacity', 0);
lines.attr('points', d => {
const pos = labelArc.centroid(d);
const midPos = arc.centroid(d);
return [midPos, pos];
});
// Animate lines
lines.transition()
.delay(500)
.duration(500)
.style('opacity', 0.7);
}
/**
* Create a donut chart (pie chart with inner radius)
*/
createDonut(innerRadiusRatio = 0.5) {
const radius = this.options.outerRadius ||
Math.min(this.innerWidth, this.innerHeight) / 2 - 10;
this.options.innerRadius = radius * innerRadiusRatio;
return this.render();
}
/**
* Update chart with new data
*/
updateData(newData) {
this.setData(newData);
this.render();
return this;
}
/**
* Animate slice explosion
*/
explodeSlice(index, distance = 20) {
const slice = this.chartGroup.select(`.slice:nth-child(${index + 1})`);
const pieData = d3.pie().value(d => d.value)(this.data);
const centroid = d3.arc()
.innerRadius(this.options.innerRadius)
.outerRadius(this.options.outerRadius ||
Math.min(this.innerWidth, this.innerHeight) / 2 - 10)
.centroid(pieData[index]);
const x = centroid[0] * distance / 100;
const y = centroid[1] * distance / 100;
slice.transition()
.duration(300)
.attr('transform', `translate(${x}, ${y})`);
return this;
}
/**
* Reset all slice positions
*/
resetSlices() {
this.chartGroup.selectAll('.slice')
.transition()
.duration(300)
.attr('transform', 'translate(0, 0)');
return this;
}
}
/**
* Scatter Plot implementation
*/
class ScatterPlot extends BaseChart {
constructor(container, options = {}) {
const defaultOptions = {
pointRadius: 4,
pointColor: '#3498db',
pointOpacity: 0.7,
hoverRadius: 6,
hoverOpacity: 1,
showTrendLine: false,
trendLineColor: '#e74c3c',
trendLineWidth: 2,
...options
};
super(container, defaultOptions);
this.addTooltip();
}
/**
* Render the scatter plot
*/
render() {
if (!this.data || this.data.length === 0) {
console.warn('No data provided for ScatterPlot');
return this;
}
// Clear previous chart
this.chartGroup.selectAll('*').remove();
// Create scales
const xScale = d3.scaleLinear()
.domain(d3.extent(this.data, d => d.x))
.range([0, this.innerWidth])
.nice();
const yScale = d3.scaleLinear()
.domain(d3.extent(this.data, d => d.y))
.range([this.innerHeight, 0])
.nice();
// Create axes
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
// Add X axis
this.chartGroup.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0, ${this.innerHeight})`)
.call(xAxis);
// Add Y axis
this.chartGroup.append('g')
.attr('class', 'y-axis')
.call(yAxis);
// Add axis labels
this.addAxisLabels();
// Add trend line if requested
if (this.options.showTrendLine) {
this.addTrendLine(xScale, yScale);
}
// Create points
this.addPoints(xScale, yScale);
return this;
}
/**
* Add scatter plot points
*/
addPoints(xScale, yScale) {
const self = this;
const points = this.chartGroup.selectAll('.point')
.data(this.data)
.enter()
.append('circle')
.attr('class', 'point')
.attr('cx', d => xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('r', 0)
.attr('fill', d => d.color || this.options.pointColor)
.attr('opacity', this.options.pointOpacity)
.style('cursor', 'pointer');
// Animate points
points.transition()
.delay((d, i) => i * 20)
.duration(500)
.attr('r', d => d.radius || this.options.pointRadius);
// Add interactivity
points
.on('mouseover', function(event, d) {
d3.select(this)
.transition()
.duration(150)
.attr('r', self.options.hoverRadius)
.attr('opacity', self.options.hoverOpacity);
const tooltipContent = self.formatTooltip(d);
self.showTooltip(tooltipContent, event);
})
.on('mouseout', function(event, d) {
d3.select(this)
.transition()
.duration(150)
.attr('r', d.radius || self.options.pointRadius)
.attr('opacity', self.options.pointOpacity);
self.hideTooltip();
})
.on('click', function(event, d) {
if (self.options.onClick) {
self.options.onClick(d, event);
}
});
}
/**
* Format tooltip content
*/
formatTooltip(d) {
let content = `X: ${d.x}<br>Y: ${d.y}`;
if (d.label) {
content = `${d.label}<br>${content}`;
}
if (d.category) {
content += `<br>Category: ${d.category}`;
}
return content;
}
/**
* Add trend line using linear regression
*/
addTrendLine(xScale, yScale) {
const regression = this.calculateLinearRegression();
if (!regression) return;
const xDomain = xScale.domain();
const trendData = [
{ x: xDomain[0], y: regression.slope * xDomain[0] + regression.intercept },
{ x: xDomain[1], y: regression.slope * xDomain[1] + regression.intercept }
];
const line = d3.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y));
this.chartGroup.append('path')
.datum(trendData)
.attr('class', 'trend-line')
.attr('fill', 'none')
.attr('stroke', this.options.trendLineColor)
.attr('stroke-width', this.options.trendLineWidth)
.attr('stroke-dasharray', '5,5')
.attr('d', line);
// Add R² value
this.chartGroup.append('text')
.attr('x', this.innerWidth - 10)
.attr('y', 20)
.attr('text-anchor', 'end')
.style('font-size', '12px')
.style('fill', this.options.trendLineColor)
.text(`R² = ${regression.rSquared.toFixed(3)}`);
}
/**
* Calculate linear regression
*/
calculateLinearRegression() {
if (this.data.length < 2) return null;
const n = this.data.length;
const sumX = d3.sum(this.data, d => d.x);
const sumY = d3.sum(this.data, d => d.y);
const sumXY = d3.sum(this.data, d => d.x * d.y);
const sumXX = d3.sum(this.data, d => d.x * d.x);
d3.sum(this.data, d => d.y * d.y);
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
// Calculate R²
const yMean = sumY / n;
const ssRes = d3.sum(this.data, d => Math.pow(d.y - (slope * d.x + intercept), 2));
const ssTot = d3.sum(this.data, d => Math.pow(d.y - yMean, 2));
const rSquared = 1 - (ssRes / ssTot);
return { slope, intercept, rSquared };
}
/**
* Add axis labels
*/
addAxisLabels() {
// X axis label
if (this.options.xLabel) {
this.chartGroup.append('text')
.attr('x', this.innerWidth / 2)
.attr('y', this.innerHeight + 35)
.attr('text-anchor', 'middle')
.style('font-size', '14px')
.style('fill', '#333')
.text(this.options.xLabel);
}
// Y axis label
if (this.options.yLabel) {
this.chartGroup.append('text')
.attr('transform', 'rotate(-90)')
.attr('x', -this.innerHeight / 2)
.attr('y', -35)
.attr('text-anchor', 'middle')
.style('font-size', '14px')
.style('fill', '#333')
.text(this.options.yLabel);
}
}
/**
* Render scatter plot with categories (different colors)
*/
renderWithCategories(data, categoryField = 'category') {
this.setData(data);
// Get unique categories
const categories = [...new Set(data.map(d => d[categoryField]))];
const colorScale = d3.scaleOrdinal(d3.schemeCategory10)
.domain(categories);
// Assign colors based on category
this.data.forEach(d => {
d.color = colorScale(d[categoryField]);
});
this.render();
// Add legend
const legendItems = categories.map(category => ({
label: category,
color: colorScale(category)
}));
this.addLegend(legendItems);
return this;
}
/**
* Render bubble chart (scatter plot with varying point sizes)
*/
renderBubbleChart(sizeField = 'size') {
if (!this.data || this.data.length === 0) {
console.warn('No data provided for BubbleChart');
return this;
}
// Create size scale
const sizeExtent = d3.extent(this.data, d => d[sizeField]);
const sizeScale = d3.scaleSqrt()
.domain(sizeExtent)
.range([3, 20]);
// Assign radius based on size field
this.data.forEach(d => {
d.radius = sizeScale(d[sizeField]);
});
this.render();
return this;
}
/**
* Update chart with new data
*/
updateData(newData) {
this.setData(newData);
this.render();
return this;
}
/**
* Highlight points based on condition
*/
highlightPoints(condition, highlightColor = '#e74c3c') {
this.chartGroup.selectAll('.point')
.attr('fill', d => condition(d) ? highlightColor : (d.color || this.options.pointColor));
return this;
}
/**
* Reset point colors
*/
resetHighlight() {
this.chartGroup.selectAll('.point')
.attr('fill', d => d.color || this.options.pointColor);
return this;
}
}
/**
* Area Chart implementation
*/
class AreaChart extends BaseChart {
constructor(container, options = {}) {
const defaultOptions = {
areaColor: 'rgba(52, 152, 219, 0.6)',
lineColor: '#3498db',
lineWidth: 2,
curve: d3.curveLinear,
showLine: true,
showPoints: false,
pointRadius: 3,
...options
};
super(container, defaultOptions);
this.addTooltip();
}
/**
* Render the area chart
*/
render() {
if (!this.data || this.data.length === 0) {
console.warn('No data provided for AreaChart');
return this;
}
// Clear previous chart
this.chartGroup.selectAll('*').remove();
// Create scales
const xScale = d3.scaleLinear()
.domain(d3.extent(this.data, d => d.x))
.range([0, this.innerWidth]);
const yScale = d3.scaleLinear()
.domain([0, d3.max(this.data, d => d.y)])
.range([this.innerHeight, 0]);
// Create area generator
const area = d3.area()
.x(d => xScale(d.x))
.y0(this.innerHeight)
.y1(d => yScale(d.y))
.curve(this.options.curve);
// Create line generator
const line = d3.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y))
.curve(this.options.curve);
// Create axes
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
// Add X axis
this.chartGroup.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0, ${this.innerHeight})`)
.call(xAxis);
// Add Y axis
this.chartGroup.append('g')
.attr('class', 'y-axis')
.call(yAxis);
// Add area
const areaPath = this.chartGroup.append('path')
.datum(this.data)
.attr('class', 'area')
.attr('fill', this.options.areaColor)
.attr('d', area);
// Animate area
this.animateArea(areaPath);
// Add line if requested
if (this.options.showLine) {
const linePath = this.chartGroup.append('path')
.datum(this.data)
.attr('class', 'line')
.attr('fill', 'none')
.attr('stroke', this.options.lineColor)
.attr('stroke-width', this.options.lineWidth)
.attr('d', line);
// Animate line
this.animateLine(linePath);
}
// Add points if requested
if (this.options.showPoints) {
this.addPoints(xScale, yScale);
}
// Add interaction overlay
this.addInteractionOverlay(xScale, yScale);
return this;
}
/**
* Animate area drawing
*/
animateArea(areaPath) {
const totalLength = areaPath.node().getTotalLength();
areaPath
.attr('stroke-dasharray', totalLength + ' ' + totalLength)
.attr('stroke-dashoffset', totalLength)
.attr('stroke', this.options.areaColor)
.attr('stroke-width', 1)
.transition()
.duration(1500)
.attr('stroke-dashoffset', 0)
.on('end', function() {
d3.select(this).attr('stroke', 'none');
});
}
/**
* Animate line drawing
*/
animateLine(linePath) {
const totalLength = linePath.node().getTotalLength();
linePath
.attr('stroke-dasharray', totalLength + ' ' + totalLength)
.attr('stroke-dashoffset', totalLength)
.transition()
.duration(1500)
.attr('stroke-dashoffset', 0);
}
/**
* Add interactive points
*/
addPoints(xScale, yScale) {
const self = this;
const points = this.chartGroup.selectAll('.point')
.data(this.data)
.enter()
.append('circle')
.attr('class', 'point')
.attr('cx', d => xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('r', 0)
.attr('fill', this.options.lineColor)
.style('cursor', 'pointer');
// Animate points
points.transition()
.delay((d, i) => i * 50)
.duration(300)
.attr('r', this.options.pointRadius);
// Add interactivity
points
.on('mouseover', function(event, d) {
d3.select(this)
.transition()
.duration(150)
.attr('r', self.options.pointRadius * 1.5);
self.showTooltip(`(${d.x}, ${d.y})`, event);
})
.on('mouseout', function() {
d3.select(this)
.transition()
.duration(150)
.attr('r', self.options.pointRadius);
self.hideTooltip();
});
}
/**
* Add interaction overlay for hover effects
*/
addInteractionOverlay(xScale, yScale) {
const self = this;
// Create bisector for finding closest data point
const bisect = d3.bisector(d => d.x).left;
// Add invisible overlay for mouse tracking
const overlay = this.chartGroup.append('rect')
.attr('class', 'overlay')
.attr('width', this.innerWidth)
.attr('height', this.innerHeight)
.attr('fill', 'none')
.attr('pointer-events', 'all')
.style('cursor', 'crosshair');
// Add focus elements
const focus = this.chartGroup.append('g')
.attr('class', 'focus')
.style('display', 'none');
focus.append('circle')
.attr('r', 4)
.attr('fill', this.options.lineColor)
.attr('stroke', '#fff')
.attr('stroke-width', 2);
focus.append('line')
.attr('class', 'x-hover-line')
.attr('stroke', '#999')
.attr('stroke-width', 1)
.attr('stroke-dasharray', '3,3');
focus.append('line')
.attr('class', 'y-hover-line')
.attr('stroke', '#999')
.attr('stroke-width', 1)
.attr('stroke-dasharray', '3,3');
// Mouse events
overlay
.on('mouseover', () => focus.style('display', null))
.on('mouseout', () => {
focus.style('display', 'none');
self.hideTooltip();
})
.on('mousemove', function(event) {
const [mouseX] = d3.pointer(event, this);
const x0 = xScale.invert(mouseX);
const i = bisect(self.data, x0, 1);
if (i >= self.data.length) return;
const d0 = self.data[i - 1];
const d1 = self.data[i];
const d = x0 - d0.x > d1.x - x0 ? d1 : d0;
focus.attr('transform', `translate(${xScale(d.x)}, ${yScale(d.y)})`);
focus.select('.x-hover-line')
.attr('y1', -yScale(d.y))
.attr('y2', self.innerHeight - yScale(d.y));
focus.select('.y-hover-line')
.attr('x1', -xScale(d.x))
.attr('x2', self.innerWidth - xScale(d.x));
self.showTooltip(`(${d.x}, ${d.y})`, event);
});
}
/**
* Render stacked area chart
*/
renderStacked(seriesData) {
if (!seriesData || seriesData.length === 0) {
console.warn('No series data provided for stacked AreaChart');
return this;
}
// Clear previous chart
this.chartGroup.selectAll('*').remove();
// Prepare data for stacking
const keys = seriesData.map(d => d.name);
const stackData = this.prepareStackData(seriesData);
// Create stack generator
const stack = d3.stack()
.keys(keys)
.order(d3.stackOrderNone)
.offset(d3.stackOffsetNone);
const stackedData = stack(stackData);
// Create scales
const xScale = d3.scaleLinear()
.domain(d3.extent(stackData, d => d.x))
.range([0, this.innerWidth]);
const yScale = d3.scaleLinear()
.domain([0, d3.max(stackedData, d => d3.max(d, d => d[1]))])
.range([this.innerHeight, 0]);
// Color scale
const colorScale = d3.scaleOrdinal(d3.schemeCategory10);
// Create area generator
const area = d3.area()
.x(d => xScale(d.data.x))
.y0(d => yScale(d[0]))
.y1(d => yScale(d[1]))
.curve(this.options.curve);
// Create axes
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
// Add axes
this.chartGroup.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0, ${this.innerHeight})`)
.call(xAxis);
this.chartGroup.append('g')
.attr('class', 'y-axis')
.call(yAxis);
// Add areas
this.chartGroup.selectAll('.area')
.data(stackedData)
.enter()
.append('path')
.attr('class', 'area')
.attr('fill', (d, i) => colorScale(i))
.attr('d', area)
.style('opacity', 0.8);
// Add legend
const legendItems = keys.map((key, i) => ({
label: key,
color: colorScale(i)
}));
this.addLegend(legendItems);
return this;
}
/**
* Prepare data for stacking
*/
prepareStackData(seriesData) {
// Get all unique x values
const allXValues = [...new Set(
seriesData.flatMap(series => series.data.map(d => d.x))
)].sort((a, b) => a - b);
// Create combined data structure
return allXValues.map(x => {
const dataPoint = { x };
seriesData.forEach(series => {
const point = series.data.find(d => d.x === x);
dataPoint[series.name] = point ? point.y : 0;
});
return dataPoint;
});
}
/**
* Update chart with new data
*/
updateData(newData) {
this.setData(newData);
this.render();
return this;
}
}
/**
* Donut Chart implementation (extends PieChart)
*/
class DonutChart extends PieChart {
constructor(container, options = {}) {
const defaultOptions = {
innerRadius: 0.5, // Ratio of outer radius
showCenterText: true,
centerText: '',
centerTextSize: '24px',
centerTextColor: '#333',
...options
};
super(container, defaultOptions);
}
/**
* Render the donut chart
*/
render() {
if (!this.data || this.data.length === 0) {
console.warn('No data provided for DonutChart');
return this;
}
// Calculate radius if not provided
const outerRadius = this.options.outerRadius ||
Math.min(this.innerWidth, this.innerHeight) / 2 - 10;
// Set inner radius based on ratio
if (typeof this.options.innerRadius === 'number' && this.options.innerRadius < 1) {
this.options.innerRadius = outerRadius * this.options.innerRadius;
}
// Call parent render method
super.render();
// Add center text if requested
if (this.options.showCenterText) {
this.addCenterText();
}
return this;
}
/**
* Add text in the center of the donut
*/
addCenterText() {
this.innerWidth / 2;
this.innerHeight / 2;
const centerGroup = this.chartGroup.select('g')
.append('g')
.attr('class', 'center-text');
// Main center text
const centerText = this.options.centerText || this.calculateTotal();
centerGroup.append('text')
.attr('class', 'center-main-text')
.attr('text-anchor', 'middle')
.attr('alignment-baseline', 'middle')
.style('font-size', this.options.centerTextSize)
.style('font-weight', 'bold')
.style('fill', this.options.centerTextColor)
.text(centerText);
// Optional subtitle
if (this.options.centerSubtext) {
centerGroup.append('text')
.attr('class', 'center-sub-text')
.attr('text-anchor', 'middle')
.attr('alignment-baseline', 'middle')
.attr('dy', '1.5em')
.style('font-size', '14px')
.style('fill', '#666')
.text(this.options.centerSubtext);
}
}
/**
* Calculate total value for center display
*/
calculateTotal() {
return d3.sum(this.data, d => d.value);
}
/**
* Update center text
*/
updateCenterText(text, subtext = null) {
const centerGroup = this.chartGroup.select('.center-text');
if (!centerGroup.empty()) {
centerGroup.select('.center-main-text').text(text);
if (subtext !== null) {
let subtextElement = centerGroup.select('.center-sub-text');
if (subtextElement.empty()) {
subtextElement = centerGroup.append('text')
.attr('class', 'center-sub-text')
.attr('text-anchor', 'middle')
.attr('alignment-baseline', 'middle')
.attr('dy', '1.5em')
.style('font-size', '14px')
.style('fill', '#666');
}
subtextElement.text(subtext);
}
}
return this;
}
/**
* Create a progress donut (single value with remaining)
*/
renderProgress(value, total, options = {}) {
const progressOptions = {
progressColor: '#3498db',
remainingColor: '#ecf0f1',
showPercentage: true,
...options
};
const percentage = (value / total) * 100;
const remaining = total - value;
const progressData = [
{ label: 'Progress', value: value, color: progressOptions.progressColor },
{ label: 'Remaining', value: remaining, color: progressOptions.remainingColor }
];
this.setData(progressData);
this.options.colors = [progressOptions.progressColor, progressOptions.remainingColor];
this.options.showLabels = false;
this.render();
// Update center text with percentage
if (progressOptions.showPercentage) {
this.updateCenterText(`${percentage.toFixed(1)}%`, 'Complete');
}
return this;
}
/**
* Create animated progress donut
*/
animateProgress(targetValue, total, duration = 2000, options = {}) {
const progressOptions = {
progressColor: '#3498db',
remainingColor: '#ecf0f1',
...options
};
// Start with 0 progress
this.renderProgress(0, total, progressOptions);
// Animate to target value
const self = this;
const startTime = Date.now();
function animate() {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const currentValue = targetValue * progress;
self.renderProgress(currentValue, total, progressOptions);
if (progress < 1) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
return this;
}
/**
* Add interactive hover effects for donut segments
*/
addDonutInteractivity() {
const self = this;
const paths = this.chartGroup.selectAll('.slice path');
paths
.on('mouseover', function(event, d) {
// Highlight segment
d3.select(this)
.transition()
.duration(200)
.style('opacity', 0.8);
// Update center text with segment info
const percentage = ((d.endAngle - d.startAngle) / (2 * Math.PI) * 100).toFixed(1);
self.updateCenterText(d.data.value, `${d.data.label} (${percentage}%)`);
// Show tooltip
const tooltipContent = `${d.data.label}: ${d.data.value} (${percentage}%)`;
self.showTooltip(tooltipContent, event);
})
.on('mouseout', function(event, d) {
// Reset segment
d3.select(this)
.transition()
.duration(200)
.style('opacity', 1);
// Reset center text
const centerText = self.options.centerText || self.calculateTotal();
self.updateCenterText(centerText, self.options.centerSubtext);
self.hideTooltip();
});
return this;
}
/**
* Create multi-level donut chart
*/
renderMultiLevel(innerData, outerData) {
// Clear previous chart
this.chartGroup.selectAll('*').remove();
const centerX = this.innerWidth / 2;
const centerY = this.innerHeight / 2;
const maxRadius = Math.min(this.innerWidth, this.innerHeight) / 2 - 10;
const chartCenter = this.chartGroup.append('g')
.attr('transform', `translate(${centerX}, ${centerY})`);
// Inner donut
this.renderDonutLevel(chartCenter, innerData, maxRadius * 0.3, maxRadius * 0.6, 'inner');
// Outer donut
this.renderDonutLevel(chartCenter, outerData, maxRadius * 0.7, maxRadius, 'outer');
return this;
}
/**
* Render a single level of multi-level donut
*/
renderDonutLevel(container, data, innerRadius, outerRadius, className) {
const pie = d3.pie()
.value(d => d.value)
.sort(null);
const arc = d3.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius);
const colorScale = d3.scaleOrdinal(d3.schemeCategory10);
const slices = container.selectAll(`.${className}-slice`)
.data(pie(data))
.enter()
.append('g')
.attr('class', `${className}-slice`);
slices.append('path')
.attr('fill', (d, i) => colorScale(i))
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.attr('d', arc)
.each(function(d) { this._current = { startAngle: 0, endAngle: 0 }; })
.transition()
.duration(1000)
.attrTween('d', function(d) {
const interpolate = d3.interpolate(this._current, d);
this._current = interpolate(0);
return function(t) {
return arc(interpolate(t));
};
});
return this;
}
}
/**
* Histogram implementation
*/
class Histogram extends BaseChart {
constructor(container, options = {}) {
const defaultOptions = {
bins: 20,
barColor: '#3498db',
hoverColor: '#2980b9',
showDensity: false,
densityColor: '#e74c3c',
densityWidth: 2,
...options
};
super(container, defaultOptions);
this.addTooltip();
}
/**
* Render the histogram
*/
render() {
if (!this.data || this.data.length === 0) {
console.warn('No data provided for Histogram');
return this;
}
// Clear previous chart
this.chartGroup.selectAll('*').remove();
// Extract values from data
const values = this.data.map(d => typeof d === 'object' ? d.value : d);
// Create histogram bins
const histogram = d3.histogram()
.domain(d3.extent(values))
.thresholds(this.options.bins);
const bins = histogram(values);
// Create scales
const xScale = d3.scaleLinear()
.domain(d3.extent(values))
.range([0, this.innerWidth]);
const yScale = d3.scaleLinear()
.domain([0, d3.max(bins, d => d.length)])
.range([this.innerHeight, 0]);
// Create axes
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
// Add X axis
this.chartGroup.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0, ${this.innerHeight})`)
.call(xAxis);
// Add Y axis
this.chartGroup.append('g')
.attr('class', 'y-axis')
.call(yAxis);
// Add