UNPKG

d3-charts-viz-library

Version:

A comprehensive D3-based chart library with customizable charts and graphs

391 lines (340 loc) 10.6 kB
import * as d3 from 'd3'; import BaseChart from './BaseChart.js'; /** * CalendarHeatmapChart class for creating calendar-based heatmap visualizations * Perfect for showing activity patterns over time (like GitHub contribution graphs) */ class CalendarHeatmapChart extends BaseChart { constructor(container, options = {}) { const defaultOptions = { width: 900, height: 200, margin: { top: 20, right: 20, bottom: 20, left: 50 }, colorScheme: 'green', // 'green', 'blue', 'purple', 'orange' showTooltip: true, showLegend: true, cellSize: 12, cellPadding: 2, showMonthLabels: true, showWeekdayLabels: true, showYearLabel: true, animated: true, year: new Date().getFullYear(), ...options }; super(container, defaultOptions); // Initialize color schemes after super() call this.colorSchemes = { green: { colors: ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'], name: 'GitHub Green' }, blue: { colors: ['#f0f9ff', '#7dd3fc', '#38bdf8', '#0ea5e9', '#0284c7'], name: 'Ocean Blue' }, purple: { colors: ['#faf5ff', '#c4b5fd', '#a78bfa', '#8b5cf6', '#7c3aed'], name: 'Royal Purple' }, orange: { colors: ['#fff7ed', '#fed7aa', '#fdba74', '#fb923c', '#f97316'], name: 'Sunset Orange' } }; // Date utilities this.timeWeek = d3.timeWeek; this.timeDay = d3.timeDay; this.timeFormat = d3.timeFormat; this.timeParse = d3.timeParse; if (this.options.showTooltip) { this.addTooltip(); } } /** * Initialize the chart */ init() { super.init(); // Ensure colorSchemes is initialized (in case init is called before constructor completes) if (!this.colorSchemes) { this.colorSchemes = { green: { colors: ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'], name: 'GitHub Green' }, blue: { colors: ['#f0f9ff', '#7dd3fc', '#38bdf8', '#0ea5e9', '#0284c7'], name: 'Ocean Blue' }, purple: { colors: ['#faf5ff', '#c4b5fd', '#a78bfa', '#8b5cf6', '#7c3aed'], name: 'Royal Purple' }, orange: { colors: ['#fff7ed', '#fed7aa', '#fdba74', '#fb923c', '#f97316'], name: 'Sunset Orange' } }; } // Calculate dimensions based on year this.year = this.options.year; this.cellSize = this.options.cellSize; this.cellPadding = this.options.cellPadding; // Date range for the year this.yearStart = new Date(this.year, 0, 1); this.yearEnd = new Date(this.year, 11, 31); // Calculate actual chart dimensions const weeksInYear = d3.timeWeek.count(this.yearStart, this.yearEnd) + 1; this.chartWidth = weeksInYear * (this.cellSize + this.cellPadding); this.chartHeight = 7 * (this.cellSize + this.cellPadding); // Update container dimensions if needed if (this.options.width < this.chartWidth + this.options.margin.left + this.options.margin.right) { this.options.width = this.chartWidth + this.options.margin.left + this.options.margin.right; this.svg.attr('width', this.options.width); } if (this.options.height < this.chartHeight + this.options.margin.top + this.options.margin.bottom + 60) { this.options.height = this.chartHeight + this.options.margin.top + this.options.margin.bottom + 60; this.svg.attr('height', this.options.height); } // Create scales this.setupScales(); // Add labels if (this.options.showWeekdayLabels) { this.addWeekdayLabels(); } if (this.options.showMonthLabels) { this.addMonthLabels(); } if (this.options.showYearLabel) { this.addYearLabel(); } if (this.options.showLegend) { this.addLegend(); } } /** * Setup scales for the calendar */ setupScales() { // Color scale based on data values const colorScheme = this.colorSchemes[this.options.colorScheme]; this.colorScale = d3.scaleQuantile() .range(colorScheme.colors); // Position scales this.xScale = (date) => d3.timeWeek.count(this.yearStart, date) * (this.cellSize + this.cellPadding); this.yScale = (date) => date.getDay() * (this.cellSize + this.cellPadding); } /** * Add weekday labels */ addWeekdayLabels() { const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; this.chartGroup.selectAll('.weekday-label') .data(weekdays) .enter() .append('text') .attr('class', 'weekday-label') .attr('x', -10) .attr('y', (d, i) => i * (this.cellSize + this.cellPadding) + this.cellSize / 2) .attr('text-anchor', 'end') .attr('dominant-baseline', 'middle') .style('font-size', '10px') .style('fill', '#666') .style('font-family', 'monospace') .text(d => d); } /** * Add month labels */ addMonthLabels() { const monthLabels = this.chartGroup.selectAll('.month-label') .data(d3.timeMonths(this.yearStart, new Date(this.year + 1, 0, 1))) .enter() .append('text') .attr('class', 'month-label') .attr('x', d => this.xScale(d)) .attr('y', -10) .style('font-size', '10px') .style('fill', '#666') .style('font-family', 'monospace') .text(d => d3.timeFormat('%b')(d)); } /** * Add year label */ addYearLabel() { this.svg.append('text') .attr('class', 'year-label') .attr('x', this.options.margin.left) .attr('y', this.options.height - 10) .style('font-size', '12px') .style('font-weight', 'bold') .style('fill', '#333') .text(this.year); } /** * Add legend */ addLegend() { const legendGroup = this.svg.append('g') .attr('class', 'legend') .attr('transform', `translate(${this.options.width - 200}, ${this.options.height - 30})`); legendGroup.append('text') .attr('x', 0) .attr('y', -5) .style('font-size', '10px') .style('fill', '#666') .text('Less'); legendGroup.append('text') .attr('x', 80) .attr('y', -5) .style('font-size', '10px') .style('fill', '#666') .text('More'); const colorScheme = this.colorSchemes[this.options.colorScheme]; legendGroup.selectAll('.legend-cell') .data(colorScheme.colors) .enter() .append('rect') .attr('class', 'legend-cell') .attr('x', (d, i) => i * 12) .attr('y', 0) .attr('width', 10) .attr('height', 10) .attr('fill', d => d) .attr('stroke', '#ccc') .attr('stroke-width', 0.5); } /** * Render the calendar heatmap */ render() { if (!this.data) { console.warn('No data provided to CalendarHeatmapChart'); return this; } // Process data into a map for quick lookup this.dataMap = new Map(); let maxValue = 0; this.data.forEach(d => { const date = typeof d.date === 'string' ? new Date(d.date) : d.date; const dateStr = d3.timeFormat('%Y-%m-%d')(date); this.dataMap.set(dateStr, d.value || 0); maxValue = Math.max(maxValue, d.value || 0); }); // Update color scale domain this.colorScale.domain([0, maxValue]); // Generate all days in the year const days = d3.timeDays(this.yearStart, new Date(this.year + 1, 0, 1)); // Create calendar cells const cells = this.chartGroup.selectAll('.day-cell') .data(days, d => d3.timeFormat('%Y-%m-%d')(d)); // Remove old cells cells.exit().remove(); // Add new cells const cellsEnter = cells.enter() .append('rect') .attr('class', 'day-cell') .attr('width', this.cellSize) .attr('height', this.cellSize) .attr('rx', 2) .attr('ry', 2) .attr('stroke', '#ccc') .attr('stroke-width', 0.5); // Merge and update all cells const cellsUpdate = cellsEnter.merge(cells) .attr('x', d => this.xScale(d)) .attr('y', d => this.yScale(d)) .attr('fill', d => { const dateStr = d3.timeFormat('%Y-%m-%d')(d); const value = this.dataMap.get(dateStr) || 0; return this.colorScale(value); }); // Add interactions if (this.options.showTooltip) { cellsUpdate .on('mouseover', (event, d) => { const dateStr = d3.timeFormat('%Y-%m-%d')(d); const value = this.dataMap.get(dateStr) || 0; const formattedDate = d3.timeFormat('%B %d, %Y')(d); this.showTooltip( `<strong>${formattedDate}</strong><br/>Value: ${value}`, event ); d3.select(event.target) .attr('stroke', '#333') .attr('stroke-width', 2); }) .on('mouseout', (event) => { this.hideTooltip(); d3.select(event.target) .attr('stroke', '#ccc') .attr('stroke-width', 0.5); }); } // Add animations if (this.options.animated) { cellsEnter .style('opacity', 0) .transition() .duration(50) .delay((d, i) => i * 2) .style('opacity', 1); } return this; } /** * Update the year and re-render */ updateYear(year) { this.options.year = year; this.init(); this.render(); return this; } /** * Update color scheme and re-render */ updateColorScheme(colorScheme) { this.options.colorScheme = colorScheme; this.setupScales(); this.render(); return this; } /** * Update data and re-render */ updateData(newData) { this.setData(newData); this.render(); return this; } /** * Get data for a specific date */ getDataForDate(date) { const dateStr = d3.timeFormat('%Y-%m-%d')(date); return this.dataMap.get(dateStr) || 0; } /** * Set data for a specific date */ setDataForDate(date, value) { const dateStr = d3.timeFormat('%Y-%m-%d')(date); this.dataMap.set(dateStr, value); // Update the original data array const existingIndex = this.data.findIndex(d => { const itemDate = typeof d.date === 'string' ? new Date(d.date) : d.date; return d3.timeFormat('%Y-%m-%d')(itemDate) === dateStr; }); if (existingIndex >= 0) { this.data[existingIndex].value = value; } else { this.data.push({ date: new Date(date), value }); } this.render(); return this; } } export default CalendarHeatmapChart;